Compare commits

...
2 Commits
Author SHA1 Message Date
lobo 0ddb345a41 feat(psn): sync PlayStation trophies as achievements
Build, Test & Push Core Service / docker (push) Successful in 1m49s
Build, Test & Push Core Service / quality-check (push) Successful in 1m30s
Sets map by exact title id, else by name and platform.
Trophy gains count as achievements; platinum or all earned is perfect.
Max 30 PSN calls per run; trophy lists load only when changed.
Hidden trophies stay hidden (name and detail) until earned.
2026-10-08 09:18:15 +02:00
lobo f5ca7237a9 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.
2026-10-08 08:15:37 +02:00
95 changed files with 5136 additions and 350 deletions

No files matched your search

@@ -0,0 +1,60 @@
{
"trophySetVersion": "01.00",
"hasTrophyGroups": true,
"trophies": [
{
"trophyId": 0,
"trophyHidden": false,
"trophyType": "platinum",
"trophyName": "Legend of Yōtei",
"trophyDetail": "Earn all other trophies.",
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t0.png",
"trophyGroupId": "default"
},
{
"trophyId": 1,
"trophyHidden": false,
"trophyType": "gold",
"trophyName": "Wolf of Ezo",
"trophyDetail": "Finish the story.",
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t1.png",
"trophyGroupId": "default"
},
{
"trophyId": 2,
"trophyHidden": false,
"trophyType": "silver",
"trophyName": "Lone Blade",
"trophyDetail": "Win a duel.",
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t2.png",
"trophyGroupId": "default"
},
{
"trophyId": 3,
"trophyHidden": false,
"trophyType": "bronze",
"trophyName": "First Steps",
"trophyDetail": "Leave the village.",
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t3.png",
"trophyGroupId": "default"
},
{
"trophyId": 4,
"trophyHidden": true,
"trophyType": "bronze",
"trophyName": "Secret Name",
"trophyDetail": "Secret detail text.",
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t4.png",
"trophyGroupId": "default"
},
{
"trophyId": 5,
"trophyHidden": false,
"trophyType": "bronze",
"trophyName": "Island Trial",
"trophyDetail": "Finish the DLC trial.",
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t5.png",
"trophyGroupId": "001"
}
]
}
@@ -0,0 +1,33 @@
{
"trophySetVersion": "01.00",
"hasTrophyGroups": false,
"trophies": [
{
"trophyId": 0,
"trophyHidden": false,
"trophyType": "bronze",
"trophyName": "Blue Bomber",
"trophyDetail": "Start the game.",
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t0.png",
"trophyGroupId": "default"
},
{
"trophyId": 1,
"trophyHidden": false,
"trophyType": "bronze",
"trophyName": "Rock On",
"trophyDetail": "Beat Mega Man 1.",
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t1.png",
"trophyGroupId": "default"
},
{
"trophyId": 2,
"trophyHidden": false,
"trophyType": "silver",
"trophyName": "Robot Master",
"trophyDetail": "Beat Mega Man 2.",
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t2.png",
"trophyGroupId": "default"
}
]
}
@@ -0,0 +1,58 @@
{
"trophySetVersion": "01.00",
"hasTrophyGroups": true,
"trophies": [
{
"trophyId": 0,
"trophyHidden": false,
"trophyType": "platinum",
"earned": true,
"trophyRare": 3,
"trophyEarnedRate": "2.1",
"earnedDateTime": "2026-09-20T18:00:00Z"
},
{
"trophyId": 1,
"trophyHidden": false,
"trophyType": "gold",
"earned": true,
"trophyRare": 3,
"trophyEarnedRate": "30.5",
"earnedDateTime": "2026-09-21T18:00:00Z"
},
{
"trophyId": 2,
"trophyHidden": false,
"trophyType": "silver",
"earned": true,
"trophyRare": 3,
"trophyEarnedRate": "55.0",
"earnedDateTime": "2026-09-22T18:00:00Z"
},
{
"trophyId": 3,
"trophyHidden": false,
"trophyType": "bronze",
"earned": true,
"trophyRare": 3,
"trophyEarnedRate": "90.2",
"earnedDateTime": "2026-09-23T18:00:00Z"
},
{
"trophyId": 4,
"trophyHidden": true,
"trophyType": "bronze",
"earned": false,
"trophyRare": 3,
"trophyEarnedRate": "1.3"
},
{
"trophyId": 5,
"trophyHidden": false,
"trophyType": "bronze",
"earned": false,
"trophyRare": 3,
"trophyEarnedRate": "12.0"
}
]
}
@@ -0,0 +1,31 @@
{
"trophySetVersion": "01.00",
"hasTrophyGroups": false,
"trophies": [
{
"trophyId": 0,
"trophyHidden": false,
"trophyType": "bronze",
"earned": false,
"trophyRare": 3,
"trophyEarnedRate": "40.0"
},
{
"trophyId": 1,
"trophyHidden": false,
"trophyType": "bronze",
"earned": true,
"earnedDateTime": "2024-03-02T16:10:30Z",
"trophyRare": 3,
"trophyEarnedRate": "25.5"
},
{
"trophyId": 2,
"trophyHidden": false,
"trophyType": "silver",
"earned": false,
"trophyRare": 2,
"trophyEarnedRate": "10.0"
}
]
}
@@ -0,0 +1,33 @@
{
"titles": [
{
"npTitleId": "PPSA26344_00",
"trophyTitles": [
{
"npServiceName": "trophy2",
"npCommunicationId": "NPWR90001_00",
"trophySetVersion": "01.00",
"trophyTitleName": "Ghost of Yōtei",
"trophyTitleIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/ghost-set.png",
"trophyTitlePlatform": "PS5",
"hasTrophyGroups": true,
"definedTrophies": {
"bronze": 3,
"silver": 1,
"gold": 1,
"platinum": 1
},
"progress": 66,
"earnedTrophies": {
"bronze": 1,
"silver": 1,
"gold": 1,
"platinum": 1
},
"hiddenFlag": false,
"lastUpdatedDateTime": "2026-10-01T20:41:07Z"
}
]
}
]
}
@@ -0,0 +1,102 @@
{
"totalItemCount": 4,
"nextOffset": null,
"trophyTitles": [
{
"npServiceName": "trophy2",
"npCommunicationId": "NPWR90001_00",
"trophySetVersion": "01.00",
"trophyTitleName": "Ghost of Yōtei",
"trophyTitleIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/ghost-set.png",
"trophyTitlePlatform": "PS5",
"hasTrophyGroups": true,
"definedTrophies": {
"bronze": 3,
"silver": 1,
"gold": 1,
"platinum": 1
},
"progress": 66,
"earnedTrophies": {
"bronze": 1,
"silver": 1,
"gold": 1,
"platinum": 1
},
"hiddenFlag": false,
"lastUpdatedDateTime": "2026-10-01T20:41:07Z"
},
{
"npServiceName": "trophy",
"npCommunicationId": "NPWR90002_00",
"trophySetVersion": "01.00",
"trophyTitleName": "Mega Man Legacy Collection",
"trophyTitleIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/megaman-set.png",
"trophyTitlePlatform": "PS4",
"hasTrophyGroups": false,
"definedTrophies": {
"bronze": 2,
"silver": 1,
"gold": 0,
"platinum": 0
},
"progress": 33,
"earnedTrophies": {
"bronze": 1,
"silver": 0,
"gold": 0,
"platinum": 0
},
"hiddenFlag": false,
"lastUpdatedDateTime": "2024-03-02T16:10:30Z"
},
{
"npServiceName": "trophy",
"npCommunicationId": "NPWR90003_00",
"trophySetVersion": "01.00",
"trophyTitleName": "Old PS3 Game",
"trophyTitleIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/ps3-set.png",
"trophyTitlePlatform": "PS3",
"hasTrophyGroups": false,
"definedTrophies": {
"bronze": 5,
"silver": 0,
"gold": 0,
"platinum": 0
},
"progress": 20,
"earnedTrophies": {
"bronze": 1,
"silver": 0,
"gold": 0,
"platinum": 0
},
"hiddenFlag": false,
"lastUpdatedDateTime": "2012-05-01T10:00:00Z"
},
{
"npServiceName": "trophy",
"npCommunicationId": "NPWR90004_00",
"trophySetVersion": "01.00",
"trophyTitleName": "Hidden By The User",
"trophyTitleIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/hidden-set.png",
"trophyTitlePlatform": "PS4",
"hasTrophyGroups": false,
"definedTrophies": {
"bronze": 1,
"silver": 0,
"gold": 0,
"platinum": 0
},
"progress": 0,
"earnedTrophies": {
"bronze": 0,
"silver": 0,
"gold": 0,
"platinum": 0
},
"hiddenFlag": true,
"lastUpdatedDateTime": "2020-01-01T10:00:00Z"
}
]
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/psn/trophy/v1/npCommunicationIds/NPWR90001_00/trophyGroups/all/trophies"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "psn-trophies-NPWR90001_00.json"
}
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/psn/trophy/v1/npCommunicationIds/NPWR90002_00/trophyGroups/all/trophies"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "psn-trophies-NPWR90002_00.json"
}
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/psn/trophy/v1/users/me/npCommunicationIds/NPWR90001_00/trophyGroups/all/trophies"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "psn-trophies-earned-NPWR90001_00.json"
}
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/psn/trophy/v1/users/me/npCommunicationIds/NPWR90002_00/trophyGroups/all/trophies"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "psn-trophies-earned-NPWR90002_00.json"
}
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/psn/trophy/v1/users/me/titles/trophyTitles"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "psn-trophy-title-sets.json"
}
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/psn/trophy/v1/users/me/trophyTitles"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "psn-trophy-titles.json"
}
}
+77 -24
View File
@@ -137,8 +137,10 @@ 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. The same sync reads the PS4/PS5 **trophy
sets** of the matched games: they count as achievements (platinum = perfect game) in the detail page, stats, tiles,
public profile, sync banner and follower digest. 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,19 +213,19 @@ 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 |
| `GET` | `/api/library/games/{appid}` (detail; slice 3: `achievements[]` also hold trophies with `kind` `STEAM`\|`PSN`, `setId`, `trophyType`, `group`, `rarity`; new `achievementSets[]` = `{id, kind, platform, name, total, unlocked, perfect, visible, listed}`, Steam first, then PS5 before PS4; `hasAchievements` = any set; the top-level counts stay Steam only) | JWT |
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
| `GET` | `/api/library/backlog?limit=&released=` (`{games}` in the user's order; `limit` 1-20, none = all) | JWT |
| `PUT` | `/api/library/backlog` (`{appids}` = the whole order; `204`) | JWT |
| `GET` | `/api/library/picker?maxMinutes=&mood=&exclude=&category=` (`{categories:[{source, games}]}`; each game has a `reason` object; `category` returns that row only) | JWT |
| `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/achievements/summary` (Steam achievements and PlayStation trophies; one entry per game, platinum = perfect) | 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 |
@@ -286,8 +288,8 @@ flowchart LR
Igdb --> IgdbApi["IGDB API"]
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")]
Psn -->|"NPSSO sign-in, played titles, trophy sets and lists"| PsnApi["PlayStation API (unofficial)"]
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 +299,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` |
@@ -306,7 +308,7 @@ flowchart LR
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
| `psn_adapter` | PlayStation client (unofficial): NPSSO to tokens, refresh, played titles |
| `psn_adapter` | PlayStation client (unofficial): NPSSO to tokens, refresh, played titles, trophy sets and lists |
`follow` and `inbox` read `account_name`, `public_user`, `blocked_user`, `deleted_user`, `sync_result`, `sync_change`,
`user_game` and `user_game_status_change` in SQL; the sync tells `follow` through `shared.event.LibraryActivityEvent`.
@@ -360,6 +362,9 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
in the background. The achievement sync also fetches Steam's global unlock rates with each schema, and sets
FINISHED for a game whose ending achievement is unlocked while the user never chose its status.
The PSN step (`PsnSyncStep`, then `PsnTrophyStep`, see "PlayStation link") runs after the Steam fetch and before
the transaction; its `ADDED`, `PLAYTIME` and trophy `ACHIEVEMENTS` changes join the same `sync_result`.
The frontend polls `GET /api/library/sync-status` until the run ends.
Followers (#27): when the achievement run ends, `AchievementSyncService` publishes `LibraryActivityEvent`, and
@@ -371,10 +376,13 @@ minutes; it never takes a sync result, which may still wait for its achievements
```mermaid
sequenceDiagram
participant W as LibrarySyncWorker
participant P as PsnSyncStep + PsnTrophyStep
participant A as AchievementSyncService
participant F as FollowDigestService
participant D as Db
W->>D: sync_result, sync_change (ADDED, STATUS, WISHLIST)
W->>P: run (titles, trophy summaries, changed lists)
P-->>W: ADDED, PLAYTIME, ACHIEVEMENTS (trophy gains)
W->>D: sync_result, sync_change (ADDED, STATUS, WISHLIST, PSN changes)
W-->>A: LibrarySyncedEvent
A->>D: sync_change (ACHIEVEMENTS)
A-->>F: LibraryActivityEvent
@@ -595,19 +603,62 @@ project ships it; all values in `app.psn.*`). `PsnAccountService` in `library` h
Only the **refresh token** is stored, in `user_psn_account`, sealed with AES-256-GCM (`SecretBox`, key
`APP_PSN_TOKEN_KEY`, base64 of 32 bytes). The NPSSO, the code and the access token are never stored; no log line,
answer or `toString` of a token type holds a secret. No key = the feature is off (`enabled: false`); a key of the
wrong length stops Core at start. A changed key makes stored links unreadable: the import answers `PSN_RELINK`.
wrong length stops Core at start. A changed key makes stored links unreadable: the sync skips PlayStation and the user links again.
- **Expiry**: the refresh token lives about 10 days (`refresh_expires_at`). A refresh that returns the same
refresh token keeps the stored expiry; a new one stores its own. An expired link answers `409 PSN_RELINK`; the user
pastes a new NPSSO. The Settings page shows the date.
- **Import** (`POST /api/library/psn-account/imports`): one refresh call, then `gamelist/v2` (200 per page, at most
1000 titles). `PsnTitles` cleans the names (no `™`/`®`, no PS4/PS5 suffix) and joins the PS4 and PS5 versions of one
`concept.id` into one line (name and platform of the most played version, playtime summed). The lines go through
`GameImportService.createLines` as a `PSN` import: IGDB match, review, save, undo. Saved rows have `source = PSN` and
`platform` PS5 or PS4. A game also owned on Steam stays one row (the Steam row wins); its PSN playtime is not added.
refresh token keeps the stored expiry; a new one stores its own. The Settings page shows the date. The inbox gets one
note `PSN_EXPIRING` when the link ends within `app.psn.expiry-warning-days` (2) and one note `PSN_EXPIRED` when it has
ended or PlayStation refuses the refresh token (also an unreadable token). `user_psn_account.expiry_note` keeps what
was written, so a note comes once per link; a new link resets it. The library module writes `inbox_note` by SQL, as
the follow module does. An ended link makes the sync skip PlayStation until the user pastes a new NPSSO.
- **Sync**: linking starts a sync (`LibrarySyncService.startAfterLink`). Inside the force-sync cooldown or while a sync runs, one sync per user is scheduled for the end of the cooldown (in memory, lost on restart; the next login sync covers it).
After that the PSN step (`PsnSyncStep`) runs in every library sync (login, nightly, manual) after the Steam fetch.
One refresh call, then `gamelist/v2` (200 per page, at most 1000 titles). `PsnTitles` cleans the names (no `™`/`®`, no
PS4/PS5 suffix) and joins the PS4 and PS5 versions of one `concept.id` into one line (name and platform of the most
played version, playtime summed, newest `lastPlayedDateTime`). `user_psn_title` remembers each title by its
`psn_key` (`c<conceptId>`, else `t<titleId>`) as `LINKED` (with the library key) or `DISMISSED`:
- A **new** title (not in the table, not in an open PSN import) is searched in IGDB. Same name and a version on the
line's platform: `ManualGameService.addGame` as `PSN`, no review, an `ADDED` change. The same game under another
spelling or platform that the user owns is only linked. Everything else goes to the review: a READY `PSN` import with
the matched rows is created, or the rows are appended to the one that waits (`reviewImportId`). The review page is
the import page; saving a PSN import links the checked lines and dismisses the unchecked ones for good.
- A `LINKED` title updates its row (`source = PSN`, not removed): playtime, `last_played_at`, `PLAYTIME` changes and the
playtime history; `playtime_2weeks` is the sum of the history of the last 14 days. A game the user deleted stays
`LINKED` and is not added again. A game also owned on Steam stays one row (the Steam row wins).
- The first run of a user (no `user_psn_title` yet) is a baseline like the first Steam sync: its adds are a count in
`sync_result.imported_games`, not a list. PSN changes share the `sync_result` of the Steam part, so the banner and the
follow digest need no change.
- PlayStation and IGDB are called outside the sync transaction; the step catches every error, so PlayStation never
fails the Steam part. A user with a PlayStation link and **no Steam ID** syncs too (`user_steam_sync.steam_id` is
nullable): the Steam part is skipped, the same result, banner and digest apply.
The client cannot start a `PSN` import with its own rows.
- **Limits**: one limiter `psnApi` for the whole server (1 request / 4 s, taken per HTTP request). Titles bought but
never started are not listed by PlayStation. A `PSN` row pays no rewards (no Steam proof), and a merge undo does not
move it back. Repeating sync and trophies are not built.
move it back. The nightly loop is sequential.
- **Trophies** (V40, `PsnTrophyStep`, called at the end of `PsnSyncStep.run`, outside the sync transaction, never throws).
It reuses the access token of the title part (`FetchResult.Titles` hides it in `toString`). Own tables
`psn_trophy_set`, `psn_trophy`, `user_psn_trophy_set`, `user_psn_trophy` and the cache `psn_title_trophy_set`, so the
Steam tables and the rewards (Steam only) stay untouched.
- **Summary**: 1-2 calls `trophy/v1/users/me/trophyTitles` give every set with defined and earned counts,
`trophySetVersion` and `lastUpdatedDateTime`. Hidden (`hiddenFlag`) sets, PS3/Vita-only sets and sets of unknown or
DISMISSED titles are not stored. A summary row is saved for every mapped set. **Gains** (`ACHIEVEMENTS` changes) come
from the earned total only; the first time a set is stored is a baseline.
Only a **complete** summary (sets returned = the API's `totalItemCount`) removes the user's stored sets that are no
longer in it (another account was linked); a partial one, after a broken page, removes nothing. Unlink deletes all of
the user's trophy rows.
- **Mapping**, two paths, the log line `PSN trophies user=… mapped by title id=N by name=M` shows which one worked.
(1) Exact: `titles/trophyTitles?npTitleIds=` (5 ids per call) answers title id to set; cached globally, "no set" is
asked again after 30 days. Not confirmed against the real service: a 4xx or an empty answer falls through to (2)
Name + platform, same account only: the clean name equals that of exactly one linked title and its platform is in the
set's platform list. Two games with the same name: the set stays unmapped. The set counts for the merge target of the game.
- **Lists only when changed**: the global trophy list when the stored `list_version` differs from `trophySetVersion`;
the user's earned list (also gives the rarity) when `list_updated_at` differs from `lastUpdatedDateTime`; a set with
0 earned needs no earned call.
- **Budget** `app.psn.trophy-calls-per-run` (30, about 2 min at 1 request / 4 s; the sync counts as stale after 5 min)
covers title id lookups and lists. Newest sets first; the rest follow in later syncs (the detail page then says the
list loads with the next sync). Three errors in a row stop the list calls, the summaries are still saved.
- **Presentation**: `perfect()` = platinum or all earned; one entry per set, one per game for the counts (perfect set,
else best percent). A hidden trophy that is not earned has no name and no description. Stored image URLs must be `https://`.
A sync costs 2 calls (token, titles) plus the summary and the changed lists.
## Run locally
@@ -642,9 +693,11 @@ 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. The trophy stubs (Ghost of Yōtei PS5
by title id, Mega Man PS4 by name, one PS3 and one hidden set) are fake `NPWR9…` ids in `local/wiremock`.
- To see an IGDB link suggestion: add Hollow Knight by hand (game search, PS5). It shares IGDB's "same game" key with the
Steam copy, so the link queue (auditor) lists the pair with the IGDB badge.
@@ -22,7 +22,7 @@ public class AccountCleanupRepository {
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "backlog_order",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "user_psn_account", "reward_ledger", "reward_equipped",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_psn_trophy", "user_psn_trophy_set", "user_game", "game_link_request", "account_name", "game_import", "user_psn_title", "user_psn_account", "reward_ledger", "reward_equipped",
"account_role_change", "blocked_user", "listed_user", "follow", "follow_digest", "inbox_note", "public_user", "deleted_user");
// Every user with data has a row in one of these; deleteRows then removes the child rows too.
@@ -9,7 +9,7 @@ import java.util.List;
public record InboxDto(List<Note> results, int unread) {
/**
* @param kind DIGEST or VISIBILITY
* @param kind DIGEST, VISIBILITY, PSN_EXPIRING or PSN_EXPIRED (the PSN kinds have no displayName)
* @param displayName the name of the followed user when the note was written; never a user id
*/
public record Note(long id, String kind, String displayName, int newGames, int achievements, int statusChanges,
@@ -2,5 +2,8 @@ package core_service.modules.inbox.domain;
public enum NoteKind {
DIGEST,
VISIBILITY
VISIBILITY,
/** About the reader's own PlayStation link; subject is the reader. */
PSN_EXPIRING,
PSN_EXPIRED
}
@@ -62,7 +62,8 @@ public class GameImportController {
@PostMapping("/{id}/save")
public ResponseEntity<?> save(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id,
@Valid @RequestBody ImportSaveRequest request) {
return switch (importService.save(principal.getIdAsLong(), id, request.rows())) {
return switch (importService.save(principal.getIdAsLong(), id, request.rows(),
request.shownLineNos())) {
case DONE -> ResponseEntity.accepted().build();
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND);
case WRONG_STATUS, ALREADY_MATCHING -> ResponseEntity.status(HttpStatus.CONFLICT)
@@ -83,7 +83,6 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Objects;
import java.util.Set;
@@ -118,15 +117,11 @@ public class LibraryController {
@GetMapping("/games-count")
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
Long userId = principal.getIdAsLong();
boolean syncStarted = principal.steamIdText()
.map(steamId -> syncService.syncIfStale(userId, steamId))
.orElseGet(() -> {
syncService.stopNightlySync(userId);
return false;
});
String steamId = principal.steamIdText().orElse(null);
boolean syncStarted = syncService.syncIfStale(userId, steamId);
long count = queryService.countOwnedGames(userId);
return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null,
"syncStarted", syncStarted));
"canSync", syncService.canSync(userId, steamId), "syncStarted", syncStarted));
}
@GetMapping("/games")
@@ -142,7 +137,7 @@ public class LibraryController {
@RequestParam(defaultValue = "false") boolean hideDuplicates
) {
Long userId = principal.getIdAsLong();
principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId));
syncService.syncIfStale(userId, principal.steamIdText().orElse(null));
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
int safePage = Math.max(0, page);
@@ -164,13 +159,12 @@ public class LibraryController {
@PostMapping("/sync")
public ResponseEntity<?> forceSync(@AuthenticationPrincipal UserPrincipal principal) {
Optional<String> steamId = principal.steamIdText();
if (steamId.isEmpty()) {
return ResponseEntity.badRequest().body(new ErrorResponse("NO_STEAM_ID", "User has no Steam ID"));
}
try {
syncService.forceSync(principal.getIdAsLong(), steamId.get());
syncService.forceSync(principal.getIdAsLong(), principal.steamIdText().orElse(null));
return ResponseEntity.accepted().body(Map.of("status", "started"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("NO_STEAM_ID",
"User has no Steam ID or PlayStation link"));
} catch (SyncCooldownException e) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(e.getRetryAfterSeconds()))
@@ -493,7 +487,7 @@ public class LibraryController {
: exclude.stream().filter(Objects::nonNull).limit(MAX_PICKER_EXCLUDES).toList();
Integer budget = maxMinutes != null && maxMinutes > 0 ? maxMinutes : null;
Long userId = principal.getIdAsLong();
principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId));
syncService.syncIfStale(userId, principal.steamIdText().orElse(null));
return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, category));
}
@@ -2,6 +2,7 @@ package core_service.modules.library.api;
import core_service.modules.library.api.dto.PsnAccountDto;
import core_service.modules.library.api.dto.PsnLinkRequest;
import core_service.modules.library.service.LibrarySyncService;
import core_service.modules.library.service.PsnAccountService;
import core_service.shared.api.ErrorResponse;
import core_service.shared.security.UserPrincipal;
@@ -17,10 +18,8 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* The user's linked PlayStation account and its import. No answer repeats the request.
* The user's linked PlayStation account; linking starts a sync. No answer repeats the request.
*/
@RestController
@RequestMapping("/api/library/psn-account")
@@ -35,12 +34,16 @@ public class PsnAccountController {
"PlayStation did not answer; try again later");
private final PsnAccountService accountService;
private final LibrarySyncService syncService;
@PostMapping
public ResponseEntity<?> link(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody PsnLinkRequest request) {
return switch (accountService.link(principal.getIdAsLong(), request.npsso())) {
case PsnAccountService.LinkResult.Linked linked -> ResponseEntity.ok(dto(linked.status()));
case PsnAccountService.LinkResult.Linked linked -> {
syncService.startAfterLink(principal.getIdAsLong(), principal.steamIdText().orElse(null));
yield ResponseEntity.ok(dto(linked.status()));
}
case PsnAccountService.LinkResult.Rejected ignored -> error(HttpStatus.BAD_REQUEST, "NPSSO_INVALID",
"PlayStation did not accept the NPSSO; it may have expired");
case PsnAccountService.LinkResult.Off ignored -> PSN_OFF;
@@ -60,28 +63,9 @@ public class PsnAccountController {
return ResponseEntity.noContent().build();
}
@PostMapping("/imports")
public ResponseEntity<?> startImport(@AuthenticationPrincipal UserPrincipal principal) {
return switch (accountService.startImport(principal.getIdAsLong())) {
case PsnAccountService.ImportResult.Started started -> ResponseEntity.status(HttpStatus.ACCEPTED)
.body(Map.of("id", started.id()));
case PsnAccountService.ImportResult.NotLinked ignored -> error(HttpStatus.NOT_FOUND, "PSN_NOT_LINKED",
"No PlayStation account is linked");
case PsnAccountService.ImportResult.Relink ignored -> error(HttpStatus.CONFLICT, "PSN_RELINK",
"The PlayStation link has expired; link again with a new NPSSO");
case PsnAccountService.ImportResult.NoTitles ignored -> ResponseEntity.noContent().build();
case PsnAccountService.ImportResult.Off ignored -> PSN_OFF;
case PsnAccountService.ImportResult.Unavailable ignored -> PSN_ERROR;
case PsnAccountService.ImportResult.Busy ignored -> PSN_BUSY;
case PsnAccountService.ImportResult.AlreadyMatching ignored -> error(HttpStatus.CONFLICT, "IMPORT_RUNNING",
"Another import is still being matched");
case PsnAccountService.ImportResult.SearchUnavailable ignored -> error(HttpStatus.SERVICE_UNAVAILABLE,
"IGDB_OFF", "Importing games needs the IGDB app");
};
}
private static PsnAccountDto dto(PsnAccountService.Status status) {
return new PsnAccountDto(status.enabled(), status.linked(), status.linkedAt(), status.expiresAt());
return new PsnAccountDto(status.enabled(), status.linked(), status.linkedAt(), status.expiresAt(),
status.reviewImportId());
}
private static ResponseEntity<?> error(HttpStatus status, String code, String message) {
@@ -1,7 +1,20 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.AchievementKind;
import core_service.modules.library.domain.TrophyType;
import java.time.Instant;
/**
* One Steam achievement or PlayStation trophy. A hidden PlayStation trophy that is not earned has no name and no
* description.
*
* @param apiName Steam: the api name; PSN: the trophy id
* @param setId null for Steam; PSN: the npCommunicationId
* @param trophyType PSN only
* @param group PSN only: null for the base game, "001" and up for DLC
* @param rarity percent of all players who have it; null when unknown
*/
public record AchievementDto(
String apiName,
String displayName,
@@ -10,5 +23,10 @@ public record AchievementDto(
String iconGrayUrl,
boolean hidden,
boolean unlocked,
Instant unlockedAt
Instant unlockedAt,
AchievementKind kind,
String setId,
TrophyType trophyType,
String group,
Double rarity
) {}
@@ -0,0 +1,25 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.AchievementKind;
/**
* The achievements of a game come in sets: Steam's, and one per PlayStation trophy set.
*
* @param id "steam", or the npCommunicationId of the trophy set
* @param platform PSN: PS5 or PS4; null for Steam
* @param name PSN: the set's name; null for Steam
* @param perfect all earned, or the platinum trophy
* @param visible false when Steam hides the user's achievements
* @param listed false until the sync has fetched the trophy list of the set
*/
public record AchievementSetDto(
String id,
AchievementKind kind,
String platform,
String name,
int total,
int unlocked,
boolean perfect,
boolean visible,
boolean listed
) {}
@@ -16,10 +16,13 @@ public record GameDetailDto(
int playtime2Weeks,
Instant firstSeenAt,
Instant lastSeenAt,
// from PlayStation; null for Steam rows
Instant lastPlayedAt,
GameStatus status,
Integer rating,
String notes,
GamePriority priority,
// any set, Steam or PlayStation
boolean hasAchievements,
boolean statsVisible,
int achievementsTotal,
@@ -52,7 +55,9 @@ public record GameDetailDto(
// see GameDto#coming
boolean coming,
// see GameDto#comingOn
LocalDate comingOn
LocalDate comingOn,
// Steam's set first, then the PlayStation sets, PS5 before PS4; the counts above are Steam's only
List<AchievementSetDto> achievementSets
) {
/**
@@ -10,6 +10,7 @@ import java.time.LocalDate;
* @param timeToBeatMinutes IGDB's normal time to beat, null when unknown
* @param achievementsTotal null when the game has no achievements or they were not fetched yet
* @param achievementsUnlocked null when not fetched yet or hidden by Steam
* @param achievementsPerfect every achievement unlocked, or the platinum trophy earned; the counts stay the real ones
* @param platform where the user owns a game added by hand, e.g. "PS5"; null for Steam games
* @param imageUrl a game added by hand from IGDB, or a repaired Steam header; null means the fixed Steam CDN header
* @param hidden the user hid the game; it shows only under the library's hidden filter
@@ -28,6 +29,7 @@ public record GameDto(
Integer timeToBeatMinutes,
Integer achievementsUnlocked,
Integer achievementsTotal,
boolean achievementsPerfect,
GameSource source,
String platform,
String imageUrl,
@@ -19,7 +19,7 @@ public record ImportRequest(
public static final int MAX_ROWS = 1000;
@AssertTrue(message = "PSN imports start at /api/library/psn-account/imports")
@AssertTrue(message = "PSN imports come from the sync")
public boolean isNotPsn() {
return source != GameImport.Source.PSN;
}
@@ -12,8 +12,12 @@ import java.util.List;
/**
* The lines the user checked in the review, each with the game picked.
*
* @param shownLineNos the lines the user saw; a PlayStation review keeps the others open. Null: all were shown.
*/
public record ImportSaveRequest(@NotNull @Size(max = ImportRequest.MAX_ROWS) List<@NotNull @Valid Row> rows) {
public record ImportSaveRequest(
@NotNull @Size(max = ImportRequest.MAX_ROWS) List<@NotNull @Valid Row> rows,
@Size(max = ImportRequest.MAX_ROWS) List<@NotNull Integer> shownLineNos) {
/**
* @param igdbId a candidate of the line, or a game from a new search
@@ -5,10 +5,12 @@ import java.time.Instant;
/**
* @param enabled false when Core has no APP_PSN_TOKEN_KEY: the PlayStation link is off
* @param expiresAt when the link ends and a new NPSSO is needed; null when not linked
* @param reviewImportId the PSN import waiting for the user's review, null when none
*/
public record PsnAccountDto(
boolean enabled,
boolean linked,
Instant linkedAt,
Instant expiresAt
Instant expiresAt,
Long reviewImportId
) {}
@@ -3,7 +3,8 @@ package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GameStatus;
/**
* @param playtime2Weeks Steam's playtime of the last two weeks, in minutes
* @param playtime2Weeks Steam's playtime of the last two weeks, or the PSN playtime history of the last 14 days, in
* minutes
* @param imageUrl see {@link GameDto#imageUrl()}
*/
public record RecentGameDto(
@@ -0,0 +1,3 @@
package core_service.modules.library.domain;
public enum AchievementKind { STEAM, PSN }
@@ -1,15 +1,26 @@
package core_service.modules.library.domain;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Achievement state of one owned game.
* Achievement state of one set of one owned game: its Steam achievements or one PlayStation trophy set.
*
* @param total 0 when the game has no achievements; null when the schema was never fetched
* @param unlocked null when the user's achievements were never fetched
* @param visible false when Steam hides the user's achievements
* @param platinum the platinum trophy is earned: the set counts as perfect, whatever else is missing
*/
public record AchievementProgress(Long appid, Integer total, Integer unlocked, boolean visible) {
public record AchievementProgress(Long appid, Integer total, Integer unlocked, boolean visible, boolean platinum) {
/**
* The JPQL constructor of the Steam query.
*/
public AchievementProgress(Long appid, Integer total, Integer unlocked, boolean visible) {
this(appid, total, unlocked, visible, false);
}
public boolean hasAchievements() {
return total != null && total > 0;
@@ -23,6 +34,14 @@ public record AchievementProgress(Long appid, Integer total, Integer unlocked, b
return unlocked == null || total == null ? 0 : capped(unlocked, total);
}
public boolean perfect() {
return platinum || total != null && unlockedCapped() == total;
}
public double percent() {
return perfect() ? 100.0 : unlockedCapped() * 100.0 / total;
}
/**
* A schema can lose achievements after the user unlocked them.
*/
@@ -35,6 +54,19 @@ public record AchievementProgress(Long appid, Integer total, Integer unlocked, b
}
public static List<Long> perfectAppids(List<AchievementProgress> started) {
return started.stream().filter(g -> g.unlockedCapped() == g.total()).map(AchievementProgress::appid).toList();
return started.stream().filter(AchievementProgress::perfect).map(AchievementProgress::appid).distinct().toList();
}
/**
* One entry per game, the one that counts for it: a perfect set, else the highest percent.
*/
public static List<AchievementProgress> perGame(List<AchievementProgress> sets) {
Comparator<AchievementProgress> best = Comparator.comparing(AchievementProgress::perfect)
.thenComparingDouble(AchievementProgress::percent);
Map<Long, AchievementProgress> games = new LinkedHashMap<>();
for (AchievementProgress set : sets) {
games.merge(set.appid(), set, (a, b) -> best.compare(b, a) > 0 ? b : a);
}
return games.values().stream().sorted(Comparator.comparing(AchievementProgress::appid)).toList();
}
}
@@ -0,0 +1,3 @@
package core_service.modules.library.domain;
public record AppidMinutes(long appid, long minutes) {}
@@ -37,10 +37,11 @@ public record GameImport(long id, long userId, Source source, Status status, int
/**
* One input line; every field but lineNo may be missing.
*
* @param psnKey the PlayStation title the line came from, null for every other import
* @param igdbId the game the user already picked in the search (source SEARCH); then title is not searched
*/
public record Line(int lineNo, String title, String platform, Integer playtimeMinutes, GameStatus status,
Long igdbId) {}
Long igdbId, String psnKey) {}
/**
* A line after the matching.
@@ -9,8 +9,8 @@ public enum GameSource {
STEAM,
MANUAL,
/**
* Rows of a PlayStation import. In this slice the sync treats them like MANUAL: it takes them over on Steam and
* never removes them.
* Rows the PSN step of the sync adds or confirms through a review; the sync updates their playtime and last
* played. The Steam sync still takes them over and never removes them.
*/
PSN
}
@@ -6,8 +6,10 @@ import java.time.Instant;
* A linked PlayStation account (V38). Only the sealed refresh token is stored, never the NPSSO.
*
* @param refreshExpiresAt when the user must paste a new NPSSO
* @param expiryNote the inbox note written for this link: null, PSN_EXPIRING or PSN_EXPIRED
*/
public record PsnAccount(long userId, byte[] refreshTokenEnc, Instant refreshExpiresAt, Instant linkedAt) {
public record PsnAccount(long userId, byte[] refreshTokenEnc, Instant refreshExpiresAt, Instant linkedAt,
String expiryNote) {
@Override
public String toString() {
@@ -0,0 +1,12 @@
package core_service.modules.library.domain;
import java.time.Instant;
/**
* One line of the user's trophy list. Every trophy of the set is in it: the rate is known also for the ones not
* earned.
*
* @param earnedAt may be null, also for an earned trophy
* @param earnedRate percent of all players; may be null
*/
public record PsnEarned(int trophyId, boolean earned, Instant earnedAt, Double earnedRate) {}
@@ -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 }
}
@@ -0,0 +1,12 @@
package core_service.modules.library.domain;
import java.time.Instant;
/**
* One trophy of a set. The sync writes it without the earned fields; the detail read has them.
*
* @param groupId "default" is the base game, "001" and up are DLC
* @param earnedRate percent of all players; may be null
*/
public record PsnTrophy(String setId, int trophyId, TrophyType type, String name, String detail, String iconUrl,
boolean hidden, String groupId, Double earnedRate, boolean earned, Instant earnedAt) {}
@@ -0,0 +1,14 @@
package core_service.modules.library.domain;
import java.time.Instant;
/**
* The summary values of a trophy set for one user (V40), as written by the sync.
*
* @param version trophySetVersion
* @param earnedTotal trophies the user earned
* @param lastUpdatedAt when the user last earned in the set; may be null
*/
public record PsnTrophySet(String id, String serviceName, String version, String name, String iconUrl,
String platform, int definedBronze, int definedSilver, int definedGold,
int definedPlatinum, int earnedTotal, boolean platinumEarned, Instant lastUpdatedAt) {}
@@ -0,0 +1,9 @@
package core_service.modules.library.domain;
/**
* A user's progress in one trophy set, for the detail page.
*
* @param listed true when the trophy rows are stored; false until a sync has fetched them
*/
public record PsnTrophySetProgress(String id, String platform, String name, int total, int earned,
boolean platinumEarned, boolean listed) {}
@@ -0,0 +1,12 @@
package core_service.modules.library.domain;
import java.time.Instant;
/**
* What is stored of a trophy set before a sync run.
*
* @param listVersion version of the stored trophy rows; null when never fetched
* @param earnedTotal null when the user has no row for the set yet
* @param listUpdatedAt the lastUpdatedDateTime the stored earned list has; null when never fetched
*/
public record PsnTrophySetState(String id, String listVersion, Integer earnedTotal, Instant listUpdatedAt) {}
@@ -0,0 +1,22 @@
package core_service.modules.library.domain;
import java.util.Optional;
public enum TrophyType {
BRONZE, SILVER, GOLD, PLATINUM;
/**
* @param psnValue as PlayStation spells it, any case
*/
public static Optional<TrophyType> of(String psnValue) {
if (psnValue == null) {
return Optional.empty();
}
for (TrophyType type : values()) {
if (type.name().equalsIgnoreCase(psnValue)) {
return Optional.of(type);
}
}
return Optional.empty();
}
}
@@ -91,6 +91,10 @@ public class UserGame {
@Column(name = "import_id")
private Long importId;
/** From PlayStation; null for Steam rows. */
@Column(name = "last_played_at")
private Instant lastPlayedAt;
public UserGame(Long userId, Long appid, int playtimeMinutes, Instant firstSeenAt, Instant lastSeenAt) {
this.userId = userId;
this.appid = appid;
@@ -26,7 +26,8 @@ public class UserSteamSync {
@Column(name = "user_id")
private Long userId;
@Column(name = "steam_id", nullable = false, length = 32)
/** Null for a user with a PlayStation link and no Steam account. */
@Column(name = "steam_id", length = 32)
private String steamId;
@Column(name = "last_synced_at")
@@ -16,10 +16,12 @@ import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* Imports and their lines (V22), the queues of the import workers, and the games an import added.
@@ -59,15 +61,116 @@ public class GameImportRepository {
key, new String[]{"id"});
long id = key.getKeyAs(Long.class);
jdbc.batchUpdate("insert into game_import_row (import_id, line_no, title, platform, playtime_minutes, status, "
+ "igdb_id) values (:id, :line, :title, :platform, :minutes, :status, :igdb)",
lines.stream().map(line -> new MapSqlParameterSource("id", id).addValue("line", line.lineNo())
.addValue("title", line.title()).addValue("platform", line.platform())
.addValue("minutes", line.playtimeMinutes())
.addValue("status", line.status() != null ? line.status().name() : null)
.addValue("igdb", line.igdbId())).toArray(MapSqlParameterSource[]::new));
+ "igdb_id, psn_key) values (:id, :line, :title, :platform, :minutes, :status, :igdb, :psnKey)",
lines.stream().map(line -> lineParams(id, line.lineNo(), line)).toArray(MapSqlParameterSource[]::new));
return id;
}
private static final String OPEN_PSN = "i.user_id = :user and i.source = 'PSN' "
+ "and i.status in ('QUEUED', 'MATCHING', 'READY', 'SAVING')";
/**
* The user's newest PSN import that is not finished: the sync adds review lines to a READY one and waits for
* the others.
*/
public Optional<GameImport> findOpenPsnImport(long userId) {
return jdbc.query(SELECT_IMPORT + "where " + OPEN_PSN + " order by i.id desc limit 1",
new MapSqlParameterSource("user", userId), IMPORT).stream().findFirst();
}
/**
* The PlayStation titles that wait in an open import.
*/
public Set<String> findOpenPsnKeys(long userId) {
return new HashSet<>(jdbc.queryForList("select r.psn_key from game_import_row r join game_import i "
+ "on i.id = r.import_id where " + OPEN_PSN + " and r.psn_key is not null",
new MapSqlParameterSource("user", userId), String.class));
}
/**
* An import the sync matched itself: READY, with the candidates of each row.
*/
public long createReady(long userId, List<GameImport.Row> rows) {
GeneratedKeyHolder key = new GeneratedKeyHolder();
jdbc.update("insert into game_import (user_id, source, status, total) values (:user, 'PSN', 'READY', :total)",
new MapSqlParameterSource("user", userId).addValue("total", rows.size()), key, new String[]{"id"});
long id = key.getKeyAs(Long.class);
insertMatchedRows(id, 1, rows);
return id;
}
/**
* Adds matched rows after the last line of an import that is still READY.
*
* @return false when the user saved the import meanwhile; nothing is added then
*/
public boolean appendReadyRows(long importId, List<GameImport.Row> rows) {
if (jdbc.update("update game_import set total = total + :n where id = :id and status = 'READY'",
new MapSqlParameterSource("id", importId).addValue("n", rows.size())) == 0) {
return false;
}
int start = jdbc.queryForObject(
"select coalesce(max(line_no), 0) + 1 from game_import_row where import_id = :id",
new MapSqlParameterSource("id", importId), Integer.class);
insertMatchedRows(importId, start, rows);
return true;
}
/**
* Moves the lines without a choice that are not in {@code shown} to a new READY import of the user, so a save
* neither adds nor dismisses them.
*/
public void moveUnseenToNewReady(long userId, long importId, Set<Integer> shown) {
List<Integer> unseen = jdbc.queryForList("select line_no from game_import_row where import_id = :id "
+ "and chosen_igdb_id is null order by line_no", new MapSqlParameterSource("id", importId),
Integer.class).stream().filter(lineNo -> !shown.contains(lineNo)).toList();
if (unseen.isEmpty()) {
return;
}
GeneratedKeyHolder key = new GeneratedKeyHolder();
jdbc.update("insert into game_import (user_id, source, status, total) values (:user, 'PSN', 'READY', :total)",
new MapSqlParameterSource("user", userId).addValue("total", unseen.size()), key, new String[]{"id"});
long target = key.getKeyAs(Long.class);
for (int index = 0; index < unseen.size(); index++) {
jdbc.update("update game_import_row set import_id = :target, line_no = :new "
+ "where import_id = :id and line_no = :old",
new MapSqlParameterSource("target", target).addValue("new", index + 1).addValue("id", importId)
.addValue("old", unseen.get(index)));
}
jdbc.update("update game_import set total = total - :n where id = :id",
new MapSqlParameterSource("id", importId).addValue("n", unseen.size()));
}
/**
* The PlayStation titles of the import's lines the user did not check.
*/
public List<String> findPsnKeysWithoutChoice(long importId) {
return jdbc.queryForList("select psn_key from game_import_row where import_id = :id and psn_key is not null "
+ "and chosen_igdb_id is null", new MapSqlParameterSource("id", importId), String.class);
}
private void insertMatchedRows(long importId, int firstLineNo, List<GameImport.Row> rows) {
MapSqlParameterSource[] params = new MapSqlParameterSource[rows.size()];
for (int index = 0; index < params.length; index++) {
GameImport.Row row = rows.get(index);
params[index] = lineParams(importId, firstLineNo + index, row.line())
.addValue("failed", row.searchFailed()).addValue("exact", row.exact())
.addValue("candidates", jsonMapper.writeValueAsString(row.candidates()));
}
jdbc.batchUpdate("insert into game_import_row (import_id, line_no, title, platform, playtime_minutes, status, "
+ "igdb_id, psn_key, matched_at, search_failed, exact, candidates) values (:id, :line, :title, "
+ ":platform, :minutes, :status, :igdb, :psnKey, now(), :failed, :exact, cast(:candidates as jsonb))",
params);
}
private static MapSqlParameterSource lineParams(long importId, int lineNo, GameImport.Line line) {
return new MapSqlParameterSource("id", importId).addValue("line", lineNo)
.addValue("title", line.title()).addValue("platform", line.platform())
.addValue("minutes", line.playtimeMinutes())
.addValue("status", line.status() != null ? line.status().name() : null)
.addValue("igdb", line.igdbId()).addValue("psnKey", line.psnKey());
}
public Optional<GameImport> find(long userId, long id) {
return jdbc.query(SELECT_IMPORT + "where i.id = :id and i.user_id = :user",
new MapSqlParameterSource("id", id).addValue("user", userId), IMPORT).stream().findFirst();
@@ -279,7 +382,7 @@ public class GameImportRepository {
String status = rs.getString("status");
return new GameImport.Line(rs.getInt("line_no"), rs.getString("title"), rs.getString("platform"),
rs.getObject("playtime_minutes", Integer.class), status != null ? GameStatus.valueOf(status) : null,
rs.getObject("igdb_id", Long.class));
rs.getObject("igdb_id", Long.class), rs.getString("psn_key"));
}
private static Instant instant(Timestamp timestamp) {
@@ -1,5 +1,6 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.AppidMinutes;
import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.PlaytimeDay;
import core_service.modules.library.domain.PlaytimeDayId;
@@ -10,6 +11,7 @@ import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
public interface PlaytimeDayRepository extends JpaRepository<PlaytimeDay, PlaytimeDayId> {
@@ -28,6 +30,11 @@ public interface PlaytimeDayRepository extends JpaRepository<PlaytimeDay, Playti
void addMinutes(@Param("userId") Long userId, @Param("appid") Long appid,
@Param("day") LocalDate day, @Param("minutes") int minutes);
@Query("select new core_service.modules.library.domain.AppidMinutes(p.appid, sum(p.minutes)) from PlaytimeDay p " +
"where p.userId = :userId and p.appid in :appids and p.day >= :from group by p.appid")
List<AppidMinutes> minutesPerGameSince(@Param("userId") Long userId, @Param("appids") Collection<Long> appids,
@Param("from") LocalDate from);
/**
* Only days with playtime; the caller fills the gaps. A hidden game's days never count, also those from before
* it was hidden.
@@ -17,10 +17,11 @@ public class PsnAccountRepository {
private final NamedParameterJdbcTemplate jdbc;
public Optional<PsnAccount> find(long userId) {
return jdbc.query("select user_id, refresh_token_enc, refresh_expires_at, linked_at from user_psn_account "
+ "where user_id = :user", new MapSqlParameterSource("user", userId),
return jdbc.query("select user_id, refresh_token_enc, refresh_expires_at, linked_at, expiry_note "
+ "from user_psn_account where user_id = :user", new MapSqlParameterSource("user", userId),
(rs, i) -> new PsnAccount(rs.getLong("user_id"), rs.getBytes("refresh_token_enc"),
rs.getTimestamp("refresh_expires_at").toInstant(), rs.getTimestamp("linked_at").toInstant()))
rs.getTimestamp("refresh_expires_at").toInstant(), rs.getTimestamp("linked_at").toInstant(),
rs.getString("expiry_note")))
.stream().findFirst();
}
@@ -31,7 +32,7 @@ public class PsnAccountRepository {
jdbc.update("""
insert into user_psn_account (user_id, refresh_token_enc, refresh_expires_at) values (:user, :token, :expires)
on conflict (user_id) do update set refresh_token_enc = :token, refresh_expires_at = :expires,
linked_at = now()""", params(userId, refreshTokenEnc, refreshExpiresAt));
linked_at = now(), expiry_note = null""", params(userId, refreshTokenEnc, refreshExpiresAt));
}
public void updateToken(long userId, byte[] refreshTokenEnc, Instant refreshExpiresAt) {
@@ -47,6 +48,24 @@ public class PsnAccountRepository {
new MapSqlParameterSource("user", userId));
}
/**
* Writes the inbox note of this link once: EXPIRING while none was written, EXPIRED also after EXPIRING. The
* library module writes the inbox table by SQL, as the follow module does.
*
* @param kind the inbox NoteKind name PSN_EXPIRING or PSN_EXPIRED
* @return 1 when a note was written
*/
public int noteExpiry(long userId, String kind) {
return jdbc.update("""
with marked as (
update user_psn_account set expiry_note = :kind
where user_id = :user and expiry_note is distinct from :kind
and (:kind = 'PSN_EXPIRED' or expiry_note is null)
returning user_id)
insert into inbox_note (user_id, kind, subject_user_id) select user_id, :kind, user_id from marked""",
new MapSqlParameterSource("user", userId).addValue("kind", kind));
}
public int delete(long userId) {
return jdbc.update("delete from user_psn_account where user_id = :user", new MapSqlParameterSource("user", userId));
}
@@ -0,0 +1,68 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.PsnTitle;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* What the PlayStation sync has seen per user (V39).
*/
@Repository
@RequiredArgsConstructor
public class PsnTitleRepository {
private final NamedParameterJdbcTemplate jdbc;
public Map<String, PsnTitle> findAll(long userId) {
Map<String, PsnTitle> titles = new LinkedHashMap<>();
jdbc.query("select psn_key, state, appid from user_psn_title where user_id = :user",
new MapSqlParameterSource("user", userId), rs -> {
titles.put(rs.getString("psn_key"), new PsnTitle(rs.getString("psn_key"),
PsnTitle.State.valueOf(rs.getString("state")), rs.getObject("appid", Long.class)));
});
return titles;
}
/**
* Also over a DISMISSED row: the user checked the title in a later review.
*/
public void link(long userId, String psnKey, long appid) {
jdbc.update("""
insert into user_psn_title (user_id, psn_key, state, appid) values (:user, :key, 'LINKED', :appid)
on conflict (user_id, psn_key) do update set state = 'LINKED', appid = excluded.appid""",
new MapSqlParameterSource("user", userId).addValue("key", psnKey).addValue("appid", appid));
}
/**
* Links a title to a game the user already owns: STEAM_OWNED when that row is a Steam row (Steam proof and
* playtime stay Steam's), else LINKED. Also over a DISMISSED row.
*/
public void linkOwned(long userId, String psnKey, long appid) {
jdbc.update("""
insert into user_psn_title (user_id, psn_key, state, appid)
values (:user, :key, case when exists (select 1 from user_game where user_id = :user and appid = :appid
and source = 'STEAM') then 'STEAM_OWNED' else 'LINKED' end,
:appid)
on conflict (user_id, psn_key) do update set state = excluded.state, appid = excluded.appid""",
new MapSqlParameterSource("user", userId).addValue("key", psnKey).addValue("appid", appid));
}
/**
* A LINKED title stays linked.
*/
public void dismiss(long userId, Collection<String> psnKeys) {
if (psnKeys.isEmpty()) {
return;
}
jdbc.batchUpdate("insert into user_psn_title (user_id, psn_key, state) values (:user, :key, 'DISMISSED') "
+ "on conflict (user_id, psn_key) do nothing",
psnKeys.stream().map(key -> new MapSqlParameterSource("user", userId).addValue("key", key))
.toArray(MapSqlParameterSource[]::new));
}
}
@@ -0,0 +1,253 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.AchievementProgress;
import core_service.modules.library.domain.PsnEarned;
import core_service.modules.library.domain.PsnTrophy;
import core_service.modules.library.domain.PsnTrophySet;
import core_service.modules.library.domain.PsnTrophySetProgress;
import core_service.modules.library.domain.PsnTrophySetState;
import core_service.modules.library.domain.TrophyType;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* PlayStation trophies (V40): the global sets and trophies and what each user earned.
*/
@Repository
@RequiredArgsConstructor
public class PsnTrophyRepository {
private final NamedParameterJdbcTemplate jdbc;
/**
* @param recheckBefore a "no set" answer checked before this time is asked again
* @return title id to set id, null for a title PlayStation has no set for; a title missing from the map must
* be asked
*/
public Map<String, String> findTitleSets(Collection<String> titleIds, Instant recheckBefore) {
Map<String, String> sets = new HashMap<>();
if (titleIds.isEmpty()) {
return sets;
}
jdbc.query("""
select title_id, np_communication_id from psn_title_trophy_set
where title_id in (:ids) and (np_communication_id is not null or checked_at >= :before)""",
new MapSqlParameterSource("ids", titleIds).addValue("before", Timestamp.from(recheckBefore)),
rs -> { sets.put(rs.getString("title_id"), rs.getString("np_communication_id")); });
return sets;
}
/**
* @param asked every title id that was asked
* @param found title id to set id of the answers; an asked id without an answer is stored as "no set"
*/
public void saveTitleSets(Collection<String> asked, Map<String, String> found) {
if (asked.isEmpty()) {
return;
}
jdbc.batchUpdate("""
insert into psn_title_trophy_set (title_id, np_communication_id) values (:title, :set)
on conflict (title_id) do update set np_communication_id = excluded.np_communication_id,
checked_at = now()""",
asked.stream().map(title -> new MapSqlParameterSource("title", title).addValue("set", found.get(title)))
.toArray(MapSqlParameterSource[]::new));
}
/**
* A set without a user row has {@code earnedTotal} null; a set never stored is missing.
*/
public Map<String, PsnTrophySetState> findStates(long userId, Collection<String> setIds) {
Map<String, PsnTrophySetState> states = new HashMap<>();
if (setIds.isEmpty()) {
return states;
}
jdbc.query("""
select s.np_communication_id, s.list_version, u.earned_total, u.list_updated_at
from psn_trophy_set s
left join user_psn_trophy_set u on u.np_communication_id = s.np_communication_id and u.user_id = :user
where s.np_communication_id in (:ids)""",
new MapSqlParameterSource("user", userId).addValue("ids", setIds), rs -> {
Timestamp listUpdatedAt = rs.getTimestamp("list_updated_at");
states.put(rs.getString("np_communication_id"), new PsnTrophySetState(
rs.getString("np_communication_id"), rs.getString("list_version"),
rs.getObject("earned_total", Integer.class),
listUpdatedAt == null ? null : listUpdatedAt.toInstant()));
});
return states;
}
/**
* The summary of one set: the global row (not its list version) and the user's counts (not their list marker).
*/
public void saveSet(long userId, long appid, PsnTrophySet set) {
jdbc.update("""
insert into psn_trophy_set (np_communication_id, service_name, platform, name, icon_url, set_version,
defined_bronze, defined_silver, defined_gold, defined_platinum)
values (:id, :service, :platform, :name, :icon, :version, :bronze, :silver, :gold, :platinum)
on conflict (np_communication_id) do update set service_name = excluded.service_name,
platform = excluded.platform, name = excluded.name, icon_url = excluded.icon_url,
set_version = excluded.set_version, defined_bronze = excluded.defined_bronze,
defined_silver = excluded.defined_silver, defined_gold = excluded.defined_gold,
defined_platinum = excluded.defined_platinum, refreshed_at = now()""",
new MapSqlParameterSource("id", set.id()).addValue("service", set.serviceName())
.addValue("platform", set.platform()).addValue("name", set.name())
.addValue("icon", set.iconUrl()).addValue("version", set.version())
.addValue("bronze", set.definedBronze()).addValue("silver", set.definedSilver())
.addValue("gold", set.definedGold()).addValue("platinum", set.definedPlatinum()));
jdbc.update("""
insert into user_psn_trophy_set (user_id, np_communication_id, appid, earned_total, platinum_earned,
last_updated_at)
values (:user, :id, :appid, :earned, :platinum, :updated)
on conflict (user_id, np_communication_id) do update set appid = excluded.appid,
earned_total = excluded.earned_total, platinum_earned = excluded.platinum_earned,
last_updated_at = excluded.last_updated_at""",
new MapSqlParameterSource("user", userId).addValue("id", set.id()).addValue("appid", appid)
.addValue("earned", set.earnedTotal()).addValue("platinum", set.platinumEarned())
.addValue("updated", timestamp(set.lastUpdatedAt())));
}
/**
* Writes the trophies of a set and the version they have. The earned rates stay; a trophy PlayStation dropped
* goes.
*/
public void replaceTrophies(String setId, String version, List<PsnTrophy> trophies) {
jdbc.batchUpdate("""
insert into psn_trophy (np_communication_id, trophy_id, type, name, detail, icon_url, hidden, group_id)
values (:set, :trophy, :type, :name, :detail, :icon, :hidden, :group)
on conflict (np_communication_id, trophy_id) do update set type = excluded.type, name = excluded.name,
detail = excluded.detail, icon_url = excluded.icon_url, hidden = excluded.hidden,
group_id = excluded.group_id""",
trophies.stream().map(trophy -> new MapSqlParameterSource("set", setId)
.addValue("trophy", trophy.trophyId()).addValue("type", trophy.type().name())
.addValue("name", trophy.name()).addValue("detail", trophy.detail())
.addValue("icon", trophy.iconUrl()).addValue("hidden", trophy.hidden())
.addValue("group", trophy.groupId())).toArray(MapSqlParameterSource[]::new));
List<Integer> ids = trophies.stream().map(PsnTrophy::trophyId).toList();
if (ids.isEmpty()) {
jdbc.update("delete from psn_trophy where np_communication_id = :set", new MapSqlParameterSource("set", setId));
} else {
jdbc.update("delete from psn_trophy where np_communication_id = :set and trophy_id not in (:ids)",
new MapSqlParameterSource("set", setId).addValue("ids", ids));
}
jdbc.update("update psn_trophy_set set list_version = :version where np_communication_id = :set",
new MapSqlParameterSource("set", setId).addValue("version", version));
}
/**
* Replaces the user's earned trophies of a set and stores the rates; {@code listUpdatedAt} marks the state the
* list has.
*/
public void replaceEarned(long userId, String setId, Instant listUpdatedAt, List<PsnEarned> earned) {
jdbc.update("delete from user_psn_trophy where user_id = :user and np_communication_id = :set",
new MapSqlParameterSource("user", userId).addValue("set", setId));
jdbc.batchUpdate("""
insert into user_psn_trophy (user_id, np_communication_id, trophy_id, earned_at)
values (:user, :set, :trophy, :at)""",
earned.stream().filter(PsnEarned::earned).map(trophy -> new MapSqlParameterSource("user", userId)
.addValue("set", setId).addValue("trophy", trophy.trophyId())
.addValue("at", timestamp(trophy.earnedAt()))).toArray(MapSqlParameterSource[]::new));
jdbc.batchUpdate("""
update psn_trophy set earned_rate = :rate where np_communication_id = :set and trophy_id = :trophy""",
earned.stream().filter(trophy -> trophy.earnedRate() != null)
.map(trophy -> new MapSqlParameterSource("set", setId).addValue("trophy", trophy.trophyId())
.addValue("rate", trophy.earnedRate())).toArray(MapSqlParameterSource[]::new));
jdbc.update("""
update user_psn_trophy_set set list_updated_at = :updated
where user_id = :user and np_communication_id = :set""",
new MapSqlParameterSource("user", userId).addValue("set", setId)
.addValue("updated", timestamp(listUpdatedAt)));
}
public void deleteUserSets(long userId, Collection<String> setIds) {
if (setIds.isEmpty()) {
return;
}
MapSqlParameterSource params = new MapSqlParameterSource("user", userId).addValue("ids", setIds);
jdbc.update("delete from user_psn_trophy where user_id = :user and np_communication_id in (:ids)", params);
jdbc.update("delete from user_psn_trophy_set where user_id = :user and np_communication_id in (:ids)", params);
}
/**
* Removes the sets the account no longer has: another account was linked.
*
* @param keepSetIds the sets the account lists now; must not be empty
*/
public void deleteUserSetsExcept(long userId, Collection<String> keepSetIds) {
if (keepSetIds.isEmpty()) {
return;
}
MapSqlParameterSource params = new MapSqlParameterSource("user", userId).addValue("ids", keepSetIds);
jdbc.update("delete from user_psn_trophy where user_id = :user and np_communication_id not in (:ids)", params);
jdbc.update("delete from user_psn_trophy_set where user_id = :user and np_communication_id not in (:ids)", params);
}
public void deleteUser(long userId) {
MapSqlParameterSource params = new MapSqlParameterSource("user", userId);
jdbc.update("delete from user_psn_trophy where user_id = :user", params);
jdbc.update("delete from user_psn_trophy_set where user_id = :user", params);
}
/**
* One entry per set of a listed game: the defined trophies are the total, the earned ones the unlocked count.
*/
public List<AchievementProgress> findProgress(long userId) {
return jdbc.query("""
select u.appid, s.defined_bronze + s.defined_silver + s.defined_gold + s.defined_platinum as total,
u.earned_total, u.platinum_earned
from user_psn_trophy_set u
join psn_trophy_set s on s.np_communication_id = u.np_communication_id
join listed_user_game ug on ug.user_id = u.user_id and ug.appid = u.appid
where u.user_id = :user""", new MapSqlParameterSource("user", userId),
(rs, i) -> new AchievementProgress(rs.getLong("appid"), rs.getInt("total"), rs.getInt("earned_total"),
true, rs.getBoolean("platinum_earned")));
}
public List<PsnTrophySetProgress> findSets(long userId, long appid) {
return jdbc.query("""
select s.np_communication_id, s.platform, s.name,
s.defined_bronze + s.defined_silver + s.defined_gold + s.defined_platinum as total,
u.earned_total, u.platinum_earned, s.list_version is not null and (u.earned_total = 0 or u.list_updated_at is not null) as listed
from user_psn_trophy_set u
join psn_trophy_set s on s.np_communication_id = u.np_communication_id
where u.user_id = :user and u.appid = :appid
order by s.platform desc, s.name, s.np_communication_id""",
new MapSqlParameterSource("user", userId).addValue("appid", appid),
(rs, i) -> new PsnTrophySetProgress(rs.getString("np_communication_id"), rs.getString("platform"),
rs.getString("name"), rs.getInt("total"), rs.getInt("earned_total"),
rs.getBoolean("platinum_earned"), rs.getBoolean("listed")));
}
public List<PsnTrophy> findTrophies(long userId, long appid) {
return jdbc.query("""
select t.np_communication_id, t.trophy_id, t.type, t.name, t.detail, t.icon_url, t.hidden, t.group_id,
t.earned_rate, e.trophy_id is not null as earned, e.earned_at
from user_psn_trophy_set u
join psn_trophy t on t.np_communication_id = u.np_communication_id
left join user_psn_trophy e on e.user_id = u.user_id and e.np_communication_id = t.np_communication_id
and e.trophy_id = t.trophy_id
where u.user_id = :user and u.appid = :appid
order by t.np_communication_id, t.group_id = 'default' desc, t.group_id, t.trophy_id""",
new MapSqlParameterSource("user", userId).addValue("appid", appid),
(rs, i) -> {
Timestamp earnedAt = rs.getTimestamp("earned_at");
return new PsnTrophy(rs.getString("np_communication_id"), rs.getInt("trophy_id"),
TrophyType.valueOf(rs.getString("type")), rs.getString("name"), rs.getString("detail"),
rs.getString("icon_url"), rs.getBoolean("hidden"), rs.getString("group_id"),
rs.getObject("earned_rate", Double.class), rs.getBoolean("earned"),
earnedAt == null ? null : earnedAt.toInstant());
});
}
private static Timestamp timestamp(Instant instant) {
return instant == null ? null : Timestamp.from(instant);
}
}
@@ -412,6 +412,12 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
"order by ug.appid, ug.userId")
List<FinishedWithoutDate> findFinishedWithoutDateCandidates();
// For a row addGame just created outside the sync transaction.
@Modifying
@Transactional
@Query("update UserGame ug set ug.lastPlayedAt = :at where ug.userId = :userId and ug.appid = :appid")
int setLastPlayedAt(@Param("userId") Long userId, @Param("appid") Long appid, @Param("at") Instant at);
@Modifying
@Transactional
@Query(value = "update user_game ug set finished_at_estimate = :finishedAt " +
@@ -54,9 +54,9 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
/**
* The gate of the sync on a visit: stamps login_sync_tried_at and returns 1 only when a sync is due, so of many
* parallel requests one wins. Due: no fresh RUNNING run, and either another Steam ID, or no OK sync since
* successBefore and no try since triedBefore. 0 for a deleted or blocked user. A new row gets only the Steam ID.
* steam_id is not updated: the worker claim compares the old ID to detect an account change.
* parallel requests one wins. Due: no fresh RUNNING run, and either another Steam ID (null counts as a value), or
* no OK sync since successBefore and no try since triedBefore. 0 for a deleted or blocked user. A new row gets
* only the Steam ID. steam_id is not updated: the worker claim compares the old ID to detect an account change.
*/
@Modifying
@Transactional
@@ -70,7 +70,7 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
WHERE NOT (user_steam_sync.last_sync_status = 'RUNNING'
AND user_steam_sync.sync_started_at IS NOT NULL
AND user_steam_sync.sync_started_at >= :runningStaleBefore)
AND (user_steam_sync.steam_id <> EXCLUDED.steam_id
AND (user_steam_sync.steam_id IS DISTINCT FROM EXCLUDED.steam_id
OR ((user_steam_sync.last_success_at IS NULL OR user_steam_sync.last_success_at < :successBefore)
AND (user_steam_sync.login_sync_tried_at IS NULL
OR user_steam_sync.login_sync_tried_at < :triedBefore)))
@@ -1,10 +1,12 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.AchievementDto;
import core_service.modules.library.api.dto.AchievementSetDto;
import core_service.modules.library.api.dto.CorrectionMarkDto;
import core_service.modules.library.api.dto.EditionDto;
import core_service.modules.library.api.dto.FinishedHintDto;
import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.domain.AchievementKind;
import core_service.modules.library.domain.AchievementProgress;
import core_service.modules.library.domain.ComingGame;
import core_service.modules.library.domain.GameAchievementSchema;
@@ -12,6 +14,8 @@ import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.FinishedHint;
import core_service.modules.library.domain.GameMoods;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.PsnTrophy;
import core_service.modules.library.domain.PsnTrophySetProgress;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameSpeedrun;
import core_service.modules.library.domain.SteamGame;
@@ -28,6 +32,7 @@ import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.PsnTrophyRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameAchievementRepository;
@@ -40,12 +45,14 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Service
@RequiresRole(Role.USER)
@@ -73,6 +80,7 @@ public class GameDetailService {
private final GameCorrectionRepository gameCorrectionRepository;
private final GameLinkRepository gameLinkRepository;
private final GameMergeRepository mergeRepository;
private final PsnTrophyRepository psnTrophyRepository;
@Value("${app.library.achievement-ttl-hours:1}")
private long achievementTtlHours;
@@ -156,8 +164,10 @@ public class GameDetailService {
// Reads only.
private GameDetailDto assemble(UserGame userGame, SteamGame meta, boolean hasSchema) {
List<AchievementSetDto> psnSets = psnSets(userGame);
List<AchievementDto> psnTrophies = psnSets.isEmpty() ? List.of() : psnTrophies(userGame);
if (!hasSchema) {
return buildDto(userGame, meta, true, 0, 0, List.of(), List.of());
return buildDto(userGame, meta, true, 0, 0, List.of(), List.of(), psnSets, psnTrophies);
}
Long userId = userGame.getUserId();
Long appid = userGame.getAppid();
@@ -183,17 +193,41 @@ public class GameDetailService {
s.getIconGrayUrl(),
s.isHidden(),
isUnlocked,
u != null ? u.getUnlockedAt() : null
u != null ? u.getUnlockedAt() : null,
AchievementKind.STEAM, null, null, null, s.getGlobalPercent()
);
})
.toList();
return buildDto(userGame, meta, statsVisible, schemaList.size(),
AchievementProgress.capped(unlocked, schemaList.size()), achievementDtos, schemaList);
AchievementProgress.capped(unlocked, schemaList.size()), achievementDtos, schemaList, psnSets, psnTrophies);
}
private List<AchievementSetDto> psnSets(UserGame userGame) {
return psnTrophyRepository.findSets(userGame.getUserId(), userGame.getAppid()).stream()
.map(set -> new AchievementSetDto(set.id(), AchievementKind.PSN, set.platform(), set.name(), set.total(),
AchievementProgress.capped(set.earned(), set.total()),
set.platinumEarned() || set.earned() >= set.total(), true, set.listed()))
.toList();
}
private List<AchievementDto> psnTrophies(UserGame userGame) {
return psnTrophyRepository.findTrophies(userGame.getUserId(), userGame.getAppid()).stream()
.map(GameDetailService::toDto).toList();
}
// A hidden trophy keeps its name and description from the user until it is earned.
private static AchievementDto toDto(PsnTrophy trophy) {
boolean secret = trophy.hidden() && !trophy.earned();
return new AchievementDto(String.valueOf(trophy.trophyId()), secret ? null : trophy.name(),
secret ? null : trophy.detail(), trophy.iconUrl(), trophy.iconUrl(), trophy.hidden(), trophy.earned(),
trophy.earnedAt(), AchievementKind.PSN, trophy.setId(), trophy.type(),
"default".equals(trophy.groupId()) ? null : trophy.groupId(), trophy.earnedRate());
}
private GameDetailDto buildDto(UserGame userGame, SteamGame meta, boolean statsVisible, int total, int unlocked,
List<AchievementDto> achievements, List<GameAchievementSchema> schema) {
List<AchievementDto> achievements, List<GameAchievementSchema> schema,
List<AchievementSetDto> psnSets, List<AchievementDto> psnTrophies) {
Optional<GameTimeToBeat> timeToBeat = gameTimeToBeatRepository.findById(userGame.getAppid());
Set<String> unlockedNames = achievements.stream().filter(AchievementDto::unlocked).map(AchievementDto::apiName)
.collect(Collectors.toSet());
@@ -206,7 +240,13 @@ public class GameDetailService {
.map(hint -> new FinishedHintDto(hint.type(), hint.achievement()))
.orElse(null);
Optional<ComingGame> coming = comingGameRepository.findById(userGame.getAppid());
boolean hasAchievements = total > 0;
boolean hasAchievements = total > 0 || !psnSets.isEmpty();
List<AchievementSetDto> sets = new ArrayList<>();
if (total > 0) {
sets.add(new AchievementSetDto("steam", AchievementKind.STEAM, null, null, total, unlocked,
unlocked >= total, statsVisible, true));
}
sets.addAll(psnSets);
int remaining = Math.max(0, total - unlocked);
double pct = total == 0 ? 0.0 : (unlocked * 100.0 / total);
return new GameDetailDto(
@@ -217,6 +257,7 @@ public class GameDetailService {
userGame.getPlaytime2Weeks(),
userGame.getFirstSeenAt(),
userGame.getLastSeenAt(),
userGame.getLastPlayedAt(),
userGame.getStatus(),
userGame.getRating(),
userGame.getNotes(),
@@ -227,7 +268,7 @@ public class GameDetailService {
unlocked,
remaining,
Math.round(pct * 10.0) / 10.0,
achievements,
Stream.concat(achievements.stream(), psnTrophies.stream()).toList(),
timeToBeat
.map(t -> new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(), t.getNormallyMinutes(), t.getCompletelyMinutes()))
.orElse(null),
@@ -247,7 +288,8 @@ public class GameDetailService {
.map(e -> new EditionDto(e.appid(), e.name(), e.platform(), e.source(), e.status(), e.imageUrl()))
.toList(),
coming.isPresent(),
coming.map(ComingGame::getReleaseDate).orElse(null)
coming.map(ComingGame::getReleaseDate).orElse(null),
sets
);
}
}
@@ -59,15 +59,8 @@ public class GameImportService {
List<GameImport.Line> lines = IntStream.range(0, rows.size()).mapToObj(index -> {
ImportRequest.Row row = rows.get(index);
return new GameImport.Line(index + 1, strippedOrNull(row.title()), strippedOrNull(row.platform()),
row.playtimeMinutes(), row.status(), row.igdbId());
row.playtimeMinutes(), row.status(), row.igdbId(), null);
}).toList();
return createLines(userId, source, lines);
}
/**
* {@link #create} for lines Core built itself, e.g. from a PlayStation account.
*/
public CreateResult createLines(Long userId, GameImport.Source source, List<GameImport.Line> lines) {
if (!igdbApiService.isEnabled()) {
return new CreateResult.SearchUnavailable();
}
@@ -91,9 +84,12 @@ public class GameImportService {
}
/**
* Once per import: stores the review and puts the import in the saving queue.
* Once per import: stores the review and puts the import in the saving queue. In a PlayStation review the
* lines the user did not see (the sync added them after the review was loaded) move to a new open review.
*
* @param shownLineNos the lines the user saw; null: all
*/
public ChangeResult save(Long userId, long id, List<ImportSaveRequest.Row> chosen) {
public ChangeResult save(Long userId, long id, List<ImportSaveRequest.Row> chosen, List<Integer> shownLineNos) {
if (importRepository.find(userId, id).isEmpty()) {
return ChangeResult.NOT_FOUND;
}
@@ -104,6 +100,9 @@ public class GameImportService {
importRepository.saveChoices(id, chosen.stream().map(row -> new GameImport.Choice(row.lineNo(),
row.igdbId(), row.platform(), row.status(),
row.playtimeMinutes() != null ? row.playtimeMinutes() : 0)).toList());
if (shownLineNos != null && importRepository.findSource(id) == GameImport.Source.PSN) {
importRepository.moveUnseenToNewReady(userId, id, Set.copyOf(shownLineNos));
}
return true;
});
if (!queued) {
@@ -5,6 +5,7 @@ import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.IgdbGame;
import core_service.modules.library.repo.GameImportRepository;
import core_service.modules.library.repo.PsnTitleRepository;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -51,6 +52,7 @@ public class GameImportWorker {
private final GameImportRepository importRepository;
private final ManualGameService manualGameService;
private final PsnTitleRepository psnTitleRepository;
private final TimeToBeatService timeToBeatService;
private final MoodService moodService;
private final ReleaseDateService releaseDateService;
@@ -62,11 +64,13 @@ public class GameImportWorker {
private final Lane saving;
public GameImportWorker(GameImportRepository importRepository, ManualGameService manualGameService,
TimeToBeatService timeToBeatService, MoodService moodService,
PsnTitleRepository psnTitleRepository, TimeToBeatService timeToBeatService,
MoodService moodService,
ReleaseDateService releaseDateService, GameCreditService gameCreditService,
@Value("${app.library.import-stall-minutes:5}") long stallMinutes) {
this.importRepository = importRepository;
this.manualGameService = manualGameService;
this.psnTitleRepository = psnTitleRepository;
this.timeToBeatService = timeToBeatService;
this.moodService = moodService;
this.releaseDateService = releaseDateService;
@@ -140,10 +144,10 @@ public class GameImportWorker {
private void save(long importId) {
importRepository.start(importId, GameImport.Status.SAVING);
List<GameImport.Choice> choices = importRepository.findUnsavedChoices(importId);
long userId = importRepository.findUserId(importId);
GameSource source = importRepository.findSource(importId) == GameImport.Source.PSN ? GameSource.PSN
: GameSource.MANUAL;
if (!choices.isEmpty()) {
long userId = importRepository.findUserId(importId);
GameSource source = importRepository.findSource(importId) == GameImport.Source.PSN ? GameSource.PSN
: GameSource.MANUAL;
Map<Integer, GameImport.Row> rows = importRepository.findRows(importId).stream()
.collect(Collectors.toMap(row -> row.line().lineNo(), row -> row));
for (GameImport.Choice choice : choices) {
@@ -156,12 +160,25 @@ public class GameImportWorker {
if (Thread.currentThread().isInterrupted()) {
return;
}
boolean added = manualGameService.addGame(userId, game, choice.platform(), choice.status(),
choice.playtimeMinutes(), source, importId) instanceof ManualGameService.AddResult.Added;
GameImport.Line line = rows.get(choice.lineNo()).line();
String psnKey = line.psnKey();
// PlayStation owns the playtime of its titles: the review's value would be booked as growth later.
int minutes = psnKey != null && line.playtimeMinutes() != null ? line.playtimeMinutes()
: choice.playtimeMinutes();
ManualGameService.AddResult result = manualGameService.addGame(userId, game, choice.platform(),
choice.status(), minutes, source, importId);
if (psnKey != null) {
linkPsnTitle(userId, psnKey, game, result);
}
importRepository.saveResult(importId, choice.lineNo(),
added ? GameImport.SaveResult.ADDED : GameImport.SaveResult.SKIPPED);
result instanceof ManualGameService.AddResult.Added ? GameImport.SaveResult.ADDED
: GameImport.SaveResult.SKIPPED);
}
}
if (source == GameSource.PSN) {
// A title left unchecked is not offered again. Also a resumed try runs this; a LINKED title stays.
psnTitleRepository.dismiss(userId, importRepository.findPsnKeysWithoutChoice(importId));
}
importRepository.setStep(importId, GameImport.Step.DETAILS);
List<Long> appids = importRepository.findAddedAppids(importId);
try {
@@ -180,6 +197,14 @@ public class GameImportWorker {
importRepository.markSaved(importId);
}
private void linkPsnTitle(long userId, String psnKey, IgdbGame game, ManualGameService.AddResult result) {
if (result instanceof ManualGameService.AddResult.Added added) {
psnTitleRepository.link(userId, psnKey, added.appid());
} else {
manualGameService.ownedKey(userId, game).ifPresent(appid -> psnTitleRepository.linkOwned(userId, psnKey, appid));
}
}
private record Match(boolean searchFailed, boolean exact, List<IgdbGame> candidates) {}
private Match match(GameImport.Line line) {
@@ -36,6 +36,7 @@ import core_service.modules.library.repo.GameCreditRepository;
import core_service.modules.library.repo.GameLinkRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.PsnTrophyRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -100,6 +101,7 @@ public class LibraryQueryService {
private final GameLinkRepository gameLinkRepository;
private final GameCreditRepository gameCreditRepository;
private final FriendPlayRepository friendPlayRepository;
private final PsnTrophyRepository psnTrophyRepository;
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
@@ -273,10 +275,13 @@ public class LibraryQueryService {
.collect(Collectors.toMap(UserGameAchievementMeta::getAppid, m -> m));
Map<Long, Integer> editions = gameLinkRepository.editionCounts(games.getFirst().getUserId(), appids);
Map<Long, ComingGame> coming = comingOf(appids);
Map<Long, AchievementProgress> trophies = AchievementProgress
.perGame(psnTrophyRepository.findProgress(games.getFirst().getUserId())).stream()
.collect(Collectors.toMap(AchievementProgress::appid, t -> t));
return games.stream()
.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()),
schemas.get(ug.getAppid()), unlocks.get(ug.getAppid()), editions.get(ug.getAppid()),
coming.get(ug.getAppid())))
coming.get(ug.getAppid()), trophies.get(ug.getAppid())))
.toList();
}
@@ -285,11 +290,12 @@ public class LibraryQueryService {
}
public AchievementSummaryDto getAchievementSummary(Long userId) {
List<AchievementProgress> games = userGameRepository.findAchievementProgress(userId);
List<AchievementProgress> games = progress(userId);
List<AchievementProgress> started = AchievementProgress.started(games);
List<AchievementProgress> closest = started.stream()
.filter(g -> g.unlockedCapped() < g.total())
.sorted(Comparator.comparingDouble((AchievementProgress g) -> (double) g.unlockedCapped() / g.total())
List<AchievementProgress> perGame = AchievementProgress.perGame(started);
List<AchievementProgress> closest = perGame.stream()
.filter(g -> !g.perfect())
.sorted(Comparator.comparingDouble(AchievementProgress::percent)
.reversed()
.thenComparingInt(g -> g.total() - g.unlockedCapped())
.thenComparing(AchievementProgress::appid))
@@ -299,9 +305,9 @@ public class LibraryQueryService {
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
return new AchievementSummaryDto(
started.stream().mapToInt(AchievementProgress::unlockedCapped).sum(),
started.size(),
perGame.size(),
AchievementProgress.perfectAppids(started).size(),
averageCompletion(started),
averageCompletion(perGame),
(int) games.stream().filter(g -> !g.known()).count(),
(int) games.stream().filter(g -> g.hasAchievements() && g.unlocked() != null && !g.visible()).count(),
closest.stream()
@@ -312,9 +318,15 @@ public class LibraryQueryService {
.toList());
}
private static Double averageCompletion(List<AchievementProgress> started) {
if (started.isEmpty()) return null;
double mean = started.stream().mapToDouble(g -> g.unlockedCapped() * 100.0 / g.total()).average().orElse(0);
// Steam achievements and PlayStation trophies; one entry per set.
private List<AchievementProgress> progress(Long userId) {
return Stream.concat(userGameRepository.findAchievementProgress(userId).stream(),
psnTrophyRepository.findProgress(userId).stream()).toList();
}
private static Double averageCompletion(List<AchievementProgress> perGame) {
if (perGame.isEmpty()) return null;
double mean = perGame.stream().mapToDouble(AchievementProgress::percent).average().orElse(0);
return Math.round(mean * 10.0) / 10.0;
}
@@ -383,7 +395,7 @@ public class LibraryQueryService {
Map<Long, SteamGame> meta = steamGameRepository.findAllById(shownAppids)
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
Map<Long, ComingGame> coming = comingOf(shownAppids);
List<AchievementProgress> started = summary ? AchievementProgress.started(userGameRepository.findAchievementProgress(userId)) : null;
List<AchievementProgress> started = summary ? AchievementProgress.started(progress(userId)) : null;
Set<StatGroup> shared = sections.contains(ProfileSection.STATS) ? StatGroup.shared(profileStats) : Set.of();
return new PublicLibraryDto(
summary ? byStatus.values().stream().mapToLong(Long::longValue).sum() : null,
@@ -392,7 +404,7 @@ public class LibraryQueryService {
sections.contains(ProfileSection.STATUS) ? byStatus : null,
toPublicGames(playing, meta, coming), toPublicGames(finished, meta, coming),
summary ? AchievementProgress.perfectAppids(started).size() : null,
summary ? averageCompletion(started) : null,
summary ? averageCompletion(AchievementProgress.perGame(started)) : null,
shared.isEmpty() ? null : userStatsService.getStats(userId, shared));
}
@@ -423,11 +435,17 @@ public class LibraryQueryService {
private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time,
GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks,
Integer editions, ComingGame coming) {
Integer editions, ComingGame coming, AchievementProgress trophies) {
Integer achievementsTotal = ug.getSource() == GameSource.STEAM && schema != null && schema.isHasSchema()
&& schema.getAchievementsTotal() > 0 ? schema.getAchievementsTotal() : null;
Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible()
? AchievementProgress.capped(unlocks.getAchievementsUnlocked(), achievementsTotal) : null;
boolean perfect = achievementsTotal != null && achievementsTotal.equals(achievementsUnlocked);
if (trophies != null && trophiesCount(ug, achievementsTotal, achievementsUnlocked, trophies)) {
achievementsTotal = trophies.total();
achievementsUnlocked = trophies.unlockedCapped();
perfect = trophies.perfect();
}
return new GameDto(
ug.getAppid(),
meta != null ? meta.getName() : null,
@@ -439,6 +457,7 @@ public class LibraryQueryService {
time != null ? time.getNormallyMinutes() : null,
achievementsUnlocked,
achievementsTotal,
perfect,
ug.getSource(),
ug.getPlatform(),
meta != null ? meta.getImageUrl() : null,
@@ -448,6 +467,16 @@ public class LibraryQueryService {
coming != null ? coming.getReleaseDate() : null);
}
// The tile shows what counts for the game: its best set, Steam's on a tie.
private static boolean trophiesCount(UserGame ug, Integer steamTotal, Integer steamUnlocked,
AchievementProgress trophies) {
if (steamTotal == null || steamUnlocked == null) {
return true;
}
AchievementProgress steam = new AchievementProgress(ug.getAppid(), steamTotal, steamUnlocked, true);
return AchievementProgress.perGame(List.of(steam, trophies)).getFirst() == trophies;
}
public Optional<UserSteamSync> getSyncStatus(Long userId) {
return userSteamSyncRepository.findById(userId);
}
@@ -3,12 +3,14 @@ package core_service.modules.library.service;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.PsnAccountRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@@ -19,6 +21,10 @@ import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;
@Service
@RequiresRole(Role.USER)
@@ -32,6 +38,9 @@ public class LibrarySyncService {
private final LibrarySyncWorker worker;
private final BacklogSnapshotRepository backlogSnapshotRepository;
private final FinishedEstimateService finishedEstimateService;
private final PsnAccountRepository psnAccountRepository;
private final TaskScheduler taskScheduler;
private final Map<Long, ScheduledFuture<?>> pendingAfterLink = new ConcurrentHashMap<>();
@Value("${app.library.sync-ttl-hours:20}")
private long syncTtlHours;
@@ -60,6 +69,10 @@ public class LibrarySyncService {
* request that won the gate
*/
public boolean syncIfStale(Long userId, String steamId) {
if (!canSync(userId, steamId)) {
stopNightlySync(userId);
return false;
}
Instant now = Instant.now();
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
touchUserSeen(sync);
@@ -94,7 +107,7 @@ public class LibrarySyncService {
userSteamSyncRepository.findByUserSeenAtAfter(now.minus(Duration.ofDays(nightlyActiveDays)));
List<UserSteamSync> accounts =
active.stream().filter(s -> !syncedLateToday(s.getLastSuccessAt(), today)).toList();
log.info("Nightly sync for {} of {} active Steam accounts", accounts.size(), active.size());
log.info("Nightly sync for {} of {} active accounts", accounts.size(), active.size());
// Taken before the syncs: the loop can run past midnight.
LocalDate day = today;
try {
@@ -121,6 +134,13 @@ public class LibrarySyncService {
}
}
/**
* A user syncs with a Steam ID or a PlayStation link.
*/
public boolean canSync(Long userId, String steamId) {
return steamId != null || psnAccountRepository.find(userId).isPresent();
}
/**
* For a user whose token has no Steam ID any more (unlinked, or taken by a verified link in Auth): the nightly
* job would otherwise keep syncing the old ID until the user is 14 days inactive.
@@ -131,8 +151,13 @@ public class LibrarySyncService {
/**
* User-triggered sync. Throws SyncCooldownException if recently synced or running.
*
* @throws IllegalArgumentException when the user has no Steam ID and no PlayStation link
*/
public void forceSync(Long userId, String steamId) {
if (!canSync(userId, steamId)) {
throw new IllegalArgumentException("No Steam ID or PlayStation link");
}
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
touchUserSeen(sync);
if (sync != null && sync.getLastSyncedAt() != null) {
@@ -147,6 +172,55 @@ public class LibrarySyncService {
worker.runSync(userId, steamId, SyncTrigger.MANUAL);
}
/**
* The sync after a PlayStation link: like {@link #forceSync}, with the same cooldown, so linking again and again
* never syncs faster than the sync button. While a sync runs or the cooldown has not ended, the sync is
* scheduled for the end of it; there is at most one scheduled sync per user.
*/
public void startAfterLink(Long userId, String steamId) {
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
touchUserSeen(sync);
Instant runAt = sync == null ? null : nextAllowedRun(sync);
if (runAt == null) {
worker.runSync(userId, steamId, SyncTrigger.MANUAL);
return;
}
log.info("Sync after the PlayStation link of user {} waits until {}", userId, runAt);
pendingAfterLink.compute(userId, (id, pending) -> pending != null && !pending.isDone() ? pending
: taskScheduler.schedule(() -> runAfterLinkLater(userId, steamId), runAt));
}
private void runAfterLinkLater(Long userId, String linkedSteamId) {
pendingAfterLink.remove(userId);
try {
// The Steam ID may have changed in the wait; a run with the old one would reset the Steam baseline.
String steamId = userSteamSyncRepository.findById(userId).map(UserSteamSync::getSteamId)
.orElse(linkedSteamId);
// Still blocked (a manual sync came first): the same call schedules the next try.
if (canSync(userId, steamId)) {
startAfterLink(userId, steamId);
}
} catch (RuntimeException e) {
log.warn("Scheduled sync after the PlayStation link of user {} failed", userId, e);
}
}
// Null: a sync may start now. A running sync has no known end, so the next try is one cooldown away.
private Instant nextAllowedRun(UserSteamSync sync) {
if (isAlreadyRunning(sync)) {
return Instant.now().plusSeconds(forceSyncCooldownSeconds);
}
if (!isInCooldown(sync)) {
return null;
}
return sync.getLastSyncedAt().plusSeconds(forceSyncCooldownSeconds);
}
private boolean isInCooldown(UserSteamSync sync) {
return sync.getLastSyncedAt() != null
&& Duration.between(sync.getLastSyncedAt(), Instant.now()).toSeconds() < forceSyncCooldownSeconds;
}
// At most one write per user and hour; the nightly sync only needs to know "used recently".
private void touchUserSeen(UserSteamSync sync) {
Instant now = Instant.now();
@@ -157,7 +231,7 @@ public class LibrarySyncService {
private boolean isLoginSyncDue(UserSteamSync sync, String steamId, Instant successBefore, Instant triedBefore) {
return !isAlreadyRunning(sync)
&& (!steamId.equals(sync.getSteamId())
&& (!Objects.equals(steamId, sync.getSteamId())
|| (isNullOrBefore(sync.getLastSuccessAt(), successBefore)
&& isNullOrBefore(sync.getLoginSyncTriedAt(), triedBefore)));
}
@@ -42,6 +42,7 @@ import java.util.stream.Collectors;
import core_service.modules.library.domain.UserSteamSync.Status;
import core_service.shared.background.BackgroundPools;
import core_service.shared.event.LibraryActivityEvent;
/**
* Worker bean. Runs the actual Steam fetch + DB upserts. Separate bean so the Spring proxy applies @Async
@@ -69,6 +70,7 @@ public class LibrarySyncWorker {
private final GameEditionService gameEditionService;
private final GameCreditService gameCreditService;
private final ApplicationEventPublisher eventPublisher;
private final PsnSyncStep psnSyncStep;
private final TransactionTemplate transaction;
@Value("${app.library.running-stale-minutes:5}")
@@ -77,6 +79,9 @@ public class LibrarySyncWorker {
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
/**
* @param steamId null for a user with a PlayStation link and no Steam account: only the PlayStation part runs
*/
@Async(BackgroundPools.DEFAULT)
public void runSync(Long userId, String steamId, SyncTrigger trigger) {
sync(userId, steamId, trigger);
@@ -101,7 +106,7 @@ public class LibrarySyncWorker {
return null;
}
String previousSteamId = before != null ? before.getSteamId() : null;
if (previousSteamId != null && !previousSteamId.equals(steamId)) {
if (steamId != null && previousSteamId != null && !previousSteamId.equals(steamId)) {
// The old games belong to the previous Steam account, also when Steam fails now. The upserts
// restore the games this account also owns.
userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now());
@@ -115,37 +120,55 @@ public class LibrarySyncWorker {
}
Status finalStatus = Status.FAILED;
boolean hasSteam = steamId != null;
try {
// Growth needs a successful earlier sync of this account; else all its playtime would count as today.
LocalDate playedOn = steamId.equals(claim.historySteamId()) ? LocalDate.ofInstant(syncStartedAt, zone) : null;
LocalDate syncDay = LocalDate.ofInstant(syncStartedAt, zone);
LocalDate playedOn = hasSteam && steamId.equals(claim.historySteamId()) ? syncDay : null;
// Distinct: the maps in saveGames are loaded once, so a second line of a game would be a new game again.
List<SteamOwnedGamesResponse.Game> games = steamApiService.getOwnedGames(steamId).stream()
List<SteamOwnedGamesResponse.Game> games = !hasSteam ? List.of()
: steamApiService.getOwnedGames(steamId).stream()
.filter(g -> g.appid() != null)
.collect(Collectors.toMap(SteamOwnedGamesResponse.Game::appid, g -> g, (first, second) -> first,
LinkedHashMap::new))
.values().stream().toList();
log.info("Sync user={} fetched {} games from Steam", userId, games.size());
if (hasSteam) {
log.info("Sync user={} fetched {} games from Steam", userId, games.size());
}
// Before the empty check: an account without games can still have a public wishlist.
Optional<Wishlist> wishlist = fetchWishlist(steamId, games);
Optional<Wishlist> wishlist = hasSteam ? fetchWishlist(steamId, games) : Optional.empty();
PsnSyncStep.Prepared psn = psnSyncStep.run(userId);
boolean saved = transaction.execute(tx -> {
List<SyncChange> wishlistAdds = wishlist.map(found -> saveWishlist(userId, found)).orElseGet(() -> {
log.warn("Sync user={} kept the old wishlist, Steam did not answer", userId);
if (hasSteam) {
log.warn("Sync user={} kept the old wishlist, Steam did not answer", userId);
}
return List.of();
});
if (games.isEmpty()) {
if (hasSteam && games.isEmpty()) {
// A private "Game details" setting ends here. A soft-delete would wipe the library.
log.warn("Sync user={} got no games for SteamID {}", userId, steamId);
return false;
}
List<SyncChange> changes = saveGames(userId, steamId, games, syncStartedAt, playedOn);
List<SyncChange> steamChanges = hasSteam ? saveGames(userId, steamId, games, syncStartedAt, playedOn)
: new ArrayList<>();
List<SyncChange> psnChanges = psnSyncStep.apply(userId, psn, syncStartedAt, syncDay);
// The first sync of an account is a baseline: a count, not a list.
if (playedOn == null) {
int imported = (int) changes.stream().filter(c -> c.kind() == SyncChangeKind.ADDED).count();
syncChangeRepository.replace(userId, syncStartedAt, trigger, imported, List.of());
int imported = 0;
List<SyncChange> listed = new ArrayList<>();
if (hasSteam && playedOn == null) {
imported += countAdded(steamChanges);
} else {
changes.addAll(wishlistAdds);
syncChangeRepository.replace(userId, syncStartedAt, trigger, 0, changes);
listed.addAll(steamChanges);
listed.addAll(wishlistAdds);
}
if (psn.baseline()) {
imported += countAdded(psnChanges);
psnChanges.stream().filter(change -> change.kind() != SyncChangeKind.ADDED).forEach(listed::add);
} else {
listed.addAll(psnChanges);
}
syncChangeRepository.replace(userId, syncStartedAt, trigger, imported, listed);
return true;
});
if (!saved) {
@@ -170,12 +193,17 @@ public class LibrarySyncWorker {
log.warn("Sync user={} ran past running-stale-minutes; the run that took over sets the status", userId);
} else if (status == Status.OK) {
// The listeners run after this commit.
eventPublisher.publishEvent(new LibrarySyncedEvent(userId, steamId));
eventPublisher.publishEvent(hasSteam ? new LibrarySyncedEvent(userId, steamId)
: new LibraryActivityEvent(userId));
}
});
}
}
private static int countAdded(List<SyncChange> changes) {
return (int) changes.stream().filter(change -> change.kind() == SyncChangeKind.ADDED).count();
}
/**
* @return what changed for the user, for the sync result
*/
@@ -233,6 +233,15 @@ public class ManualGameService {
.map(IgdbGame::igdbId).collect(Collectors.toSet());
}
/**
* The key the user owns this game under, if any (a merge included).
*/
public Optional<Long> ownedKey(Long userId, IgdbGame game) {
Map<Long, Long> targets = mergeRepository.findTargets(ownKeys(game));
Set<Long> owned = ownedAppids(userId, List.of(game), targets, List.of());
return possibleKeys(game, targets).stream().filter(owned::contains).findFirst();
}
private AddResult addInTransaction(Long userId, IgdbGame game, String platform, GameStatus status,
int playtimeMinutes, GameSource source, Long importId) {
return transaction.execute(tx -> {
@@ -270,7 +279,10 @@ public class ManualGameService {
public ChangeResult update(Long userId, long appid, String platform, int playtimeMinutes) {
return transaction.execute(tx -> changeManual(userId, appid, game -> {
game.setPlatform(platform);
game.setPlaytimeMinutes(playtimeMinutes);
// PlayStation owns the playtime of its rows: a typed value would be booked as growth by the next sync.
if (game.getSource() != GameSource.PSN) {
game.setPlaytimeMinutes(playtimeMinutes);
}
}));
}
@@ -2,7 +2,9 @@ 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.library.repo.PsnTrophyRepository;
import core_service.modules.psn_adapter.service.PsnApiService;
import core_service.modules.psn_adapter.service.PsnAuthService;
import core_service.shared.security.RequiresRole;
@@ -15,6 +17,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 +25,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 +36,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 +54,54 @@ public class PsnAccountService {
record Busy() implements LinkResult {}
}
public sealed interface ImportResult {
record Started(long id) implements ImportResult {}
public sealed interface FetchResult {
/**
* @param accessToken valid for about an hour; the sync reuses it for the trophy calls
*/
record Titles(List<PsnApiService.Title> titles, String accessToken) implements FetchResult {
@Override
public String toString() {
return "Titles[titles=" + titles.size() + "]";
}
}
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 PsnTrophyRepository trophyRepository;
private final Duration warningTime;
private final SecretBox box;
// One PlayStation call per user at a time: parallel calls of one user must not hold the shared rate limiter.
private final Set<Long> inFlight = ConcurrentHashMap.newKeySet();
public PsnAccountService(PsnAccountRepository repository, PsnAuthService authService, PsnApiService apiService,
GameImportService importService, @Value("${app.psn.token-key:}") String tokenKey) {
GameImportRepository importRepository, PsnTrophyRepository trophyRepository,
@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.trophyRepository = trophyRepository;
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 +112,10 @@ public class PsnAccountService {
public Status get(Long userId) {
Optional<PsnAccount> account = box != null ? repository.find(userId) : Optional.empty();
Long reviewImportId = box == null ? null : importRepository.findOpenPsnImport(userId)
.filter(found -> found.status() == GameImport.Status.READY).map(GameImport::id).orElse(null);
return new Status(isEnabled(), account.isPresent(), account.map(PsnAccount::linkedAt).orElse(null),
account.map(PsnAccount::refreshExpiresAt).orElse(null));
account.map(PsnAccount::refreshExpiresAt).orElse(null), reviewImportId);
}
public LinkResult link(Long userId, String npsso) {
@@ -132,36 +150,44 @@ public class PsnAccountService {
}
public void unlink(Long userId) {
// The trophies came with this account; another account linked later must start from nothing.
trophyRepository.deleteUser(userId);
if (repository.delete(userId) > 0) {
log.info("PSN account unlinked for user={}", userId);
}
}
/**
* Refreshes the access token, reads the played titles and starts an import with them. A refresh that returns
* the same refresh token keeps the stored expiry, a new one sets its own.
* Refreshes the access token and reads the played titles; the inbox gets a note when the link ends soon or has
* ended. A refresh that returns the same refresh token keeps the stored expiry, a new one sets its own.
*/
public ImportResult startImport(Long userId) {
public FetchResult fetchTitles(Long userId) {
if (box == null) {
return new ImportResult.Off();
return new FetchResult.Off();
}
if (!inFlight.add(userId)) {
return new ImportResult.Busy();
return new FetchResult.Busy();
}
try {
return doImport(userId);
return doFetch(userId);
} finally {
inFlight.remove(userId);
}
}
private ImportResult doImport(Long userId) {
private FetchResult doFetch(Long userId) {
Optional<PsnAccount> account = repository.find(userId);
if (account.isEmpty()) {
return new ImportResult.NotLinked();
return new FetchResult.NotLinked();
}
if (!account.get().refreshExpiresAt().isAfter(Instant.now())) {
return new ImportResult.Relink();
Instant now = Instant.now();
Instant expiresAt = account.get().refreshExpiresAt();
if (!expiresAt.isAfter(now)) {
repository.noteExpiry(userId, EXPIRED_NOTE);
return new FetchResult.Relink();
}
if (!expiresAt.isAfter(now.plus(warningTime))) {
repository.noteExpiry(userId, EXPIRING_NOTE);
}
String stored;
try {
@@ -169,33 +195,28 @@ public class PsnAccountService {
} catch (IllegalStateException e) {
// Sealed with another key: the user links again.
log.warn("Stored PSN token of user={} cannot be opened", userId);
repository.expireNow(userId);
return new ImportResult.Relink();
return expired(userId);
}
List<PsnApiService.Title> titles;
try {
Optional<PsnAuthService.Tokens> tokens = authService.refresh(stored);
if (tokens.isEmpty()) {
repository.expireNow(userId);
return new ImportResult.Relink();
return expired(userId);
}
if (!tokens.get().refreshToken().equals(stored)) {
repository.updateToken(userId, box.seal(tokens.get().refreshToken()), tokens.get().refreshExpiresAt());
}
titles = apiService.getPlayedTitles(tokens.get().accessToken());
return new FetchResult.Titles(apiService.getPlayedTitles(tokens.get().accessToken()),
tokens.get().accessToken());
} catch (RestClientException | RequestNotPermitted e) {
logFailure("PSN import failed", e);
return new ImportResult.Unavailable();
logFailure("PSN fetch failed", e);
return new FetchResult.Unavailable();
}
List<GameImport.Line> lines = PsnTitles.toLines(titles);
if (lines.isEmpty()) {
return new ImportResult.NoTitles();
}
return switch (importService.createLines(userId, GameImport.Source.PSN, lines)) {
case GameImportService.CreateResult.Created created -> new ImportResult.Started(created.id());
case GameImportService.CreateResult.AlreadyMatching ignored -> new ImportResult.AlreadyMatching();
case GameImportService.CreateResult.SearchUnavailable ignored -> new ImportResult.SearchUnavailable();
};
}
private FetchResult expired(Long userId) {
repository.expireNow(userId);
repository.noteExpiry(userId, EXPIRED_NOTE);
return new FetchResult.Relink();
}
// The class and the status only: the message of a RestClientException can hold the answer's body.
@@ -0,0 +1,245 @@
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, adds the games and reads the
* trophies, outside the sync transaction; {@link #apply} writes the playtime inside it. PlayStation never fails the Steam part.
*/
@Service
@Slf4j
public class PsnSyncStep {
private static final int TWO_WEEKS_DAYS = 14;
/**
* A LINKED title's PSN values, applied in the sync transaction.
*
* @param lastPlayedAt may be null
*/
public record Playtime(long appid, int minutes, Instant lastPlayedAt) {}
/**
* What {@link #run} did outside the transaction.
*
* @param changes the ADDED rows of this run
* @param baseline the user had no PSN title before this run
*/
public record Prepared(List<SyncChange> changes, List<Playtime> playtimes, boolean baseline) {
public static final Prepared NONE = new Prepared(List.of(), List.of(), false);
}
private final PsnAccountService accountService;
private final PsnTrophyStep trophyStep;
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, PsnTrophyStep trophyStep, PsnTitleRepository psnTitleRepository,
GameImportRepository importRepository, GameMergeRepository mergeRepository,
ManualGameService manualGameService, UserGameRepository userGameRepository,
PlaytimeDayRepository playtimeDayRepository, PlatformTransactionManager transactionManager) {
this.accountService = accountService;
this.trophyStep = trophyStep;
this.psnTitleRepository = psnTitleRepository;
this.importRepository = importRepository;
this.mergeRepository = mergeRepository;
this.manualGameService = manualGameService;
this.userGameRepository = userGameRepository;
this.playtimeDayRepository = playtimeDayRepository;
this.transaction = new TransactionTemplate(transactionManager);
}
/**
* Outside any transaction: PlayStation and IGDB calls, adds, review rows. Never throws.
*/
public Prepared run(Long userId) {
List<SyncChange> changes = new ArrayList<>();
List<Playtime> playtimes = new ArrayList<>();
boolean baseline = false;
try {
Optional<PsnAccountService.FetchResult.Titles> found = fetch(userId);
if (found.isEmpty()) {
return Prepared.NONE;
}
List<PsnApiService.Title> titles = found.get().titles();
List<GameImport.Line> lines = PsnTitles.toLines(titles);
Map<String, Instant> lastPlayed = PsnTitles.lastPlayedByKey(titles);
Map<String, PsnTitle> known = psnTitleRepository.findAll(userId);
baseline = known.isEmpty();
Set<String> open = importRepository.findOpenPsnKeys(userId);
List<GameImport.Row> review = new ArrayList<>();
for (GameImport.Line line : lines) {
PsnTitle seen = known.get(line.psnKey());
if (seen != null && seen.state() == PsnTitle.State.LINKED) {
playtimes.add(new Playtime(seen.appid(), line.playtimeMinutes(), lastPlayed.get(line.psnKey())));
} else if (seen == null && !open.contains(line.psnKey())) {
matchNew(userId, line, lastPlayed.get(line.psnKey()), changes, review);
}
}
saveReview(userId, review);
int added = changes.size();
changes.addAll(trophyStep.run(userId, found.get().accessToken(), titles));
log.info("PSN sync user={} added {} titles, {} wait for review, {} games gained trophies", userId, added,
review.size(), changes.size() - added);
return new Prepared(changes, mergedKeys(playtimes), baseline);
} catch (RuntimeException e) {
log.error("PSN step failed for user {}", userId, e);
return new Prepared(changes, playtimes, baseline);
}
}
private Optional<PsnAccountService.FetchResult.Titles> fetch(Long userId) {
switch (accountService.fetchTitles(userId)) {
case PsnAccountService.FetchResult.Titles found -> {
return Optional.of(found);
}
case PsnAccountService.FetchResult.Relink _ ->
log.info("PSN sync skipped for user={}: link expired", userId);
case PsnAccountService.FetchResult.Unavailable _, PsnAccountService.FetchResult.Busy _ ->
log.warn("PSN sync skipped for user={}", userId);
case PsnAccountService.FetchResult.NotLinked _, PsnAccountService.FetchResult.Off _ -> { }
}
return Optional.empty();
}
private void matchNew(Long userId, GameImport.Line line, Instant lastPlayedAt, List<SyncChange> changes,
List<GameImport.Row> review) {
Optional<List<IgdbGame>> found = manualGameService.findGames(line.title());
if (found.isEmpty()) {
return;
}
List<IgdbGame> candidates = GameImportWorker.rank(line, found.get());
IgdbGame first = candidates.isEmpty() ? null : candidates.getFirst();
boolean exact = first != null && GameImportWorker.sameName(first.name(), line.title())
&& first.platforms().stream().anyMatch(line.platform()::equalsIgnoreCase);
if (exact) {
addExact(userId, line, first, lastPlayedAt, changes);
} else if (!linkOwned(userId, line.psnKey(), first)) {
review.add(new GameImport.Row(line, false, false, candidates));
}
}
private void addExact(Long userId, GameImport.Line line, IgdbGame game, Instant lastPlayedAt,
List<SyncChange> changes) {
ManualGameService.AddResult added = manualGameService.addGame(userId, game, line.platform(), line.status(),
line.playtimeMinutes(), GameSource.PSN, null);
if (!(added instanceof ManualGameService.AddResult.Added result)) {
linkOwned(userId, line.psnKey(), game);
return;
}
psnTitleRepository.link(userId, line.psnKey(), result.appid());
if (lastPlayedAt != null) {
userGameRepository.setLastPlayedAt(userId, result.appid(), lastPlayedAt);
}
changes.add(SyncChange.added(result.appid()));
}
// Also the same game under another spelling or platform.
private boolean linkOwned(Long userId, String psnKey, IgdbGame game) {
Optional<Long> owned = game == null ? Optional.empty() : manualGameService.ownedKey(userId, game);
owned.ifPresent(appid -> psnTitleRepository.linkOwned(userId, psnKey, appid));
return owned.isPresent();
}
private void saveReview(Long userId, List<GameImport.Row> review) {
if (review.isEmpty()) {
return;
}
Optional<GameImport> open = importRepository.findOpenPsnImport(userId);
if (open.isEmpty()) {
transaction.executeWithoutResult(tx -> importRepository.createReady(userId, review));
} else if (open.get().status() == GameImport.Status.READY) {
transaction.executeWithoutResult(tx -> importRepository.appendReadyRows(open.get().id(), review));
}
}
private List<Playtime> mergedKeys(List<Playtime> playtimes) {
Map<Long, Long> targets = mergeRepository.findTargets(playtimes.stream().map(Playtime::appid).toList());
return playtimes.stream().map(playtime -> new Playtime(targets.getOrDefault(playtime.appid(), playtime.appid()),
playtime.minutes(), playtime.lastPlayedAt())).toList();
}
// Several titles can be one game (PS4 and PS5 versions, a review pick): its playtime is their sum, so the row
// gets one value per run and the total never flips between titles.
private static Collection<Playtime> perGame(List<Playtime> playtimes) {
Map<Long, Playtime> byGame = new LinkedHashMap<>();
for (Playtime playtime : playtimes) {
byGame.merge(playtime.appid(), playtime, (a, b) -> new Playtime(a.appid(),
(int) Math.min((long) a.minutes() + b.minutes(), Integer.MAX_VALUE),
a.lastPlayedAt() == null || (b.lastPlayedAt() != null && b.lastPlayedAt().isAfter(a.lastPlayedAt()))
? b.lastPlayedAt() : a.lastPlayedAt()));
}
return byGame.values();
}
/**
* In the sync transaction, after the Steam games are saved: playtime, last played and two-weeks minutes of the
* PSN rows. No status change: PSN rows have statusSetByUser.
*
* @return all PSN changes of the run (ADDED and PLAYTIME)
*/
public List<SyncChange> apply(Long userId, Prepared prepared, Instant now, LocalDate day) {
List<SyncChange> changes = new ArrayList<>(prepared.changes());
Map<Long, UserGame> touched = new HashMap<>();
for (Playtime playtime : perGame(prepared.playtimes())) {
userGameRepository.findById(new UserGameId(userId, playtime.appid()))
.filter(game -> game.getSource() == GameSource.PSN && game.getRemovedAt() == null)
.ifPresent(game -> {
int growth = playtime.minutes() - game.getPlaytimeMinutes();
game.setPlaytimeMinutes(playtime.minutes());
game.setLastSeenAt(now);
if (playtime.lastPlayedAt() != null) {
game.setLastPlayedAt(playtime.lastPlayedAt());
}
if (growth > 0) {
changes.add(SyncChange.playtime(game.getAppid(), growth));
playtimeDayRepository.addMinutes(userId, game.getAppid(), day, growth);
}
touched.put(game.getAppid(), game);
});
}
if (!touched.isEmpty()) {
Map<Long, Long> twoWeeks = playtimeDayRepository
.minutesPerGameSince(userId, new HashSet<>(touched.keySet()), day.minusDays(TWO_WEEKS_DAYS - 1))
.stream().collect(Collectors.toMap(AppidMinutes::appid, AppidMinutes::minutes));
touched.forEach((appid, game) ->
game.setPlaytime2Weeks((int) Math.min(twoWeeks.getOrDefault(appid, 0L), Integer.MAX_VALUE)));
}
return changes;
}
}
@@ -6,11 +6,14 @@ import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.psn_adapter.service.PsnApiService;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
/**
@@ -43,23 +46,43 @@ final class PsnTitles {
}
static List<GameImport.Line> toLines(List<PsnApiService.Title> titles) {
Map<String, List<PsnApiService.Title>> groups = new LinkedHashMap<>();
for (PsnApiService.Title title : titles) {
String key = title.conceptId() != null ? "c" + title.conceptId() : "t" + title.titleId();
groups.computeIfAbsent(key, k -> new ArrayList<>()).add(title);
}
List<GameImport.Line> lines = new ArrayList<>();
for (List<PsnApiService.Title> group : groups.values()) {
for (Map.Entry<String, List<PsnApiService.Title>> group : group(titles).entrySet()) {
if (lines.size() >= ImportRequest.MAX_ROWS) {
break;
}
PsnApiService.Title mostPlayed = group.stream()
PsnApiService.Title mostPlayed = group.getValue().stream()
.max(Comparator.comparing(PsnApiService.Title::playDuration)).orElseThrow();
long minutes = Math.min(ManualGameUpdate.MAX_MINUTES,
group.stream().mapToLong(title -> title.playDuration().toMinutes()).sum());
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));
(int) minutes, GameStatus.initialFor((int) minutes), null, group.getKey()));
}
return lines;
}
/**
* The newest last-played time of each title group; a group without any is left out.
*/
static Map<String, Instant> lastPlayedByKey(List<PsnApiService.Title> titles) {
Map<String, Instant> lastPlayed = new HashMap<>();
group(titles).forEach((key, group) -> group.stream().map(PsnApiService.Title::lastPlayedAt)
.filter(Objects::nonNull).max(Comparator.naturalOrder()).ifPresent(at -> lastPlayed.put(key, at)));
return lastPlayed;
}
private static Map<String, List<PsnApiService.Title>> group(List<PsnApiService.Title> titles) {
Map<String, List<PsnApiService.Title>> groups = new LinkedHashMap<>();
for (PsnApiService.Title title : titles) {
groups.computeIfAbsent(key(title), k -> new ArrayList<>()).add(title);
}
return groups;
}
/**
* The library key of a title: its concept, so PS4 and PS5 versions share it.
*/
static String key(PsnApiService.Title title) {
return title.conceptId() != null ? "c" + title.conceptId() : "t" + title.titleId();
}
}
@@ -0,0 +1,145 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.PsnEarned;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.PsnTrophy;
import core_service.modules.library.domain.PsnTrophySet;
import core_service.modules.library.domain.TrophyType;
import core_service.modules.psn_adapter.service.PsnApiService;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* Maps PlayStation trophy sets to library games and turns PlayStation values into stored ones. Only the user's own
* linked titles can map a set; a set is never matched across users or by name alone.
*/
final class PsnTrophies {
private static final int MAX_NAME_LENGTH = 512;
private static final int MAX_DETAIL_LENGTH = 2048;
private static final int MAX_URL_LENGTH = 1024;
private static final int MAX_GROUP_LENGTH = 8;
private PsnTrophies() {
}
/**
* Exact: the set PlayStation names for a linked title id, then by name for the sets left.
*
* @return set id to library key; a set with two candidate games is left out
*/
static Map<String, Long> setAppids(List<PsnApiService.Title> titles, Map<String, PsnTitle> links,
Map<String, String> setIdByTitleId, List<PsnApiService.TrophySet> sets) {
Map<String, Long> mapped = byTitleId(titles, links, setIdByTitleId);
mapped.putAll(byName(titles, links, sets, mapped.keySet()));
return mapped;
}
/**
* @param setIdByTitleId title id to set id; values may be null
*/
static Map<String, Long> byTitleId(List<PsnApiService.Title> titles, Map<String, PsnTitle> links,
Map<String, String> setIdByTitleId) {
Map<String, Long> mapped = new HashMap<>();
Set<String> conflicts = new HashSet<>();
for (PsnApiService.Title title : titles) {
String setId = setIdByTitleId.get(title.titleId());
Long appid = linkedAppid(links, title);
if (setId != null && appid != null) {
collect(mapped, conflicts, setId, appid);
}
}
conflicts.forEach(mapped::remove);
return mapped;
}
/**
* The sets not in {@code skip} whose clean name and platform match exactly one game of the user's linked titles.
*/
static Map<String, Long> byName(List<PsnApiService.Title> titles, Map<String, PsnTitle> links,
List<PsnApiService.TrophySet> sets, Set<String> skip) {
Map<String, Long> mapped = new HashMap<>();
Set<String> conflicts = new HashSet<>();
for (PsnApiService.TrophySet set : sets) {
if (skip.contains(set.npCommunicationId())) {
continue;
}
for (PsnApiService.Title title : titles) {
Long appid = linkedAppid(links, title);
if (appid != null && onPlatform(set, PsnTitles.platform(title.category()))
&& GameImportWorker.sameName(PsnTitles.cleanName(title.name()), PsnTitles.cleanName(set.name()))) {
collect(mapped, conflicts, set.npCommunicationId(), appid);
}
}
}
conflicts.forEach(mapped::remove);
return mapped;
}
static boolean inScope(PsnApiService.TrophySet set) {
return !set.hidden() && (onPlatform(set, "PS4") || onPlatform(set, "PS5"));
}
static PsnTrophySet toSet(PsnApiService.TrophySet set) {
PsnApiService.Counts defined = set.defined();
return new PsnTrophySet(set.npCommunicationId(), serviceName(set), set.version(),
cut(set.name(), MAX_NAME_LENGTH), httpsOrNull(set.iconUrl()), onPlatform(set, "PS5") ? "PS5" : "PS4",
defined.bronze(), defined.silver(), defined.gold(), defined.platinum(), set.earned().total(),
set.earned().platinum() > 0, set.lastUpdatedAt());
}
/**
* trophy2 is PS5, trophy is PS4; PlayStation sends the name, the platform is the fallback.
*/
static String serviceName(PsnApiService.TrophySet set) {
return set.serviceName() != null ? set.serviceName() : onPlatform(set, "PS5") ? "trophy2" : "trophy";
}
/**
* @return empty for a trophy type this code does not know
*/
static Optional<PsnTrophy> toTrophy(String setId, PsnApiService.Trophy trophy) {
return TrophyType.of(trophy.type()).map(type -> new PsnTrophy(setId, trophy.trophyId(), type,
cut(trophy.name(), MAX_NAME_LENGTH), cut(trophy.detail(), MAX_DETAIL_LENGTH),
httpsOrNull(trophy.iconUrl()), trophy.hidden(), cut(trophy.groupId(), MAX_GROUP_LENGTH), null, false,
null));
}
static PsnEarned toEarned(PsnApiService.EarnedTrophy trophy) {
return new PsnEarned(trophy.trophyId(), trophy.earned(), trophy.earnedAt(), trophy.earnedRate());
}
/**
* Only https URLs are stored; the page renders them as images.
*/
private static String httpsOrNull(String url) {
return url != null && url.length() <= MAX_URL_LENGTH && url.regionMatches(true, 0, "https://", 0, 8) ? url : null;
}
private static String cut(String text, int max) {
return text == null || text.length() <= max ? text : text.substring(0, max);
}
static Long linkedAppid(Map<String, PsnTitle> links, PsnApiService.Title title) {
PsnTitle link = links.get(PsnTitles.key(title));
return link != null && link.state() != PsnTitle.State.DISMISSED ? link.appid() : null;
}
private static boolean onPlatform(PsnApiService.TrophySet set, String platform) {
return set.platform() != null && Arrays.stream(set.platform().split(","))
.anyMatch(listed -> listed.strip().equalsIgnoreCase(platform));
}
private static void collect(Map<String, Long> mapped, Set<String> conflicts, String setId, long appid) {
Long previous = mapped.putIfAbsent(setId, appid);
if (previous != null && previous != appid) {
conflicts.add(setId);
}
}
}
@@ -0,0 +1,209 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.PsnEarned;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.PsnTrophy;
import core_service.modules.library.domain.PsnTrophySetState;
import core_service.modules.library.domain.SyncChange;
import core_service.modules.library.repo.GameMergeRepository;
import core_service.modules.library.repo.PsnTitleRepository;
import core_service.modules.library.repo.PsnTrophyRepository;
import core_service.modules.psn_adapter.service.PsnApiService;
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestClientResponseException;
import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Supplier;
import java.util.stream.Collectors;
/**
* The trophy part of the PlayStation sync. Outside any transaction, after the title part. The summary of every
* trophy set costs one or two calls; the trophy lists are fetched only for sets that changed, newest first, within
* a budget of calls per run. The access token comes from the title part and stays out of every log.
*/
@Service
@Slf4j
public class PsnTrophyStep {
static final int MAX_ERRORS_IN_A_ROW = 3;
private static final Duration RECHECK_NO_SET = Duration.ofDays(30);
private final PsnApiService apiService;
private final PsnTrophyRepository repository;
private final PsnTitleRepository titleRepository;
private final GameMergeRepository mergeRepository;
private final TransactionTemplate transaction;
private final int callsPerRun;
public PsnTrophyStep(PsnApiService apiService, PsnTrophyRepository repository, PsnTitleRepository titleRepository,
GameMergeRepository mergeRepository, PlatformTransactionManager transactionManager,
@Value("${app.psn.trophy-calls-per-run:30}") int callsPerRun) {
this.apiService = apiService;
this.repository = repository;
this.titleRepository = titleRepository;
this.mergeRepository = mergeRepository;
this.transaction = new TransactionTemplate(transactionManager);
this.callsPerRun = callsPerRun;
}
/**
* Outside any transaction. Never throws.
*
* @return ACHIEVEMENTS changes of the games whose sets gained trophies; a set seen the first time is a baseline
*/
public List<SyncChange> run(Long userId, String accessToken, List<PsnApiService.Title> titles) {
Map<Long, Integer> gains = new LinkedHashMap<>();
try {
PsnApiService.TrophySets summary = apiService.getTrophySets(accessToken);
List<PsnApiService.TrophySet> sets = summary.sets();
repository.deleteUserSets(userId, sets.stream().filter(PsnApiService.TrophySet::hidden)
.map(PsnApiService.TrophySet::npCommunicationId).toList());
// A complete list shows the sets of the linked account: those of an earlier account go. A partial or empty
// list proves nothing.
if (summary.complete() && !sets.isEmpty()) {
repository.deleteUserSetsExcept(userId,
sets.stream().map(PsnApiService.TrophySet::npCommunicationId).toList());
}
List<PsnApiService.TrophySet> inScope = sets.stream().filter(PsnTrophies::inScope).toList();
Budget budget = new Budget(callsPerRun);
Map<String, Long> appids = mapSets(userId, accessToken, titles, inScope, budget);
saveSets(userId, accessToken, inScope, appids, budget, gains);
} catch (RestClientException | RequestNotPermitted e) {
logFailure(e);
} catch (RuntimeException e) {
log.error("PSN trophy step failed for user {}", userId, e);
}
return gains.entrySet().stream().map(gain -> SyncChange.achievements(gain.getKey(), gain.getValue())).toList();
}
private Map<String, Long> mapSets(Long userId, String accessToken, List<PsnApiService.Title> titles,
List<PsnApiService.TrophySet> sets, Budget budget) {
Map<String, PsnTitle> links = titleRepository.findAll(userId);
Set<String> linkedTitleIds = titles.stream().filter(title -> PsnTrophies.linkedAppid(links, title) != null)
.map(PsnApiService.Title::titleId).collect(Collectors.toCollection(LinkedHashSet::new));
Map<String, String> setIdByTitleId = new HashMap<>(
repository.findTitleSets(linkedTitleIds, Instant.now().minus(RECHECK_NO_SET)));
List<String> toAsk = linkedTitleIds.stream().filter(id -> !setIdByTitleId.containsKey(id))
.limit((long) budget.callsLeft * PsnApiService.TITLE_IDS_PER_CALL).toList();
if (!toAsk.isEmpty()) {
PsnApiService.TrophySetIds ids = apiService.getTrophySetIds(accessToken, toAsk);
budget.callsLeft -= ids.calls();
if (ids.failure() != null) {
logFailure(ids.failure());
}
// Only an answered title can be "no set"; a failed or unsent call leaves no mark.
repository.saveTitleSets(ids.answered(), ids.found());
setIdByTitleId.putAll(ids.found());
}
Map<String, Long> mapped = PsnTrophies.byTitleId(titles, links, setIdByTitleId);
Set<String> inScopeIds = sets.stream().map(PsnApiService.TrophySet::npCommunicationId).collect(Collectors.toSet());
mapped.keySet().retainAll(inScopeIds);
int exact = mapped.size();
mapped.putAll(PsnTrophies.byName(titles, links, sets, mapped.keySet()));
log.info("PSN trophies user={} sets={} mapped by title id={} by name={}", userId, sets.size(), exact,
mapped.size() - exact);
Map<Long, Long> targets = mergeRepository.findTargets(mapped.values());
mapped.replaceAll((setId, appid) -> targets.getOrDefault(appid, appid));
return mapped;
}
private void saveSets(Long userId, String accessToken, List<PsnApiService.TrophySet> inScope,
Map<String, Long> appids, Budget budget, Map<Long, Integer> gains) {
List<PsnApiService.TrophySet> mapped = inScope.stream().filter(set -> appids.containsKey(set.npCommunicationId()))
.sorted(Comparator.comparing(PsnApiService.TrophySet::lastUpdatedAt,
Comparator.nullsLast(Comparator.reverseOrder())))
.toList();
Map<String, PsnTrophySetState> states = repository.findStates(userId,
mapped.stream().map(PsnApiService.TrophySet::npCommunicationId).toList());
for (PsnApiService.TrophySet set : mapped) {
String setId = set.npCommunicationId();
PsnTrophySetState state = states.get(setId);
Integer storedEarned = state == null ? null : state.earnedTotal();
boolean needList = state == null || state.listVersion() == null
|| set.version() != null && !set.version().equals(state.listVersion());
boolean needEarned = set.earned().total() > 0 && (storedEarned == null
|| !Objects.equals(state.listUpdatedAt(), set.lastUpdatedAt()));
List<PsnTrophy> trophies = needList ? fetchTrophies(accessToken, set, budget) : null;
List<PsnEarned> earned = needEarned ? fetchEarned(accessToken, set, budget) : null;
long appid = appids.get(setId);
transaction.executeWithoutResult(tx -> {
repository.saveSet(userId, appid, PsnTrophies.toSet(set));
if (trophies != null) {
repository.replaceTrophies(setId, set.version() != null ? set.version() : "", trophies);
}
if (earned != null) {
repository.replaceEarned(userId, setId, set.lastUpdatedAt(), earned);
}
});
if (storedEarned != null && set.earned().total() > storedEarned) {
gains.merge(appid, set.earned().total() - storedEarned, Integer::sum);
}
}
}
private List<PsnTrophy> fetchTrophies(String accessToken, PsnApiService.TrophySet set, Budget budget) {
return budget.call(() -> apiService.getTrophies(accessToken, set.npCommunicationId(),
PsnTrophies.serviceName(set)).trophies().stream()
.map(trophy -> PsnTrophies.toTrophy(set.npCommunicationId(), trophy))
.flatMap(Optional::stream).toList());
}
private List<PsnEarned> fetchEarned(String accessToken, PsnApiService.TrophySet set, Budget budget) {
return budget.call(() -> apiService.getEarnedTrophies(accessToken, set.npCommunicationId(),
PsnTrophies.serviceName(set)).stream()
.map(PsnTrophies::toEarned).toList());
}
// The class and the status only: the message of a RestClientException can hold the answer's body.
private static void logFailure(RuntimeException e) {
log.warn("PSN trophy call failed: {}{}", e.getClass().getSimpleName(),
e instanceof RestClientResponseException response ? " status " + response.getStatusCode().value() : "");
}
/**
* The calls left of a run; list calls stop after {@link #MAX_ERRORS_IN_A_ROW} errors.
*/
private static final class Budget {
private int callsLeft;
private int errorsInARow;
private Budget(int callsLeft) {
this.callsLeft = callsLeft;
}
/**
* @return null when the budget is spent or the call failed
*/
private <T> T call(Supplier<T> call) {
if (callsLeft <= 0 || errorsInARow >= MAX_ERRORS_IN_A_ROW) {
return null;
}
callsLeft--;
try {
T result = call.get();
errorsInARow = 0;
return result;
} catch (RestClientException | RequestNotPermitted e) {
logFailure(e);
errorsInARow++;
return null;
}
}
}
}
@@ -5,6 +5,7 @@ import core_service.modules.library.domain.ProfileSection;
import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.domain.StatKey;
import core_service.modules.library.domain.UserStats;
import core_service.modules.library.repo.PsnTrophyRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserStatsRepository;
import core_service.shared.security.Public;
@@ -24,6 +25,7 @@ import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;
/**
* The games behind a stat number, see {@link StatKey}. Reads the same sets as the numbers of {@link UserStatsService}.
@@ -38,6 +40,7 @@ public class StatGamesService {
private final UserStatsRepository userStatsRepository;
private final UserGameRepository userGameRepository;
private final PsnTrophyRepository psnTrophyRepository;
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
@@ -81,8 +84,9 @@ public class StatGamesService {
case LONGEST_DAY -> userStatsRepository.longestDay(userId).map(day -> (Object) day.day()).orElse(null);
case TREND_ADDED, TREND_DONE -> today.withDayOfMonth(1).atStartOfDay(zone).toInstant();
case TREND_MOVES -> today.minusDays(UserStatsService.TREND_DAYS - 1L).atStartOfDay(zone).toInstant();
case PERFECT -> AchievementProgress.perfectAppids(
AchievementProgress.started(userGameRepository.findAchievementProgress(userId)));
case PERFECT -> AchievementProgress.perfectAppids(AchievementProgress.started(
Stream.concat(userGameRepository.findAchievementProgress(userId).stream(),
psnTrophyRepository.findProgress(userId).stream()).toList()));
default -> arg;
};
}
@@ -12,7 +12,8 @@ import java.util.List;
public record PsnGameListResponse(List<Title> titles, Integer nextOffset) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record Title(String titleId, String name, String category, String playDuration, Concept concept) {}
public record Title(String titleId, String name, String category, String playDuration, Concept concept,
String lastPlayedDateTime) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Concept(Long id) {}
@@ -0,0 +1,15 @@
package core_service.modules.psn_adapter.api.dto.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
/**
* The trophy sets PlayStation names for title ids (trophy v1, titles/trophyTitles).
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record PsnTitleTrophySetsResponse(List<Title> titles) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record Title(String npTitleId, List<PsnTrophyTitlesResponse.TrophyTitle> trophyTitles) {}
}
@@ -0,0 +1,18 @@
package core_service.modules.psn_adapter.api.dto.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
/**
* The trophies of one set, from the global list or from the user's list (which adds earned, earnedDateTime and
* trophyEarnedRate). trophyType is bronze, silver, gold or platinum; trophyEarnedRate is a string like "12.3".
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record PsnTrophiesResponse(String trophySetVersion, List<Trophy> trophies) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record Trophy(Integer trophyId, Boolean trophyHidden, String trophyType, String trophyName,
String trophyDetail, String trophyIconUrl, String trophyGroupId, Boolean earned,
String earnedDateTime, String trophyEarnedRate) {}
}
@@ -0,0 +1,22 @@
package core_service.modules.psn_adapter.api.dto.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
/**
* One page of the trophy sets of the account (trophy v1). trophyTitlePlatform is a comma list like "PS5" or
* "PS4,PSVITA"; npServiceName is trophy (PS4) or trophy2 (PS5).
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record PsnTrophyTitlesResponse(List<TrophyTitle> trophyTitles, Integer nextOffset, Integer totalItemCount) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record TrophyTitle(String npServiceName, String npCommunicationId, String trophySetVersion,
String trophyTitleName, String trophyTitleIconUrl, String trophyTitlePlatform,
Boolean hiddenFlag, Counts definedTrophies, Counts earnedTrophies,
String lastUpdatedDateTime) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Counts(int bronze, int silver, int gold, int platinum) {}
}
@@ -1,22 +1,33 @@
package core_service.modules.psn_adapter.service;
import core_service.modules.psn_adapter.api.dto.response.PsnGameListResponse;
import core_service.modules.psn_adapter.api.dto.response.PsnTitleTrophySetsResponse;
import core_service.modules.psn_adapter.api.dto.response.PsnTrophiesResponse;
import core_service.modules.psn_adapter.api.dto.response.PsnTrophyTitlesResponse;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.UriBuilder;
import java.net.URI;
import java.time.Duration;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
/**
* The titles a PlayStation account has played (gamelist v2, unofficial), PS4 and PS5.
* The titles a PlayStation account has played (gamelist v2, unofficial), PS4 and PS5, and their trophies (trophy v1).
*/
@Service
@RequiresRole(Role.USER)
@@ -24,11 +35,45 @@ 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) {}
/**
* @param earned the user's earned counts; the earned total is {@code earned.total()}
* @param lastUpdatedAt null when PlayStation sends none or an unreadable value
* @param hidden the user hid the set on PlayStation
*/
public record TrophySet(String npCommunicationId, String serviceName, String version, String name, String iconUrl,
String platform, Counts defined, Counts earned, Instant lastUpdatedAt, boolean hidden) {}
public record Counts(int bronze, int silver, int gold, int platinum) {
public int total() {
return bronze + silver + gold + platinum;
}
}
/**
* @param type as PlayStation spells it: bronze, silver, gold or platinum
*/
public record Trophy(int trophyId, String type, String name, String detail, String iconUrl, boolean hidden,
String groupId) {}
/**
* @param earnedRate percent of all players; null when PlayStation sends none or an unreadable value
*/
public record EarnedTrophy(int trophyId, boolean earned, Instant earnedAt, Double earnedRate) {}
/**
* @param version trophySetVersion; null when PlayStation sends none
*/
public record TrophyList(String version, List<Trophy> trophies) {}
static final int PAGE_SIZE = 200;
static final int MAX_TITLES = 1000;
public static final int MAX_TITLES = 1000;
static final int TROPHY_PAGE_SIZE = 800;
public static final int TITLE_IDS_PER_CALL = 5;
private final RestClient apiClient;
private final RateLimiter rateLimiter;
@@ -46,23 +91,20 @@ public class PsnApiService {
int offset = 0;
while (titles.size() < MAX_TITLES) {
int from = offset;
RateLimiter.waitForPermission(rateLimiter);
PsnGameListResponse page = apiClient.get()
.uri(uri -> uri.path("/gamelist/v2/users/me/titles")
PsnGameListResponse page = get(accessToken, PsnGameListResponse.class,
uri -> uri.path("/gamelist/v2/users/me/titles")
.queryParam("categories", "ps4_game,ps5_native_game")
.queryParam("limit", PAGE_SIZE)
.queryParam("offset", from)
.build())
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.retrieve()
.body(PsnGameListResponse.class);
.build());
if (page == null || page.titles() == null) {
break;
}
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 +115,178 @@ public class PsnApiService {
return titles;
}
/**
* @param complete the number of sets equals the totalItemCount PlayStation reports: nothing is missing, so a set
* that is not in the list is not in the account. False after a broken page or the cap.
*/
public record TrophySets(List<TrophySet> sets, boolean complete) {}
/**
* Every trophy set of the account, also PS3 and Vita ones and hidden ones; the caller decides what is in scope.
*
* @throws org.springframework.web.client.RestClientException when PlayStation fails
*/
public TrophySets getTrophySets(String accessToken) {
List<TrophySet> sets = new ArrayList<>();
Integer total = null;
int offset = 0;
while (sets.size() < MAX_TITLES) {
int from = offset;
PsnTrophyTitlesResponse page = get(accessToken, PsnTrophyTitlesResponse.class,
uri -> uri.path("/trophy/v1/users/me/trophyTitles")
.queryParam("limit", TROPHY_PAGE_SIZE)
.queryParam("offset", from)
.build());
if (page == null || page.trophyTitles() == null) {
break;
}
total = page.totalItemCount();
for (PsnTrophyTitlesResponse.TrophyTitle title : page.trophyTitles()) {
if (title.npCommunicationId() != null && title.trophyTitleName() != null && sets.size() < MAX_TITLES) {
sets.add(toSet(title));
}
}
if (page.nextOffset() == null || page.nextOffset() <= offset) {
break;
}
offset = page.nextOffset();
}
return new TrophySets(sets, total != null && total == sets.size());
}
/**
* The answer of {@link #getTrophySetIds}.
*
* @param found title id to npCommunicationId; an answered title without a set is left out
* @param answered the title ids of the calls PlayStation answered: only these can be "no set"
* @param calls the HTTP calls made, also the failed one
* @param failure the error that ended the calls early; null when all were answered
*/
public record TrophySetIds(Map<String, String> found, List<String> answered, int calls, RuntimeException failure) {}
/**
* Asks PlayStation which trophy set belongs to each title id, 5 ids per call. Unconfirmed against the real
* service: any failure (also a 4xx or a rate limit) ends the calls and returns what was answered so far.
*/
public TrophySetIds getTrophySetIds(String accessToken, List<String> titleIds) {
Map<String, String> found = new LinkedHashMap<>();
List<String> answered = new ArrayList<>();
int calls = 0;
for (int from = 0; from < titleIds.size(); from += TITLE_IDS_PER_CALL) {
List<String> chunk = titleIds.subList(from, Math.min(titleIds.size(), from + TITLE_IDS_PER_CALL));
String ids = String.join(",", chunk);
PsnTitleTrophySetsResponse answer;
calls++;
try {
answer = get(accessToken, PsnTitleTrophySetsResponse.class,
uri -> uri.path("/trophy/v1/users/me/titles/trophyTitles").queryParam("npTitleIds", ids).build());
} catch (RestClientException | RequestNotPermitted e) {
return new TrophySetIds(found, answered, calls, e);
}
answered.addAll(chunk);
if (answer == null || answer.titles() == null) {
continue;
}
for (PsnTitleTrophySetsResponse.Title title : answer.titles()) {
if (title.npTitleId() != null && title.trophyTitles() != null) {
title.trophyTitles().stream().filter(set -> set.npCommunicationId() != null).findFirst()
.ifPresent(set -> found.put(title.npTitleId(), set.npCommunicationId()));
}
}
}
return new TrophySetIds(found, answered, calls, null);
}
/**
* The trophies of a set, the same for every user.
*
* @throws org.springframework.web.client.RestClientException when PlayStation fails
*/
public TrophyList getTrophies(String accessToken, String npCommunicationId, String serviceName) {
PsnTrophiesResponse answer = fetchTrophies("/trophy/v1/npCommunicationIds/{id}/trophyGroups/all/trophies",
accessToken, npCommunicationId, serviceName);
if (answer == null || answer.trophies() == null) {
return new TrophyList(null, List.of());
}
List<Trophy> trophies = new ArrayList<>();
for (PsnTrophiesResponse.Trophy trophy : answer.trophies()) {
if (trophy.trophyId() != null && trophy.trophyType() != null) {
trophies.add(new Trophy(trophy.trophyId(), trophy.trophyType(), trophy.trophyName(),
trophy.trophyDetail(), trophy.trophyIconUrl(), Boolean.TRUE.equals(trophy.trophyHidden()),
trophy.trophyGroupId() != null ? trophy.trophyGroupId() : "default"));
}
}
return new TrophyList(answer.trophySetVersion(), trophies);
}
/**
* What the account earned in a set, with the share of all players per trophy.
*
* @throws org.springframework.web.client.RestClientException when PlayStation fails
*/
public List<EarnedTrophy> getEarnedTrophies(String accessToken, String npCommunicationId, String serviceName) {
PsnTrophiesResponse answer = fetchTrophies(
"/trophy/v1/users/me/npCommunicationIds/{id}/trophyGroups/all/trophies", accessToken, npCommunicationId,
serviceName);
if (answer == null || answer.trophies() == null) {
return List.of();
}
return answer.trophies().stream().filter(trophy -> trophy.trophyId() != null)
.map(trophy -> new EarnedTrophy(trophy.trophyId(), Boolean.TRUE.equals(trophy.earned()),
instant(trophy.earnedDateTime()), rate(trophy.trophyEarnedRate())))
.toList();
}
private PsnTrophiesResponse fetchTrophies(String path, String accessToken, String npCommunicationId,
String serviceName) {
return get(accessToken, PsnTrophiesResponse.class,
uri -> uri.path(path).queryParam("npServiceName", serviceName).build(npCommunicationId));
}
private <T> T get(String accessToken, Class<T> type, Function<UriBuilder, URI> uri) {
RateLimiter.waitForPermission(rateLimiter);
return apiClient.get()
.uri(uri)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.retrieve()
.body(type);
}
private static TrophySet toSet(PsnTrophyTitlesResponse.TrophyTitle title) {
return new TrophySet(title.npCommunicationId(), title.npServiceName(), title.trophySetVersion(),
title.trophyTitleName(), title.trophyTitleIconUrl(), title.trophyTitlePlatform(),
counts(title.definedTrophies()), counts(title.earnedTrophies()), instant(title.lastUpdatedDateTime()),
Boolean.TRUE.equals(title.hiddenFlag()));
}
private static Counts counts(PsnTrophyTitlesResponse.Counts counts) {
return counts == null ? new Counts(0, 0, 0, 0)
: new Counts(counts.bronze(), counts.silver(), counts.gold(), counts.platinum());
}
private static Double rate(String value) {
if (value == null) {
return null;
}
try {
double rate = Double.parseDouble(value);
return Double.isFinite(rate) ? rate : null;
} catch (NumberFormatException e) {
return null;
}
}
private static Instant instant(String iso) {
if (iso == null) {
return null;
}
try {
return Instant.parse(iso);
} catch (DateTimeParseException e) {
return null;
}
}
private static Duration duration(String iso) {
if (iso == null) {
return Duration.ZERO;
@@ -20,7 +20,8 @@ import java.util.Optional;
/**
* What the library tables say about a user's games, as raw facts for {@link RewardRules}. Plain SQL, because the
* library module owns the entities and a module may not depend on another.
* library module owns the entities and a module may not depend on another. Rewards keep Steam-only proof: PlayStation
* rows (their playtime is PlayStation's number, and a title can be linked to a game later) give no facts.
*/
@Repository
@RequiredArgsConstructor
@@ -55,7 +56,9 @@ public class RewardFactsRepository {
where user_id = :userId and from_status = 'BACKLOG' and to_status = 'PLAYING'
group by user_id, appid) c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid and ug.removed_at is null
and ug.hidden_at is null
and ug.hidden_at is null and ug.source <> 'PSN'
and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid
and t.state = 'LINKED')
where not exists (select 1 from reward_ledger l where l.user_id = c.user_id and l.kind = 'STARTED'
and l.ref = c.appid::text)
order by c.started_at
@@ -88,7 +91,11 @@ public class RewardFactsRepository {
where user_id = :userId and to_status in ('FINISHED', 'COMPLETED')
group by user_id, appid) c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid and ug.removed_at is null
and ug.status in ('FINISHED', 'COMPLETED') and ug.hidden_at is null
and ug.status in ('FINISHED', 'COMPLETED') and ug.hidden_at is null and ug.source <> 'PSN'
and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid
and t.state = 'LINKED')
and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid
and t.state = 'LINKED')
left join game_time_to_beat t on t.appid = c.appid
left join user_game_achievement_meta m on m.user_id = c.user_id and m.appid = c.appid
left join game_achievement_schema_meta s on s.appid = c.appid
@@ -117,6 +124,9 @@ public class RewardFactsRepository {
left join user_game_achievement_meta m on m.user_id = ug.user_id and m.appid = ug.appid
left join game_achievement_schema_meta s on s.appid = ug.appid
where ug.user_id = :userId and ug.status = 'COMPLETED' and ug.removed_at is null and ug.hidden_at is null
and ug.source <> 'PSN'
and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid
and t.state = 'LINKED')
and not exists (select 1 from reward_ledger l where l.user_id = ug.user_id and l.kind = 'COMPLETED'
and l.ref = ug.appid::text)
""", new MapSqlParameterSource("userId", userId),
@@ -125,7 +135,8 @@ public class RewardFactsRepository {
}
/**
* Playtime minutes per month of the Steam playtime days (day is a Berlin date). Hidden games count too: hiding
* Playtime minutes per month of the Steam playtime days (day is a Berlin date); the days of a PlayStation row, or of
* a game a PlayStation title is linked to, are left out. Hidden games count too: hiding
* is free and reversible, so excluding them would let a user shape the curve for one settle.
*/
public Map<YearMonth, Long> minutesPerMonth(long userId) {
@@ -134,6 +145,10 @@ public class RewardFactsRepository {
select to_char(p.day, 'YYYY-MM') as month, sum(p.minutes) as minutes
from user_game_playtime_day p
where p.user_id = :userId
and not exists (select 1 from user_game g where g.user_id = p.user_id and g.appid = p.appid
and g.source = 'PSN')
and not exists (select 1 from user_psn_title t where t.user_id = p.user_id and t.appid = p.appid
and t.state = 'LINKED')
group by 1
""", new MapSqlParameterSource("userId", userId),
(RowCallbackHandler) rs -> result.put(YearMonth.parse(rs.getString("month")), rs.getLong("minutes")));
+5
View File
@@ -56,6 +56,11 @@ 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
# Trophy calls per sync run (title id lookups and trophy lists). The sync counts as stale after 5 minutes and
# the rate limit below is one call per 4 s, so 30 calls take 2 minutes.
trophy-calls-per-run: 30
# Days before the link ends that the inbox gets its "ends soon" note.
expiry-warning-days: 2
# The public client of the PlayStation app, as the psn-api project ships it. Not a secret of ours.
client-id: 09515159-7237-4370-9b40-3806e67c0891
client-secret: ucPjka5tntB2KqsP
@@ -0,0 +1,40 @@
-- PlayStation titles the sync has seen per user (#25 slice 2), one per concept (PS4 + PS5 = one). LINKED keeps its
-- appid also after the user removed the game, so the sync never adds it again. STEAM_OWNED: the title matched a game the user
-- owns from Steam; kept so it is not offered again, but PlayStation gives no playtime for it. DISMISSED: left unchecked
-- in a review.
CREATE TABLE user_psn_title (
user_id BIGINT NOT NULL,
psn_key VARCHAR(40) NOT NULL, -- PsnTitles key: "c<conceptId>" or "t<titleId>"
state VARCHAR(16) NOT NULL CHECK (state IN ('LINKED', 'STEAM_OWNED', 'DISMISSED')),
appid BIGINT, -- LINKED: the library key
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, psn_key)
);
-- The title a review line came from; NULL for any other import.
ALTER TABLE game_import_row ADD COLUMN psn_key VARCHAR(40);
-- The inbox note written for this link: NULL, PSN_EXPIRING or PSN_EXPIRED. A new link resets it.
ALTER TABLE user_psn_account ADD COLUMN expiry_note VARCHAR(16);
-- A user with a PlayStation link and no Steam account syncs too (steam_id NULL).
ALTER TABLE user_steam_sync ALTER COLUMN steam_id DROP NOT NULL;
ALTER TABLE inbox_note DROP CONSTRAINT inbox_note_kind_check;
ALTER TABLE inbox_note ADD CONSTRAINT inbox_note_kind_check
CHECK (kind IN ('DIGEST', 'VISIBILITY', 'PSN_EXPIRING', 'PSN_EXPIRED'));
-- When the user last played the game, from PlayStation; Steam gives no such value. The two views over user_game fix
-- their columns at creation (see V24, V28): drop them, add the column, create them again.
DROP VIEW counted_user_game;
DROP VIEW listed_user_game;
ALTER TABLE user_game ADD COLUMN last_played_at TIMESTAMPTZ;
CREATE VIEW listed_user_game AS
SELECT * FROM user_game WHERE removed_at IS NULL AND hidden_at IS NULL;
CREATE VIEW counted_user_game AS
SELECT ug.* FROM listed_user_game ug
WHERE NOT EXISTS (
SELECT 1 FROM game_link l
JOIN game_link o ON o.set_id = l.set_id AND o.appid <> l.appid
JOIN listed_user_game og ON og.user_id = ug.user_id AND og.appid = o.appid
WHERE l.appid = ug.appid
AND (CASE og.status WHEN 'COMPLETED' THEN 4 WHEN 'FINISHED' THEN 3 WHEN 'PLAYING' THEN 2 WHEN 'DROPPED' THEN 1 ELSE 0 END,
og.playtime_minutes, -extract(epoch from og.first_seen_at), -og.appid)
> (CASE ug.status WHEN 'COMPLETED' THEN 4 WHEN 'FINISHED' THEN 3 WHEN 'PLAYING' THEN 2 WHEN 'DROPPED' THEN 1 ELSE 0 END,
ug.playtime_minutes, -extract(epoch from ug.first_seen_at), -ug.appid));
@@ -0,0 +1,61 @@
-- PlayStation trophies (#25 slice 3). Own tables, keyed by npCommunicationId: the Steam achievement tables stay
-- untouched, so rewards (Steam only) never see trophies.
-- Global cache: a gamelist titleId and the trophy set PlayStation names for it.
CREATE TABLE psn_title_trophy_set (
title_id VARCHAR(16) PRIMARY KEY,
np_communication_id VARCHAR(16), -- NULL: PlayStation knows no set; asked again after 30 days
checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Global: one trophy set (the trophies of one game on one console generation).
CREATE TABLE psn_trophy_set (
np_communication_id VARCHAR(16) PRIMARY KEY,
service_name VARCHAR(8) NOT NULL, -- trophy (PS4) / trophy2 (PS5)
platform VARCHAR(32) NOT NULL, -- trophyTitlePlatform, e.g. PS5 or PS4
name VARCHAR(512) NOT NULL,
icon_url VARCHAR(1024),
set_version VARCHAR(16), -- trophySetVersion of the last summary
list_version VARCHAR(16), -- version of the stored psn_trophy rows; NULL = never fetched
defined_bronze INTEGER NOT NULL,
defined_silver INTEGER NOT NULL,
defined_gold INTEGER NOT NULL,
defined_platinum INTEGER NOT NULL,
refreshed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Global: the trophies of a set.
CREATE TABLE psn_trophy (
np_communication_id VARCHAR(16) NOT NULL REFERENCES psn_trophy_set ON DELETE CASCADE,
trophy_id INTEGER NOT NULL,
type VARCHAR(8) NOT NULL CHECK (type IN ('BRONZE', 'SILVER', 'GOLD', 'PLATINUM')),
name VARCHAR(512),
detail VARCHAR(2048),
icon_url VARCHAR(1024),
hidden BOOLEAN NOT NULL DEFAULT FALSE,
group_id VARCHAR(8) NOT NULL DEFAULT 'default', -- 'default' = base game, '001'.. = DLC
earned_rate DOUBLE PRECISION, -- % of players, from the last earned list call
PRIMARY KEY (np_communication_id, trophy_id)
);
-- A user's progress in a set, from the summary.
CREATE TABLE user_psn_trophy_set (
user_id BIGINT NOT NULL,
np_communication_id VARCHAR(16) NOT NULL REFERENCES psn_trophy_set,
appid BIGINT NOT NULL, -- library key the set counts for (merge target)
earned_total INTEGER NOT NULL,
platinum_earned BOOLEAN NOT NULL,
last_updated_at TIMESTAMPTZ, -- lastUpdatedDateTime of the last summary
list_updated_at TIMESTAMPTZ, -- lastUpdatedDateTime the stored earned list has; NULL = never
PRIMARY KEY (user_id, np_communication_id)
);
CREATE INDEX idx_upts_user_app ON user_psn_trophy_set (user_id, appid);
-- The trophies a user earned.
CREATE TABLE user_psn_trophy (
user_id BIGINT NOT NULL,
np_communication_id VARCHAR(16) NOT NULL,
trophy_id INTEGER NOT NULL,
earned_at TIMESTAMPTZ,
PRIMARY KEY (user_id, np_communication_id, trophy_id)
);
@@ -2,7 +2,10 @@ package core_service.configuration;
import core_service.modules.igdb_adapter.api.dto.response.IgdbResponses;
import core_service.modules.psn_adapter.api.dto.response.PsnGameListResponse;
import core_service.modules.psn_adapter.api.dto.response.PsnTitleTrophySetsResponse;
import core_service.modules.psn_adapter.api.dto.response.PsnTokenResponse;
import core_service.modules.psn_adapter.api.dto.response.PsnTrophiesResponse;
import core_service.modules.psn_adapter.api.dto.response.PsnTrophyTitlesResponse;
import core_service.modules.speedrun_adapter.api.dto.response.SpeedrunResponses;
import core_service.modules.steam_adapter.api.dto.response.SteamGlobalAchievementPercentagesResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
@@ -52,6 +55,9 @@ class LocalMocksParseTest extends AbstractUnitTest {
Map.entry("igdb-game-", IgdbResponses.Game[].class),
Map.entry("psn-token", PsnTokenResponse.class),
Map.entry("psn-gamelist", PsnGameListResponse.class),
Map.entry("psn-trophy-titles", PsnTrophyTitlesResponse.class),
Map.entry("psn-trophy-title-sets", PsnTitleTrophySetsResponse.class),
Map.entry("psn-trophies-", PsnTrophiesResponse.class),
Map.entry("speedrun-games-", SpeedrunResponses.Games.class),
Map.entry("speedrun-runs-", SpeedrunResponses.Runs.class));
@@ -17,7 +17,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_psn_trophy", "user_psn_trophy_set", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
"reward_equipped", "user_psn_account", "listed_user", "public_user", "follow", "follow_digest", "inbox_note");
@Autowired
@@ -58,6 +58,12 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'SAVED', 0)", userId);
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', 20, now())", userId);
jdbc.update("insert into user_psn_account (user_id, refresh_token_enc, refresh_expires_at) values (?, '\\x01'::bytea, now())", userId);
jdbc.update("insert into psn_trophy_set (np_communication_id, service_name, platform, name, defined_bronze, "
+ "defined_silver, defined_gold, defined_platinum) values ('NPWR1_00', 'trophy', 'PS4', 'Game', 1, 0, 0, 0) "
+ "on conflict do nothing");
jdbc.update("insert into user_psn_trophy_set (user_id, np_communication_id, appid, earned_total, platinum_earned) "
+ "values (?, 'NPWR1_00', 220, 1, false)", userId);
jdbc.update("insert into user_psn_trophy (user_id, np_communication_id, trophy_id) values (?, 'NPWR1_00', 1)", userId);
jdbc.update("insert into reward_equipped (user_id, slot, item_code) values (?, 'BADGE', 'BADGE_PILE_SLAYER')", userId);
}
}
@@ -1,11 +1,13 @@
package core_service.modules.library.api;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.PsnTrophySet;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.PsnTrophyRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -16,6 +18,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
@@ -59,11 +62,24 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
@Autowired
private LibrarySyncWorker librarySyncWorker;
@Autowired
private PsnTrophyRepository psnTrophyRepository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void clean() {
userGameRepository.deleteAll();
schemaMetaRepository.deleteAll();
userAchievementMetaRepository.deleteAll();
jdbc.update("delete from user_psn_trophy_set");
jdbc.update("delete from psn_trophy_set");
}
private void trophies(long appid, String setId, String platform, int total, int earned, boolean platinum) {
psnTrophyRepository.saveSet(USER_ID, appid, new PsnTrophySet(setId, "trophy2", "01.00", "Set " + setId, null,
platform, total, 0, 0, 0, earned, platinum, Instant.now()));
}
/**
@@ -130,6 +146,43 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
.andExpect(jsonPath("$.closest[1].imageUrl").doesNotExist());
}
@Test
void summary_TrophiesCount_APlatinumSetIsPerfectAndOneGameCountsOnce() throws Exception {
game(10L, null, null, true);
game(20L, 10, 4, true);
trophies(10L, "NPWR1_00", "PS5", 50, 30, true);
trophies(10L, "NPWR2_00", "PS4", 5, 2, false);
trophies(20L, "NPWR3_00", "PS4", 10, 6, false);
mockMvc.perform(get("/api/library/achievements/summary"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.unlocked").value(42))
.andExpect(jsonPath("$.startedGames").value(2))
.andExpect(jsonPath("$.perfectGames").value(1))
.andExpect(jsonPath("$.averageCompletion").value(80.0))
.andExpect(jsonPath("$.closest", hasSize(1)))
.andExpect(jsonPath("$.closest[0].appid").value(20))
.andExpect(jsonPath("$.closest[0].unlocked").value(6))
.andExpect(jsonPath("$.closest[0].total").value(10));
}
@Test
void games_TheTileShowsTheRealTrophyCount_AndFlagsAPlatinumSetAsPerfect() throws Exception {
game(10L, null, null, true);
game(20L, 10, 4, true);
trophies(10L, "NPWR1_00", "PS5", 50, 30, true);
trophies(20L, "NPWR3_00", "PS4", 10, 2, false);
mockMvc.perform(get("/api/library/games").param("sort", "appid,asc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].achievementsUnlocked").value(30))
.andExpect(jsonPath("$.content[0].achievementsTotal").value(50))
.andExpect(jsonPath("$.content[0].achievementsPerfect").value(true))
.andExpect(jsonPath("$.content[1].achievementsUnlocked").value(4))
.andExpect(jsonPath("$.content[1].achievementsTotal").value(10))
.andExpect(jsonPath("$.content[1].achievementsPerfect").value(false));
}
@Test
void games_CarryTheAchievementProgress() throws Exception {
game(20L, 4, 3, true);
@@ -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
@@ -2,12 +2,14 @@ package core_service.modules.library.api;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.PsnTrophySet;
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.UserGameAchievementMeta;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameReleaseRepository;
import core_service.modules.library.repo.PsnTrophyRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -17,6 +19,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
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;
@@ -77,12 +80,25 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
@Autowired
private GameReleaseRepository gameReleaseRepository;
@Autowired
private PsnTrophyRepository psnTrophyRepository;
@Autowired
private JdbcTemplate jdbc;
private void platinum(long appid) {
psnTrophyRepository.saveSet(USER_ID, appid, new PsnTrophySet("NPWR1_00", "trophy2", "01.00", "Set", null, "PS5",
50, 0, 0, 0, 30, true, Instant.now()));
}
@BeforeEach
void seed() {
gameReleaseRepository.deleteAll();
userGameRepository.deleteAll();
schemaMetaRepository.deleteAll();
userAchievementMetaRepository.deleteAll();
jdbc.update("delete from user_psn_trophy_set");
jdbc.update("delete from psn_trophy_set");
steamGameRepository.saveAll(List.of(
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
new SteamGame(620L, "Portal 2", null, Instant.now()),
@@ -148,6 +164,31 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.averageCompletion").value(62.5));
}
@Test
void publicLibrary_APlatinumSetIsPerfect_WhenTheSummaryIsShown() throws Exception {
platinum(220L);
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.perfectGames").value(1))
.andExpect(jsonPath("$.averageCompletion").value(100.0));
mockMvc.perform(get("/api/public/profiles/library")
.header("X-Profile-Ticket", ticket(USER_ID, List.of(), List.of("PLAYING"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.perfectGames").value(nullValue()))
.andExpect(jsonPath("$.averageCompletion").value(nullValue()));
}
@Test
void statGames_APlatinumGameIsInThePerfectList() throws Exception {
platinum(220L);
statGames(ticket(USER_ID, List.of(), List.of("SUMMARY")), "PERFECT", null)
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.content[0].appid").value(220));
}
@Test
void publicLibrary_TicketWithoutStatGroups_HasNoStats() throws Exception {
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID)))
@@ -0,0 +1,49 @@
package core_service.modules.library.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class AchievementProgressTest extends AbstractUnitTest {
private static AchievementProgress set(long appid, int total, int unlocked, boolean platinum) {
return new AchievementProgress(appid, total, unlocked, true, platinum);
}
@Test
void platinum_MakesASetPerfectAndFullPercent_EvenWhenTrophiesAreMissing() {
AchievementProgress platinum = set(1L, 50, 30, true);
assertThat(platinum.perfect()).isTrue();
assertThat(platinum.percent()).isEqualTo(100.0);
assertThat(set(1L, 50, 30, false).perfect()).isFalse();
assertThat(set(1L, 50, 30, false).percent()).isEqualTo(60.0);
assertThat(set(1L, 50, 50, false).perfect()).isTrue();
}
@Test
void steamConstructor_HasNoPlatinum() {
assertThat(new AchievementProgress(1L, 10, 10, true).platinum()).isFalse();
}
@Test
void perfectAppids_ListsAGameOnce_WhenTwoSetsArePerfect() {
List<AchievementProgress> sets = List.of(set(1L, 10, 10, false), set(1L, 50, 30, true), set(2L, 10, 3, false));
assertThat(AchievementProgress.perfectAppids(sets)).containsExactly(1L);
}
@Test
void perGame_PicksAPerfectSet_ElseTheBestPercent_OnePerGameByAppid() {
AchievementProgress perfect = set(2L, 50, 30, true);
AchievementProgress best = set(1L, 10, 8, false);
List<AchievementProgress> games = AchievementProgress.perGame(List.of(set(2L, 10, 9, false), perfect,
set(1L, 10, 4, false), best));
assertThat(games).containsExactly(best, perfect);
}
}
@@ -0,0 +1,155 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.IgdbGame;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import tools.jackson.databind.json.JsonMapper;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Import({GameImportRepository.class, GameImportRepositoryTest.Mapper.class})
class GameImportRepositoryTest extends AbstractPersistenceTest {
@TestConfiguration
static class Mapper {
@Bean
JsonMapper jsonMapper() {
return JsonMapper.builder().build();
}
}
private static final IgdbGame GAME = new IgdbGame(300, "Ghost of Yotei", 2025, null, List.of("PS5"), null);
@Autowired
private GameImportRepository repository;
@Autowired
private JdbcTemplate jdbc;
private static GameImport.Row row(String title, String psnKey, IgdbGame... candidates) {
return new GameImport.Row(new GameImport.Line(1, title, "PS5", 60, GameStatus.PLAYING, null, psnKey), false, false,
List.of(candidates));
}
private void setStatus(long id, String status) {
jdbc.update("update game_import set status = ? where id = ?", status, id);
}
@Test
void createReady_WritesAReadyPsnImportWithMatchedRowsPsnKeyAndCandidates() {
long id = repository.createReady(1L, List.of(row("Ghost of Yotei", "c10", GAME), row("Wilds", "c11")));
GameImport found = repository.find(1L, id).orElseThrow();
assertThat(found.source()).isEqualTo(GameImport.Source.PSN);
assertThat(found.status()).isEqualTo(GameImport.Status.READY);
assertThat(found.total()).isEqualTo(2);
assertThat(found.matched()).isEqualTo(2);
List<GameImport.Row> rows = repository.findRows(id);
assertThat(rows).extracting(r -> r.line().lineNo()).containsExactly(1, 2);
assertThat(rows).extracting(r -> r.line().psnKey()).containsExactly("c10", "c11");
assertThat(rows.getFirst().candidates()).containsExactly(GAME);
assertThat(rows.get(1).candidates()).isEmpty();
assertThat(repository.findUnmatchedLines(id)).isEmpty();
}
@Test
void appendReadyRows_ContinuesTheLineNumbers_AndRaisesTheTotal() {
long id = repository.createReady(2L, List.of(row("A", "c1")));
assertThat(repository.appendReadyRows(id, List.of(row("B", "c2"), row("C", "c3")))).isTrue();
assertThat(repository.findRows(id)).extracting(r -> r.line().lineNo()).containsExactly(1, 2, 3);
assertThat(repository.find(2L, id).orElseThrow().total()).isEqualTo(3);
}
@Test
void appendReadyRows_AnImportTheUserSavedMeanwhile_GetsNothing() {
long id = repository.createReady(3L, List.of(row("A", "c1")));
setStatus(id, "SAVING");
assertThat(repository.appendReadyRows(id, List.of(row("B", "c2")))).isFalse();
assertThat(repository.findRows(id)).hasSize(1);
assertThat(repository.find(3L, id).orElseThrow().total()).isEqualTo(1);
}
@Test
void findOpenPsnImport_AndKeys_SeeOnlyThePsnImportsOfTheUserInTheFourOpenStates() {
long finished = repository.createReady(4L, List.of(row("Old", "c1")));
setStatus(finished, "SAVED");
long undone = repository.createReady(4L, List.of(row("Undone", "c2")));
setStatus(undone, "UNDONE");
long failed = repository.createReady(4L, List.of(row("Failed", "c3")));
setStatus(failed, "FAILED");
repository.createReady(5L, List.of(row("Other user", "c4")));
long text = repository.create(4L, GameImport.Source.TEXT, List.of(
new GameImport.Line(1, "Typed", null, null, null, null, null)));
assertThat(text).isPositive();
assertThat(repository.findOpenPsnImport(4L)).isEmpty();
assertThat(repository.findOpenPsnKeys(4L)).isEmpty();
// The V22 index allows one queued import per user.
setStatus(text, "SAVED");
long open = repository.createReady(4L, List.of(row("Open", "c5"), row("No key", null)));
assertThat(repository.findOpenPsnImport(4L)).hasValueSatisfying(found -> assertThat(found.id()).isEqualTo(open));
assertThat(repository.findOpenPsnKeys(4L)).containsExactly("c5");
for (String status : List.of("QUEUED", "MATCHING", "SAVING")) {
setStatus(open, status);
assertThat(repository.findOpenPsnImport(4L)).isPresent();
}
}
@Test
void findPsnKeysWithoutChoice_ListsTheLinesTheUserDidNotCheck() {
long id = repository.createReady(6L, List.of(row("A", "c1"), row("B", "c2"), row("Typed", null)));
repository.saveChoices(id, List.of(new GameImport.Choice(1, 300, "PS5", GameStatus.PLAYING, 60)));
assertThat(repository.findPsnKeysWithoutChoice(id)).containsExactly("c2");
}
@Test
void moveUnseenToNewReady_TakesTheLinesWithoutChoiceTheUserDidNotSee_ToANewReadyImport() {
long id = repository.createReady(8L, List.of(row("A", "c1"), row("B", "c2"), row("C", "c3"), row("D", "c4")));
repository.saveChoices(id, List.of(new GameImport.Choice(1, 300, "PS5", GameStatus.PLAYING, 60)));
repository.moveUnseenToNewReady(8L, id, java.util.Set.of(1, 2));
assertThat(repository.findPsnKeysWithoutChoice(id)).containsExactly("c2");
assertThat(repository.find(8L, id).orElseThrow().total()).isEqualTo(2);
GameImport open = repository.findOpenPsnImport(8L).orElseThrow();
assertThat(open.id()).isNotEqualTo(id);
assertThat(open.status()).isEqualTo(GameImport.Status.READY);
assertThat(open.total()).isEqualTo(2);
assertThat(repository.findRows(open.id())).extracting(r -> r.line().lineNo(), r -> r.line().psnKey())
.containsExactly(org.assertj.core.groups.Tuple.tuple(1, "c3"), org.assertj.core.groups.Tuple.tuple(2, "c4"));
}
@Test
void moveUnseenToNewReady_WhenAllWereSeen_CreatesNothing() {
long id = repository.createReady(9L, List.of(row("A", "c1")));
repository.moveUnseenToNewReady(9L, id, java.util.Set.of(1));
assertThat(repository.findOpenPsnImport(9L)).hasValueSatisfying(found -> assertThat(found.id()).isEqualTo(id));
}
@Test
void create_StoresThePsnKeyOfALine() {
long id = repository.create(7L, GameImport.Source.PSN, List.of(
new GameImport.Line(1, "A", "PS5", 1, GameStatus.PLAYING, null, "c9")));
assertThat(jdbc.queryForMap("select psn_key from game_import_row where import_id = ?", id))
.isEqualTo(Map.of("psn_key", "c9"));
}
}
@@ -1,5 +1,6 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.AppidMinutes;
import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
@@ -10,6 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -57,6 +59,19 @@ class PlaytimeDayRepositoryTest extends AbstractPersistenceTest {
playtimeDayRepository.minutesPerDay(USER_ID, DAY));
}
@Test
void minutesPerGameSince_SumsOnlyTheAskedGamesOfTheUserFromTheDate() {
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY.minusDays(1), 999);
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY, 30);
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY.plusDays(1), 20);
playtimeDayRepository.addMinutes(USER_ID, 620L, DAY, 10);
playtimeDayRepository.addMinutes(USER_ID, 730L, DAY, 500);
playtimeDayRepository.addMinutes(2L, 220L, DAY, 500);
assertEquals(Set.of(new AppidMinutes(220L, 50), new AppidMinutes(620L, 10)),
Set.copyOf(playtimeDayRepository.minutesPerGameSince(USER_ID, List.of(220L, 620L), DAY)));
}
@Test
void minutesPerDay_LeavesOutHiddenGames() {
steamGameRepository.saveAll(List.of(new SteamGame(220L, "Half-Life 2", null, Instant.now()),
@@ -5,6 +5,7 @@ import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
@@ -17,6 +18,9 @@ class PsnAccountRepositoryTest extends AbstractPersistenceTest {
@Autowired
private PsnAccountRepository repository;
@Autowired
private JdbcTemplate jdbc;
@Test
void upsert_StoresTheBytes_AndALaterLinkReplacesThem() {
Instant first = Instant.now().plus(10, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS);
@@ -62,4 +66,58 @@ class PsnAccountRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.find(8L)).isEmpty();
assertThat(repository.delete(8L)).isZero();
}
private int notes(long userId, String kind) {
return jdbc.queryForObject("select count(*) from inbox_note where user_id = ? and subject_user_id = ? and kind = ?",
Integer.class, userId, userId, kind);
}
@Test
void noteExpiry_Expiring_WritesOneNoteForTheReader_AndOnlyOncePerLink() {
repository.upsert(10L, new byte[]{1}, Instant.now().plus(1, ChronoUnit.DAYS));
assertThat(repository.noteExpiry(10L, "PSN_EXPIRING")).isEqualTo(1);
assertThat(repository.noteExpiry(10L, "PSN_EXPIRING")).isZero();
assertThat(notes(10L, "PSN_EXPIRING")).isEqualTo(1);
assertThat(repository.find(10L).orElseThrow().expiryNote()).isEqualTo("PSN_EXPIRING");
}
@Test
void noteExpiry_ExpiredAfterExpiring_WritesOneMore_AndExpiringAfterExpiredNone() {
repository.upsert(11L, new byte[]{1}, Instant.now().plus(1, ChronoUnit.DAYS));
repository.noteExpiry(11L, "PSN_EXPIRING");
assertThat(repository.noteExpiry(11L, "PSN_EXPIRED")).isEqualTo(1);
assertThat(repository.noteExpiry(11L, "PSN_EXPIRED")).isZero();
assertThat(repository.noteExpiry(11L, "PSN_EXPIRING")).isZero();
assertThat(notes(11L, "PSN_EXPIRING")).isEqualTo(1);
assertThat(notes(11L, "PSN_EXPIRED")).isEqualTo(1);
assertThat(repository.find(11L).orElseThrow().expiryNote()).isEqualTo("PSN_EXPIRED");
}
@Test
void noteExpiry_ExpiredWithoutAnEarlierNote_WritesIt() {
repository.upsert(12L, new byte[]{1}, Instant.now().minusSeconds(5));
assertThat(repository.noteExpiry(12L, "PSN_EXPIRED")).isEqualTo(1);
}
@Test
void noteExpiry_UserWithoutALink_WritesNothing() {
assertThat(repository.noteExpiry(13L, "PSN_EXPIRED")).isZero();
assertThat(notes(13L, "PSN_EXPIRED")).isZero();
}
@Test
void upsert_ANewLink_ResetsTheNote_SoTheNextExpiryIsAnnouncedAgain() {
repository.upsert(14L, new byte[]{1}, Instant.now().plus(1, ChronoUnit.DAYS));
repository.noteExpiry(14L, "PSN_EXPIRED");
repository.upsert(14L, new byte[]{2}, Instant.now().plus(10, ChronoUnit.DAYS));
assertThat(repository.find(14L).orElseThrow().expiryNote()).isNull();
assertThat(repository.noteExpiry(14L, "PSN_EXPIRING")).isEqualTo(1);
}
}
@@ -0,0 +1,75 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.PsnTitle;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Import(PsnTitleRepository.class)
class PsnTitleRepositoryTest extends AbstractPersistenceTest {
@Autowired
private PsnTitleRepository repository;
@Autowired
private JdbcTemplate jdbc;
private void game(long userId, long appid, String source) {
jdbc.update("insert into steam_game (appid, name) values (?, 'Game') on conflict do nothing", appid);
jdbc.update("insert into user_game (user_id, appid, status, source) values (?, ?, 'BACKLOG', ?)", userId, appid,
source);
}
@Test
void linkOwned_ASteamRowIsSteamOwned_AnyOtherRowIsLinked() {
game(5L, -1L, "STEAM");
game(5L, -2L, "MANUAL");
repository.linkOwned(5L, "c1", -1L);
repository.linkOwned(5L, "c2", -2L);
repository.linkOwned(5L, "c3", -3L);
assertThat(repository.findAll(5L)).containsOnly(
Map.entry("c1", new PsnTitle("c1", PsnTitle.State.STEAM_OWNED, -1L)),
Map.entry("c2", new PsnTitle("c2", PsnTitle.State.LINKED, -2L)),
Map.entry("c3", new PsnTitle("c3", PsnTitle.State.LINKED, -3L)));
}
@Test
void link_StoresTheAppid_AndALaterLinkOverADismissedTitleWins() {
repository.dismiss(1L, List.of("c1"));
repository.link(1L, "c1", -300L);
assertThat(repository.findAll(1L)).containsOnly(Map.entry("c1", new PsnTitle("c1", PsnTitle.State.LINKED, -300L)));
}
@Test
void dismiss_LeavesALinkedTitleLinked_AndIsIdempotent() {
repository.link(2L, "c1", -300L);
repository.dismiss(2L, List.of("c1", "t2"));
repository.dismiss(2L, List.of("t2"));
repository.dismiss(2L, List.of());
assertThat(repository.findAll(2L)).containsOnly(
Map.entry("c1", new PsnTitle("c1", PsnTitle.State.LINKED, -300L)),
Map.entry("t2", new PsnTitle("t2", PsnTitle.State.DISMISSED, null)));
}
@Test
void findAll_ReturnsOnlyTheUsersTitles() {
repository.link(3L, "c1", -1L);
repository.link(4L, "c1", -2L);
assertThat(repository.findAll(3L).get("c1").appid()).isEqualTo(-1L);
assertThat(repository.findAll(5L)).isEmpty();
}
}
@@ -0,0 +1,276 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.AchievementProgress;
import core_service.modules.library.domain.PsnEarned;
import core_service.modules.library.domain.PsnTrophy;
import core_service.modules.library.domain.PsnTrophySet;
import core_service.modules.library.domain.PsnTrophySetProgress;
import core_service.modules.library.domain.PsnTrophySetState;
import core_service.modules.library.domain.TrophyType;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
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.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Import(PsnTrophyRepository.class)
class PsnTrophyRepositoryTest extends AbstractPersistenceTest {
private static final long USER = 5L;
private static final Instant UPDATED = Instant.parse("2026-10-01T20:00:00Z");
@Autowired
private PsnTrophyRepository repository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void clean() {
jdbc.update("delete from user_psn_trophy");
jdbc.update("delete from user_psn_trophy_set");
jdbc.update("delete from psn_trophy");
jdbc.update("delete from psn_trophy_set");
jdbc.update("delete from psn_title_trophy_set");
}
private void game(long userId, long appid) {
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', 'PSN') "
+ "on conflict do nothing", userId, appid);
}
private static PsnTrophySet set(String id, String platform, String name, int earned, boolean platinum, String version) {
return new PsnTrophySet(id, "trophy2", version, name, "https://x/i.png", platform, 3, 1, 1, 1, earned, platinum,
UPDATED);
}
private static PsnTrophy trophy(String setId, int id, TrophyType type, String group, boolean hidden) {
return new PsnTrophy(setId, id, type, "Trophy " + id, "Detail " + id, "https://x/" + id + ".png", hidden, group,
null, false, null);
}
@Test
void saveSet_UpsertsTheSummary_AndKeepsTheListVersionAndTheListMarker() {
game(USER, -1L);
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 2, false, "01.00"));
repository.replaceTrophies("NPWR1_00", "01.00", List.of(trophy("NPWR1_00", 1, TrophyType.BRONZE, "default", false)));
repository.replaceEarned(USER, "NPWR1_00", UPDATED, List.of());
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost renamed", 4, true, "01.10"));
assertThat(repository.findStates(USER, List.of("NPWR1_00"))).containsOnly(Map.entry("NPWR1_00",
new PsnTrophySetState("NPWR1_00", "01.00", 4, UPDATED)));
assertThat(repository.findSets(USER, -1L)).singleElement().satisfies(found -> {
assertThat(found.name()).isEqualTo("Ghost renamed");
assertThat(found.platinumEarned()).isTrue();
assertThat(found.total()).isEqualTo(6);
assertThat(found.listed()).isTrue();
});
}
@Test
void findStates_ASetOfAnotherUserHasNoEarnedTotal_AndANeverStoredSetIsMissing() {
game(9L, -1L);
repository.saveSet(9L, -1L, set("NPWR1_00", "PS5", "Ghost", 2, false, "01.00"));
Map<String, PsnTrophySetState> states = repository.findStates(USER, List.of("NPWR1_00", "NPWR2_00"));
assertThat(states).containsOnly(Map.entry("NPWR1_00", new PsnTrophySetState("NPWR1_00", null, null, null)));
assertThat(repository.findStates(USER, List.of())).isEmpty();
}
@Test
void replaceTrophies_ReplacesTheList_KeepsKnownRates_AndDropsRemovedTrophies() {
game(USER, -1L);
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 1, false, "01.00"));
repository.replaceTrophies("NPWR1_00", "01.00", List.of(trophy("NPWR1_00", 1, TrophyType.BRONZE, "default", false),
trophy("NPWR1_00", 2, TrophyType.GOLD, "default", false)));
repository.replaceEarned(USER, "NPWR1_00", UPDATED, List.of(new PsnEarned(1, true, UPDATED, 12.5),
new PsnEarned(2, false, null, 3.0)));
repository.replaceTrophies("NPWR1_00", "01.10", List.of(trophy("NPWR1_00", 1, TrophyType.SILVER, "001", true)));
List<PsnTrophy> trophies = repository.findTrophies(USER, -1L);
assertThat(trophies).singleElement().satisfies(found -> {
assertThat(found.trophyId()).isEqualTo(1);
assertThat(found.type()).isEqualTo(TrophyType.SILVER);
assertThat(found.groupId()).isEqualTo("001");
assertThat(found.hidden()).isTrue();
assertThat(found.earnedRate()).isEqualTo(12.5);
assertThat(found.earned()).isTrue();
assertThat(found.earnedAt()).isEqualTo(UPDATED);
});
assertThat(repository.findStates(USER, List.of("NPWR1_00")).get("NPWR1_00").listVersion()).isEqualTo("01.10");
}
@Test
void replaceEarned_ReplacesTheUsersRows_StoresRatesAndTheMarker() {
game(USER, -1L);
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 1, false, "01.00"));
repository.replaceTrophies("NPWR1_00", "01.00", List.of(trophy("NPWR1_00", 1, TrophyType.BRONZE, "default", false),
trophy("NPWR1_00", 2, TrophyType.GOLD, "default", false)));
repository.replaceEarned(USER, "NPWR1_00", UPDATED, List.of(new PsnEarned(1, true, UPDATED, 12.5)));
repository.replaceEarned(USER, "NPWR1_00", UPDATED.plusSeconds(60),
List.of(new PsnEarned(1, false, null, 20.0), new PsnEarned(2, true, null, 4.0)));
assertThat(repository.findTrophies(USER, -1L)).extracting(PsnTrophy::trophyId, PsnTrophy::earned,
PsnTrophy::earnedRate).containsExactly(org.assertj.core.groups.Tuple.tuple(1, false, 20.0),
org.assertj.core.groups.Tuple.tuple(2, true, 4.0));
assertThat(repository.findStates(USER, List.of("NPWR1_00")).get("NPWR1_00").listUpdatedAt())
.isEqualTo(UPDATED.plusSeconds(60));
}
@Test
void findProgress_HasThePlatinumFlag_AndLeavesOutHiddenAndRemovedGames() {
game(USER, -1L);
game(USER, -2L);
game(USER, -3L);
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 2, true, "01.00"));
repository.saveSet(USER, -2L, set("NPWR2_00", "PS4", "Hidden game", 1, false, "01.00"));
repository.saveSet(USER, -3L, set("NPWR3_00", "PS4", "Removed game", 1, false, "01.00"));
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = -2", USER);
jdbc.update("update user_game set removed_at = now() where user_id = ? and appid = -3", USER);
assertThat(repository.findProgress(USER)).containsExactly(new AchievementProgress(-1L, 6, 2, true, true));
}
@Test
void findSets_OrdersPs5BeforePs4_ThenByName_AndShowsWhetherTheListIsStored() {
game(USER, -1L);
repository.saveSet(USER, -1L, set("NPWR1_00", "PS4", "B Game", 1, false, "01.00"));
repository.saveSet(USER, -1L, set("NPWR2_00", "PS5", "Z Game", 1, false, "01.00"));
repository.saveSet(USER, -1L, set("NPWR3_00", "PS4", "A Game", 1, false, "01.00"));
repository.replaceTrophies("NPWR3_00", "01.00", List.of());
assertThat(repository.findSets(USER, -1L)).containsExactly(
new PsnTrophySetProgress("NPWR2_00", "PS5", "Z Game", 6, 1, false, false),
new PsnTrophySetProgress("NPWR3_00", "PS4", "A Game", 6, 1, false, false),
new PsnTrophySetProgress("NPWR1_00", "PS4", "B Game", 6, 1, false, false));
}
@Test
void findSets_NotListedWhileTheUsersEarnedListIsMissing() {
game(9L, -1L);
game(USER, -1L);
repository.saveSet(9L, -1L, set("NPWR1_00", "PS5", "Ghost", 0, false, "01.00"));
repository.replaceTrophies("NPWR1_00", "01.00", List.of(trophy("NPWR1_00", 1, TrophyType.BRONZE, "default", false),
trophy("NPWR1_00", 2, TrophyType.SILVER, "default", false)));
// This user's first sync stored the summary (2 earned), then the budget ran out before the earned list call.
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 2, false, "01.00"));
assertThat(repository.findTrophies(USER, -1L)).noneMatch(PsnTrophy::earned);
assertThat(repository.findSets(USER, -1L)).singleElement()
.satisfies(found -> assertThat(found.listed()).isFalse());
}
@Test
void findSets_ListedWhenTheUserHasNothingEarned_OrWhenBothListsAreStored() {
game(USER, -1L);
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Zero", 0, false, "01.00"));
repository.replaceTrophies("NPWR1_00", "01.00", List.of());
repository.saveSet(USER, -1L, set("NPWR2_00", "PS4", "Some", 2, false, "01.00"));
repository.replaceTrophies("NPWR2_00", "01.00", List.of());
repository.replaceEarned(USER, "NPWR2_00", UPDATED, List.of());
assertThat(repository.findSets(USER, -1L)).extracting(PsnTrophySetProgress::listed).containsExactly(true, true);
}
@Test
void deleteUserSetsExcept_KeepsTheListedSets_AndOtherUsersRows() {
game(USER, -1L);
game(6L, -1L);
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Keep", 1, false, "01.00"));
repository.saveSet(USER, -1L, set("NPWR2_00", "PS4", "Old account", 1, false, "01.00"));
repository.saveSet(6L, -1L, set("NPWR2_00", "PS4", "Old account", 1, false, "01.00"));
repository.replaceEarned(USER, "NPWR2_00", UPDATED, List.of(new PsnEarned(1, true, UPDATED, null)));
repository.deleteUserSetsExcept(USER, List.of("NPWR1_00"));
repository.deleteUserSetsExcept(USER, List.of());
assertThat(repository.findSets(USER, -1L)).extracting(PsnTrophySetProgress::id).containsExactly("NPWR1_00");
assertThat(jdbc.queryForObject("select count(*) from user_psn_trophy where user_id = ?", Integer.class, USER)).isZero();
assertThat(repository.findSets(6L, -1L)).hasSize(1);
}
@Test
void deleteUser_RemovesEverySetAndTrophyOfTheUser_ForThatUserOnly() {
game(USER, -1L);
game(6L, -1L);
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 1, false, "01.00"));
repository.saveSet(6L, -1L, set("NPWR1_00", "PS5", "Ghost", 1, false, "01.00"));
repository.replaceEarned(USER, "NPWR1_00", UPDATED, List.of(new PsnEarned(1, true, UPDATED, null)));
repository.deleteUser(USER);
assertThat(repository.findSets(USER, -1L)).isEmpty();
assertThat(jdbc.queryForObject("select count(*) from user_psn_trophy where user_id = ?", Integer.class, USER)).isZero();
assertThat(repository.findSets(6L, -1L)).hasSize(1);
}
@Test
void findTrophies_OrdersBySetGroupAndId_AndSeesOnlyTheAskedUsersGame() {
game(USER, -1L);
game(USER, -2L);
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 0, false, "01.00"));
repository.saveSet(USER, -2L, set("NPWR2_00", "PS4", "Other", 0, false, "01.00"));
repository.replaceTrophies("NPWR1_00", "01.00", List.of(trophy("NPWR1_00", 2, TrophyType.BRONZE, "001", false),
trophy("NPWR1_00", 1, TrophyType.BRONZE, "default", false),
trophy("NPWR1_00", 3, TrophyType.BRONZE, "default", false)));
repository.replaceTrophies("NPWR2_00", "01.00", List.of(trophy("NPWR2_00", 1, TrophyType.GOLD, "default", false)));
assertThat(repository.findTrophies(USER, -1L)).extracting(PsnTrophy::trophyId).containsExactly(1, 3, 2);
assertThat(repository.findTrophies(6L, -1L)).isEmpty();
}
@Test
void findTitleSets_RecheckWindow_AFoundSetStays_ANoSetAnswerExpires() {
repository.saveTitleSets(List.of("PPSA1_00", "PPSA2_00"), Map.of("PPSA1_00", "NPWR1_00"));
jdbc.update("update psn_title_trophy_set set checked_at = now() - interval '40 days'");
repository.saveTitleSets(List.of("PPSA3_00"), Map.of());
Map<String, String> fresh = repository.findTitleSets(List.of("PPSA1_00", "PPSA2_00", "PPSA3_00", "PPSA4_00"),
Instant.now().minusSeconds(86_400));
Map<String, String> expected = new HashMap<>();
expected.put("PPSA1_00", "NPWR1_00");
expected.put("PPSA3_00", null);
assertThat(fresh).isEqualTo(expected);
}
@Test
void saveTitleSets_AskedAgain_OverwritesTheAnswer() {
repository.saveTitleSets(List.of("PPSA1_00"), Map.of());
repository.saveTitleSets(List.of("PPSA1_00"), Map.of("PPSA1_00", "NPWR1_00"));
assertThat(repository.findTitleSets(List.of("PPSA1_00"), Instant.EPOCH)).containsEntry("PPSA1_00", "NPWR1_00");
}
@Test
void deleteUserSets_RemovesBothUserTables_ForThatUserOnly() {
game(USER, -1L);
game(6L, -1L);
repository.saveSet(USER, -1L, set("NPWR1_00", "PS5", "Ghost", 1, false, "01.00"));
repository.saveSet(6L, -1L, set("NPWR1_00", "PS5", "Ghost", 1, false, "01.00"));
repository.replaceEarned(USER, "NPWR1_00", UPDATED, List.of(new PsnEarned(1, true, UPDATED, null)));
repository.replaceEarned(6L, "NPWR1_00", UPDATED, List.of(new PsnEarned(1, true, UPDATED, null)));
repository.deleteUserSets(USER, List.of("NPWR1_00"));
repository.deleteUserSets(USER, List.of());
assertThat(repository.findSets(USER, -1L)).isEmpty();
assertThat(jdbc.queryForObject("select count(*) from user_psn_trophy where user_id = ?", Integer.class, USER)).isZero();
assertThat(repository.findSets(6L, -1L)).hasSize(1);
assertThat(jdbc.queryForObject("select count(*) from user_psn_trophy where user_id = ?", Integer.class, 6L)).isEqualTo(1);
}
}
@@ -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);
}
}
@@ -1,6 +1,13 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.AchievementDto;
import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.domain.AchievementKind;
import core_service.modules.library.domain.PsnEarned;
import core_service.modules.library.domain.PsnTrophy;
import core_service.modules.library.domain.PsnTrophySet;
import core_service.modules.library.domain.TrophyType;
import core_service.modules.library.repo.PsnTrophyRepository;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.SteamGame;
@@ -24,6 +31,7 @@ import core_service.shared.test.AbstractMockedAdaptersTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.client.ResourceAccessException;
import java.time.Duration;
@@ -75,6 +83,12 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
@Autowired
private GameSpeedrunRepository gameSpeedrunRepository;
@Autowired
private PsnTrophyRepository psnTrophyRepository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
@@ -85,6 +99,10 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
gameSpeedrunRepository.deleteAll();
gameTimeToBeatRepository.deleteAll();
gameMoodsRepository.deleteAll();
jdbc.update("delete from user_psn_trophy");
jdbc.update("delete from user_psn_trophy_set");
jdbc.update("delete from psn_trophy");
jdbc.update("delete from psn_trophy_set");
steamGameRepository.save(new SteamGame(620L, "Portal 2", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 620L, 60, Instant.now(), Instant.now()));
}
@@ -215,4 +233,63 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
assertEquals(1, detail.achievementsUnlocked());
assertTrue(detail.statsVisible());
}
private void trophySet(String id, String platform, String name, boolean platinumEarned) {
psnTrophyRepository.saveSet(USER_ID, 620L, new PsnTrophySet(id, "trophy2", "01.00", name, null, platform, 2, 0, 0,
1, 2, platinumEarned, Instant.now()));
psnTrophyRepository.replaceTrophies(id, "01.00", List.of(
new PsnTrophy(id, 0, TrophyType.PLATINUM, "Platinum", "All", "https://x/0.png", false, "default", null,
false, null),
new PsnTrophy(id, 1, TrophyType.BRONZE, "Secret name", "Secret detail", "https://x/1.png", true,
"default", null, false, null),
new PsnTrophy(id, 2, TrophyType.BRONZE, "DLC trophy", "Dlc", "https://x/2.png", false, "001", null,
false, null)));
psnTrophyRepository.replaceEarned(USER_ID, id, Instant.now(), List.of(new PsnEarned(0, true, Instant.now(), 2.5),
new PsnEarned(1, false, null, 1.0), new PsnEarned(2, true, Instant.now(), 12.0)));
}
@Test
void getGameDetail_PsnOnlyGame_ShowsTheSetsPs5First_AndHidesWhatAHiddenTrophyWouldTell() {
userGameRepository.save(UserGame.manual(USER_ID, 620L, GameSource.PSN, "PS5", GameStatus.PLAYING, 60, Instant.now()));
trophySet("NPWR2_00", "PS4", "Old version", false);
trophySet("NPWR1_00", "PS5", "Ghost", true);
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, null, 620L).orElseThrow();
assertTrue(detail.hasAchievements());
assertEquals(0, detail.achievementsTotal());
assertEquals(List.of("NPWR1_00", "NPWR2_00"), detail.achievementSets().stream().map(set -> set.id()).toList());
assertTrue(detail.achievementSets().getFirst().perfect());
assertEquals(AchievementKind.PSN, detail.achievementSets().getFirst().kind());
AchievementDto hidden = detail.achievements().stream()
.filter(a -> "NPWR1_00".equals(a.setId()) && a.apiName().equals("1")).findFirst().orElseThrow();
assertEquals(null, hidden.displayName());
assertEquals(null, hidden.description());
assertTrue(hidden.hidden());
AchievementDto dlc = detail.achievements().stream()
.filter(a -> "NPWR1_00".equals(a.setId()) && a.apiName().equals("2")).findFirst().orElseThrow();
assertEquals("001", dlc.group());
assertEquals(12.0, dlc.rarity());
assertTrue(dlc.unlocked());
assertEquals("Platinum", detail.achievements().getFirst().displayName());
}
@Test
void getGameDetail_SteamStatsHidden_StillListsTheTrophySets_AndTheSteamSetIsNotVisible() {
UserGame hiddenGame = userGameRepository.findById(new UserGameId(USER_ID, 620L)).orElseThrow();
hiddenGame.setHiddenAt(Instant.now());
userGameRepository.save(hiddenGame);
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 1, true, Instant.now()));
schemaRepository.save(new GameAchievementSchema(620L, "A", "A", null, "icon", "gray", false));
userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), false, 0, 60));
trophySet("NPWR1_00", "PS5", "Ghost", false);
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
assertEquals(List.of("steam", "NPWR1_00"), detail.achievementSets().stream().map(set -> set.id()).toList());
assertEquals(false, detail.achievementSets().getFirst().visible());
assertEquals(AchievementKind.STEAM, detail.achievements().getFirst().kind());
assertEquals(4, detail.achievements().size());
assertEquals(1, detail.achievementsTotal());
}
}
@@ -3,6 +3,7 @@ package core_service.modules.library.service;
import core_service.modules.library.domain.IgdbGame;
import core_service.modules.library.domain.GameImport;
import core_service.modules.library.repo.GameImportRepository;
import core_service.modules.library.repo.PsnTitleRepository;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataAccessResourceFailureException;
@@ -25,7 +26,7 @@ class GameImportWorkerTest {
}
private static GameImport.Line line(String title, String platform) {
return new GameImport.Line(1, title, platform, null, null, null);
return new GameImport.Line(1, title, platform, null, null, null, null);
}
@Test
@@ -56,7 +57,7 @@ class GameImportWorkerTest {
.thenReturn(Optional.of(7L), Optional.empty());
when(repository.findStalled(any())).thenReturn(Map.of());
GameImportWorker worker = new GameImportWorker(repository, mock(ManualGameService.class),
mock(TimeToBeatService.class), mock(MoodService.class), mock(ReleaseDateService.class), mock(GameCreditService.class), 5);
mock(PsnTitleRepository.class), mock(TimeToBeatService.class), mock(MoodService.class), mock(ReleaseDateService.class), mock(GameCreditService.class), 5);
try {
worker.wakeMatching();
verify(repository, timeout(2000)).findNext(GameImportWorker.MATCHING_QUEUE);
@@ -2,14 +2,18 @@ package core_service.modules.library.service;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.domain.PsnAccount;
import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.PsnAccountRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Duration;
@@ -18,6 +22,7 @@ import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ScheduledFuture;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -26,6 +31,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
@@ -50,6 +56,15 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
@Mock
private FinishedEstimateService finishedEstimateService;
@Mock
private PsnAccountRepository psnAccountRepository;
@Mock
private TaskScheduler taskScheduler;
@Mock
private ScheduledFuture<Object> pendingTask;
@InjectMocks
private LibrarySyncService syncService;
@@ -281,4 +296,149 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.MANUAL);
}
private void givenPsnLink(boolean linked) {
when(psnAccountRepository.find(USER_ID)).thenReturn(linked
? Optional.of(new PsnAccount(USER_ID, new byte[0], Instant.now().plus(Duration.ofDays(9)), Instant.now(), null))
: Optional.empty());
}
@Test
void syncIfStale_NoSteamIdAndNoPsnLink_StopsTheNightlySyncAndStartsNothing() {
givenPsnLink(false);
assertFalse(syncService.syncIfStale(USER_ID, null));
verify(userSteamSyncRepository).clearUserSeen(USER_ID);
verify(userSteamSyncRepository, never()).tryStartLoginSync(anyLong(), any(), any(), any(), any(), any());
verify(worker, never()).runSync(anyLong(), any(), any());
}
@Test
void syncIfStale_NoSteamIdButAPsnLink_UsesTheLoginGateAndRunsWithoutSteam() {
givenPsnLink(true);
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.empty());
when(userSteamSyncRepository.tryStartLoginSync(eq(USER_ID), eq(null), any(), any(), any(), any())).thenReturn(1);
assertTrue(syncService.syncIfStale(USER_ID, null));
verify(worker).runSync(USER_ID, null, SyncTrigger.LOGIN);
}
@Test
void syncIfStale_PsnOnlyUserWithAFreshSync_DoesNotAskTheGate() {
givenPsnLink(true);
givenSync(null, UserSteamSync.Status.OK, Duration.ofHours(1), null);
assertFalse(syncService.syncIfStale(USER_ID, null));
verify(userSteamSyncRepository, never()).tryStartLoginSync(anyLong(), any(), any(), any(), any(), any());
}
@Test
void forceSync_NoSteamIdAndNoPsnLink_Throws() {
givenPsnLink(false);
assertThrows(IllegalArgumentException.class, () -> syncService.forceSync(USER_ID, null));
verify(worker, never()).runSync(anyLong(), any(), any());
}
@Test
void forceSync_NoSteamIdButAPsnLink_StartsAManualSync() {
givenPsnLink(true);
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.empty());
syncService.forceSync(USER_ID, null);
verify(worker).runSync(USER_ID, null, SyncTrigger.MANUAL);
}
@Test
void canSync_NeedsASteamIdOrAPsnLink() {
givenPsnLink(false);
assertTrue(syncService.canSync(USER_ID, STEAM_ID));
assertFalse(syncService.canSync(USER_ID, null));
givenPsnLink(true);
assertTrue(syncService.canSync(USER_ID, null));
}
@Test
void startAfterLink_AfterTheCooldown_RunsAManualSync() {
givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofHours(1), null);
userSteamSyncRepository.findById(USER_ID).ifPresent(sync -> sync.setLastSyncedAt(Instant.now().minusSeconds(120)));
syncService.startAfterLink(USER_ID, STEAM_ID);
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.MANUAL);
}
@Test
void startAfterLink_InsideTheCooldown_SchedulesOneSyncAtTheCooldownEnd_SoRepeatedLinksNeverStack() {
givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofHours(1), null);
Instant lastSyncedAt = userSteamSyncRepository.findById(USER_ID).orElseThrow().getLastSyncedAt();
doReturn(pendingTask).when(taskScheduler).schedule(any(Runnable.class), any(Instant.class));
syncService.startAfterLink(USER_ID, STEAM_ID);
syncService.startAfterLink(USER_ID, STEAM_ID);
verify(worker, never()).runSync(anyLong(), any(), any());
verify(taskScheduler, times(1)).schedule(any(Runnable.class), eq(lastSyncedAt.plusSeconds(60)));
}
@Test
void startAfterLink_ScheduledSync_RunsAtItsTime_AndTheNextLinkMaySchedule() {
givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofHours(1), null);
doReturn(pendingTask).when(taskScheduler).schedule(any(Runnable.class), any(Instant.class));
ArgumentCaptor<Runnable> task = ArgumentCaptor.forClass(Runnable.class);
syncService.startAfterLink(USER_ID, STEAM_ID);
verify(taskScheduler).schedule(task.capture(), any(Instant.class));
userSteamSyncRepository.findById(USER_ID).orElseThrow().setLastSyncedAt(Instant.now().minusSeconds(120));
task.getValue().run();
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.MANUAL);
}
@Test
void startAfterLink_ScheduledSync_UsesTheSteamIdOfTheSyncRowAtThatTime() {
givenSync(null, UserSteamSync.Status.OK, Duration.ofHours(1), null);
doReturn(pendingTask).when(taskScheduler).schedule(any(Runnable.class), any(Instant.class));
ArgumentCaptor<Runnable> task = ArgumentCaptor.forClass(Runnable.class);
syncService.startAfterLink(USER_ID, null);
verify(taskScheduler).schedule(task.capture(), any(Instant.class));
UserSteamSync later = userSteamSyncRepository.findById(USER_ID).orElseThrow();
later.setSteamId(STEAM_ID);
later.setLastSyncedAt(Instant.now().minusSeconds(120));
task.getValue().run();
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.MANUAL);
verify(worker, never()).runSync(eq(USER_ID), eq(null), any());
}
@Test
void startAfterLink_RunningSync_SchedulesTheSyncOneCooldownLater() {
givenSync(STEAM_ID, UserSteamSync.Status.RUNNING, Duration.ofHours(1), null);
doReturn(pendingTask).when(taskScheduler).schedule(any(Runnable.class), any(Instant.class));
syncService.startAfterLink(USER_ID, STEAM_ID);
verify(worker, never()).runSync(anyLong(), any(), any());
ArgumentCaptor<Instant> runAt = ArgumentCaptor.forClass(Instant.class);
verify(taskScheduler).schedule(any(Runnable.class), runAt.capture());
assertTrue(runAt.getValue().isAfter(Instant.now().plusSeconds(50)));
}
@Test
void startAfterLink_NoSteamId_RunsWithoutSteam_AndMarksTheUserAsSeen() {
UserSteamSync seenLongAgo = new UserSteamSync(USER_ID, null);
seenLongAgo.setUserSeenAt(Instant.now().minus(Duration.ofDays(2)));
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(seenLongAgo));
syncService.startAfterLink(USER_ID, null);
verify(userSteamSyncRepository).touchUserSeen(eq(USER_ID), any());
verify(worker).runSync(USER_ID, null, SyncTrigger.MANUAL);
}
}
@@ -18,6 +18,7 @@ import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.modules.library.repo.WishlistGameRepository;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.event.LibraryActivityEvent;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -139,6 +140,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock
private ApplicationEventPublisher eventPublisher;
@Mock
private PsnSyncStep psnSyncStep;
@Spy
private TransactionTemplate transaction = new TransactionTemplate(mock(PlatformTransactionManager.class));
@@ -148,6 +152,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(worker, "zone", ZoneId.of("Europe/Berlin"));
lenient().when(psnSyncStep.run(USER_ID)).thenReturn(PsnSyncStep.Prepared.NONE);
lenient().when(userSteamSyncRepository.markFinished(eq(USER_ID), any(), anyString(), any())).thenReturn(1);
}
@@ -745,6 +750,80 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verifyNoInteractions(syncChangeRepository);
}
@Test
void runSync_PsnStepRunsBeforeTheTransaction_AndApplyAfterTheGamesAreSaved_ItsChangesJoinTheResult() {
PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(), false);
when(psnSyncStep.run(USER_ID)).thenReturn(prepared);
when(psnSyncStep.apply(eq(USER_ID), eq(prepared), any(), any()))
.thenReturn(List.of(SyncChange.added(-9L), SyncChange.playtime(-8L, 30)));
owned(100);
steamReturns(game(160, 60), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
InOrder order = inOrder(steamApiService, psnSyncStep, userGameRepository, syncChangeRepository);
order.verify(psnSyncStep).run(USER_ID);
order.verify(userGameRepository).findByUserId(USER_ID);
order.verify(psnSyncStep).apply(eq(USER_ID), eq(prepared), any(), any());
order.verify(syncChangeRepository).replace(eq(USER_ID), any(), eq(SyncTrigger.AUTO), eq(0), anyList());
assertThat(replacedChanges(SyncTrigger.AUTO, 0)).containsExactlyInAnyOrder(SyncChange.playtime(220L, 60),
SyncChange.added(-9L), SyncChange.playtime(-8L, 30));
}
@Test
void runSync_PsnBaselineAdds_AreCountedNotListed() {
PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(), true);
when(psnSyncStep.run(USER_ID)).thenReturn(prepared);
when(psnSyncStep.apply(eq(USER_ID), eq(prepared), any(), any()))
.thenReturn(List.of(SyncChange.added(-9L), SyncChange.playtime(-8L, 30)));
owned(100);
steamReturns(game(100, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertThat(replacedChanges(SyncTrigger.AUTO, 1)).containsExactly(SyncChange.playtime(-8L, 30));
}
@Test
void runSync_WithoutSteamId_RunsOnlyThePsnPart_AndPublishesTheActivityEvent() {
PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(), false);
when(psnSyncStep.run(USER_ID)).thenReturn(prepared);
when(psnSyncStep.apply(eq(USER_ID), eq(prepared), any(), any())).thenReturn(List.of(SyncChange.added(-9L)));
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(null), any(), any())).thenReturn(1);
worker.runSync(USER_ID, null, SyncTrigger.AUTO);
verifyNoInteractions(steamApiService);
verify(userGameRepository, never()).softDeleteMissingSince(anyLong(), any(), any());
verifyNoInteractions(wishlistGameRepository);
verify(userGameRepository, never()).save(any(UserGame.class));
assertThat(replacedChanges(SyncTrigger.AUTO, 0)).containsExactly(SyncChange.added(-9L));
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("OK"), any());
verify(eventPublisher).publishEvent(new LibraryActivityEvent(USER_ID));
verify(eventPublisher, never()).publishEvent(any(LibrarySyncedEvent.class));
}
@Test
void runSync_WithoutSteamIdAndNothingFromPsn_IsAnOkRunWithAnEmptyResult() {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(null), any(), any())).thenReturn(1);
worker.runSync(USER_ID, null, SyncTrigger.AUTO);
assertThat(replacedChanges(SyncTrigger.AUTO, 0)).isEmpty();
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("OK"), any());
}
@Test
void runSync_UserLostSteam_KeepsTheGames() {
when(userSteamSyncRepository.findForClaim(USER_ID)).thenReturn(Optional.of(syncRow(STEAM_ID, STEAM_ID)));
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(null), any(), any())).thenReturn(1);
worker.runSync(USER_ID, null, SyncTrigger.AUTO);
verify(userGameRepository, never()).softDeleteMissingSince(anyLong(), any(), any());
verifyNoInteractions(wishlistGameRepository);
}
@Test
void runSync_SteamFails_KeepsTheOldResult() {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
@@ -1,8 +1,10 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameMergeRepository;
import core_service.modules.library.repo.SteamGameRepository;
@@ -203,4 +205,15 @@ class ManualGameServiceTest extends AbstractUnitTest {
verify(igdbApiService, never()).searchGames("Silksong");
verify(igdbApiService, times(3)).searchGames(anyString());
}
@Test
void update_PsnRow_KeepsThePsnPlaytime() {
UserGame psnRow = UserGame.manual(USER_ID, -119388L, GameSource.PSN, "PS5", GameStatus.PLAYING, 600,
Instant.parse("2026-10-01T10:00:00Z"));
when(userGameRepository.findById(new UserGameId(USER_ID, -119388L))).thenReturn(Optional.of(psnRow));
service.update(USER_ID, -119388L, "PS5", 0);
assertThat(psnRow.getPlaytimeMinutes()).isEqualTo(600);
}
}
@@ -2,11 +2,14 @@ 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.PsnTrophyRepository;
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 +53,16 @@ class PsnAccountServiceTest extends AbstractUnitTest {
@Mock
private PsnApiService apiService;
@Mock
private GameImportService importService;
private GameImportRepository importRepository;
@Mock
private PsnTrophyRepository trophyRepository;
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, trophyRepository, KEY, 2);
}
private PsnAuthService.Tokens tokens(String refresh, Instant refreshExpires) {
@@ -65,7 +70,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 +81,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 +116,58 @@ 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, trophyRepository, "", 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, trophyRepository, "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);
PsnAccountService.FetchResult result = service.fetchTitles(1L);
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));
assertThat(result).isEqualTo(new PsnAccountService.FetchResult.Titles(played, ACCESS));
assertThat(result.toString()).doesNotContain(ACCESS);
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 +176,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 +296,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);
@@ -269,4 +312,11 @@ class PsnAccountServiceTest extends AbstractUnitTest {
verify(repository).delete(1L);
}
@Test
void unlink_AlsoDeletesTheTrophiesOfTheUser_SoAnotherAccountStartsFromNothing() {
service.unlink(1L);
verify(trophyRepository).deleteUser(1L);
}
}
@@ -0,0 +1,330 @@
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 PsnTrophyStep trophyStep;
@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), "ACCESS"));
}
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, trophyStep);
}
@Test
void run_TrophyStepGetsTheTokenAndTheTitles_AndItsChangesAreInThePreparedChanges() {
fetches(ghost());
when(manualGameService.findGames(anyString())).thenReturn(Optional.empty());
when(trophyStep.run(USER, "ACCESS", List.of(ghost()))).thenReturn(List.of(SyncChange.achievements(-4L, 3)));
PsnSyncStep.Prepared prepared = step.run(USER);
assertThat(prepared.changes()).containsExactly(SyncChange.achievements(-4L, 3));
}
@Test
void run_ARepositoryThrows_ReturnsWhatWasDone_AndNeverThrows() {
fetches(ghost());
when(psnTitleRepository.findAll(USER)).thenThrow(new IllegalStateException("database down"));
PsnSyncStep.Prepared prepared = step.run(USER);
assertThat(prepared.changes()).isEmpty();
assertThat(prepared.playtimes()).isEmpty();
}
private UserGame psnRow(long appid, int minutes) {
UserGame game = UserGame.manual(USER, appid, GameSource.PSN, "PS5", GameStatus.PLAYING, minutes, Instant.EPOCH);
when(userGameRepository.findById(new UserGameId(USER, appid))).thenReturn(Optional.of(game));
return game;
}
private PsnSyncStep.Prepared prepared(PsnSyncStep.Playtime... playtimes) {
return new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(playtimes), false);
}
@Test
void apply_GrowthIsBooked_AsChangeAndDayMinutes_WithTwoWeeksFromTheHistoryAndLastPlayed() {
UserGame game = psnRow(-1L, 30);
when(playtimeDayRepository.minutesPerGameSince(eq(USER), eq(Set.of(-1L)), eq(DAY.minusDays(13))))
.thenReturn(List.of(new AppidMinutes(-1L, 75)));
Instant now = Instant.now();
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 90, LAST_PLAYED)), now, DAY);
assertThat(changes).containsExactly(SyncChange.added(-9L), SyncChange.playtime(-1L, 60));
verify(playtimeDayRepository).addMinutes(USER, -1L, DAY, 60);
assertThat(game.getPlaytimeMinutes()).isEqualTo(90);
assertThat(game.getPlaytime2Weeks()).isEqualTo(75);
assertThat(game.getLastPlayedAt()).isEqualTo(LAST_PLAYED);
assertThat(game.getLastSeenAt()).isEqualTo(now);
}
@Test
void apply_LowerTotal_IsStored_WithoutChangeOrDayMinutes_AndAMissingLastPlayedKeepsTheOld() {
UserGame game = psnRow(-1L, 90);
game.setLastPlayedAt(LAST_PLAYED);
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 60, null)), Instant.now(), DAY);
assertThat(changes).containsExactly(SyncChange.added(-9L));
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
assertThat(game.getPlaytimeMinutes()).isEqualTo(60);
assertThat(game.getPlaytime2Weeks()).isZero();
assertThat(game.getLastPlayedAt()).isEqualTo(LAST_PLAYED);
}
@Test
void apply_SteamRowRemovedRowAndMissingRow_AreUntouched() {
UserGame steam = UserGame.manual(USER, -1L, GameSource.STEAM, null, GameStatus.PLAYING, 30, Instant.EPOCH);
when(userGameRepository.findById(new UserGameId(USER, -1L))).thenReturn(Optional.of(steam));
UserGame removed = psnRow(-2L, 30);
removed.setRemovedAt(Instant.EPOCH);
when(userGameRepository.findById(new UserGameId(USER, -3L))).thenReturn(Optional.empty());
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 90, LAST_PLAYED),
new PsnSyncStep.Playtime(-2L, 90, LAST_PLAYED), new PsnSyncStep.Playtime(-3L, 90, LAST_PLAYED)),
Instant.now(), DAY);
assertThat(changes).containsExactly(SyncChange.added(-9L));
assertThat(steam.getPlaytimeMinutes()).isEqualTo(30);
assertThat(removed.getPlaytimeMinutes()).isEqualTo(30);
verifyNoInteractions(playtimeDayRepository);
}
@Test
void apply_TwoTitlesOnOneGame_BookTheirSum_SoTheTotalNeverFlips() {
UserGame game = psnRow(-1L, 600);
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 100, null),
new PsnSyncStep.Playtime(-1L, 500, null)), Instant.now(), DAY);
assertThat(game.getPlaytimeMinutes()).isEqualTo(600);
assertThat(changes).containsExactly(SyncChange.added(-9L));
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
}
}
@@ -10,7 +10,9 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
@@ -18,7 +20,12 @@ import static org.assertj.core.api.Assertions.assertThat;
class PsnTitlesTest extends AbstractUnitTest {
private static PsnApiService.Title title(String id, String name, String category, String duration, Long concept) {
return new PsnApiService.Title(id, name, category, Duration.parse(duration), concept);
return new PsnApiService.Title(id, name, category, Duration.parse(duration), concept, null);
}
private static PsnApiService.Title played(String id, Long concept, String lastPlayed) {
return new PsnApiService.Title(id, id, "ps4_game", Duration.ZERO, concept,
lastPlayed == null ? null : Instant.parse(lastPlayed));
}
@ParameterizedTest
@@ -59,8 +66,27 @@ class PsnTitlesTest extends AbstractUnitTest {
assertThat(lines).hasSize(2);
assertThat(lines.get(0)).isEqualTo(new GameImport.Line(1, "Hades", "PS5", 65 * 60 + 9 + 120,
GameStatus.PLAYING, null));
assertThat(lines.get(1)).isEqualTo(new GameImport.Line(2, "Celeste", "PS5", 0, GameStatus.BACKLOG, null));
GameStatus.PLAYING, null, "c1"));
assertThat(lines.get(1)).isEqualTo(new GameImport.Line(2, "Celeste", "PS5", 0, GameStatus.BACKLOG, null, "c2"));
}
@Test
void toLines_KeepsThePsnKeyOfTheGroup() {
List<GameImport.Line> lines = PsnTitles.toLines(List.of(
title("A_00", "A", "ps4_game", "PT1H", 5L), title("B_00", "B", "ps4_game", "PT1H", null)));
assertThat(lines).extracting(GameImport.Line::psnKey).containsExactly("c5", "tB_00");
}
@Test
void lastPlayedByKey_TakesTheNewestOfAConcept_AndSkipsTitlesWithoutOne() {
Map<String, Instant> lastPlayed = PsnTitles.lastPlayedByKey(List.of(
played("A_00", 1L, "2026-10-01T10:00:00Z"), played("A_01", 1L, "2026-10-03T10:00:00Z"),
played("A_02", 1L, null), played("B_00", null, null), played("C_00", null, "2026-09-01T00:00:00Z")));
assertThat(lastPlayed).containsOnly(
Map.entry("c1", Instant.parse("2026-10-03T10:00:00Z")),
Map.entry("tC_00", Instant.parse("2026-09-01T00:00:00Z")));
}
@Test
@@ -0,0 +1,163 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.PsnTrophy;
import core_service.modules.library.domain.TrophyType;
import core_service.modules.psn_adapter.service.PsnApiService;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
class PsnTrophiesTest extends AbstractUnitTest {
private static PsnApiService.Title title(String id, String name, String category, Long concept) {
return new PsnApiService.Title(id, name, category, Duration.ZERO, concept, null);
}
private static PsnApiService.TrophySet set(String id, String name, String platform) {
return new PsnApiService.TrophySet(id, "trophy", "01.00", name, "https://x/i.png", platform,
new PsnApiService.Counts(1, 0, 0, 0), new PsnApiService.Counts(0, 0, 0, 0), null, false);
}
private static PsnTitle linked(String key, long appid) {
return new PsnTitle(key, PsnTitle.State.LINKED, appid);
}
@Test
void setAppids_TitleIdMapsExactly_EvenWhenTheNamesDiffer() {
PsnApiService.Title ghost = title("PPSA1_00", "Ghost of Yotei", "ps5_native_game", 7L);
Map<String, Long> appids = PsnTrophies.setAppids(List.of(ghost), Map.of("c7", linked("c7", -1L)),
Map.of("PPSA1_00", "NPWR1_00"), List.of(set("NPWR1_00", "Totally Other Name", "PS5")));
assertThat(appids).containsOnly(Map.entry("NPWR1_00", -1L));
}
@Test
void setAppids_ASteamOwnedTitleCountsForTheOwnedGame() {
PsnApiService.Title ghost = title("PPSA1_00", "Ghost", "ps5_native_game", 7L);
Map<String, Long> appids = PsnTrophies.setAppids(List.of(ghost),
Map.of("c7", new PsnTitle("c7", PsnTitle.State.STEAM_OWNED, 620L)), Map.of("PPSA1_00", "NPWR1_00"),
List.of(set("NPWR1_00", "Ghost", "PS5")));
assertThat(appids).containsOnly(Map.entry("NPWR1_00", 620L));
}
@Test
void setAppids_NoAnswerForTheTitleId_FallsBackToCleanNameAndPlatform() {
PsnApiService.Title mega = title("CUSA1_00", "Mega Man Legacy Collection™", "ps4_game", 8L);
Map<String, Long> appids = PsnTrophies.setAppids(List.of(mega), Map.of("c8", linked("c8", -2L)), Map.of(),
List.of(set("NPWR2_00", "Mega Man Legacy Collection", "PS4")));
assertThat(appids).containsOnly(Map.entry("NPWR2_00", -2L));
}
@Test
void setAppids_NameMatchOnTheWrongPlatform_IsNotMapped() {
PsnApiService.Title mega = title("CUSA1_00", "Mega Man", "ps4_game", 8L);
Map<String, Long> appids = PsnTrophies.setAppids(List.of(mega), Map.of("c8", linked("c8", -2L)), Map.of(),
List.of(set("NPWR2_00", "Mega Man", "PS5")));
assertThat(appids).isEmpty();
}
@Test
void setAppids_ThePlatformListOfASetIsSplit() {
PsnApiService.Title mega = title("CUSA1_00", "Mega Man", "ps4_game", 8L);
Map<String, Long> appids = PsnTrophies.setAppids(List.of(mega), Map.of("c8", linked("c8", -2L)), Map.of(),
List.of(set("NPWR2_00", "Mega Man", "PS4,PSVITA")));
assertThat(appids).containsOnlyKeys("NPWR2_00");
}
@Test
void setAppids_SameNameOnTwoGames_IsUnmapped_ButOneGameWithTwoTitlesMaps() {
PsnApiService.Title a = title("CUSA1_00", "Doom", "ps4_game", 1L);
PsnApiService.Title b = title("CUSA2_00", "Doom", "ps4_game", 2L);
List<PsnApiService.TrophySet> sets = List.of(set("NPWR1_00", "Doom", "PS4"));
assertThat(PsnTrophies.setAppids(List.of(a, b), Map.of("c1", linked("c1", -1L), "c2", linked("c2", -2L)),
Map.of(), sets)).isEmpty();
assertThat(PsnTrophies.setAppids(List.of(a, b), Map.of("c1", linked("c1", -1L), "c2", linked("c2", -1L)),
Map.of(), sets)).containsOnly(Map.entry("NPWR1_00", -1L));
}
@Test
void setAppids_TwoTitleIdsOfOneSetOnTwoGames_LeaveTheSetOut() {
PsnApiService.Title a = title("CUSA1_00", "A", "ps4_game", 1L);
PsnApiService.Title b = title("CUSA2_00", "B", "ps4_game", 2L);
Map<String, Long> appids = PsnTrophies.setAppids(List.of(a, b),
Map.of("c1", linked("c1", -1L), "c2", linked("c2", -2L)),
Map.of("CUSA1_00", "NPWR1_00", "CUSA2_00", "NPWR1_00"), List.of(set("NPWR1_00", "Other", "PS4")));
assertThat(appids).isEmpty();
}
@Test
void setAppids_DismissedAndUnknownTitles_MapNothing() {
PsnApiService.Title ghost = title("PPSA1_00", "Ghost", "ps5_native_game", 7L);
List<PsnApiService.TrophySet> sets = List.of(set("NPWR1_00", "Ghost", "PS5"));
Map<String, String> byId = Map.of("PPSA1_00", "NPWR1_00");
assertThat(PsnTrophies.setAppids(List.of(ghost), Map.of("c7", new PsnTitle("c7", PsnTitle.State.DISMISSED, null)),
byId, sets)).isEmpty();
assertThat(PsnTrophies.setAppids(List.of(ghost), Map.of(), byId, sets)).isEmpty();
}
@Test
void inScope_Ps4AndPs5AreIn_Ps3OnlyAndHiddenAreOut() {
assertThat(PsnTrophies.inScope(set("N", "A", "PS5"))).isTrue();
assertThat(PsnTrophies.inScope(set("N", "A", "PS4,PSVITA"))).isTrue();
assertThat(PsnTrophies.inScope(set("N", "A", "PS3"))).isFalse();
assertThat(PsnTrophies.inScope(set("N", "A", "PSVITA"))).isFalse();
assertThat(PsnTrophies.inScope(new PsnApiService.TrophySet("N", "trophy", "1", "A", null, "PS4",
new PsnApiService.Counts(1, 0, 0, 0), new PsnApiService.Counts(0, 0, 0, 0), null, true))).isFalse();
}
@Test
void toSet_TakesTheCountsAndPlatinum_AndNormalizesThePlatform() {
PsnApiService.TrophySet source = new PsnApiService.TrophySet("N", "trophy", "01.00", "A", "http://x/i.png",
"PS4,PSVITA", new PsnApiService.Counts(3, 2, 1, 1), new PsnApiService.Counts(2, 1, 0, 1),
Instant.EPOCH, false);
assertThat(PsnTrophies.toSet(source)).satisfies(set -> {
assertThat(set.platform()).isEqualTo("PS4");
assertThat(set.earnedTotal()).isEqualTo(4);
assertThat(set.platinumEarned()).isTrue();
assertThat(List.of(set.definedBronze(), set.definedSilver(), set.definedGold(), set.definedPlatinum()))
.containsExactly(3, 2, 1, 1);
assertThat(set.iconUrl()).isNull();
});
}
@Test
void toTrophy_HttpIconAndTooLongUrlAreNull_TextIsCut_UnknownTypeIsEmpty() {
PsnApiService.Trophy http = new PsnApiService.Trophy(1, "GOLD", "n".repeat(600), "d".repeat(3000),
"http://x/i.png", false, "default");
PsnApiService.Trophy longUrl = new PsnApiService.Trophy(2, "bronze", "n", "d", "https://x/" + "a".repeat(1100),
false, "default");
PsnApiService.Trophy ok = new PsnApiService.Trophy(3, "silver", "n", "d", "HTTPS://x/i.png", true, "001");
PsnTrophy mapped = PsnTrophies.toTrophy("N", http).orElseThrow();
assertThat(mapped.type()).isEqualTo(TrophyType.GOLD);
assertThat(mapped.iconUrl()).isNull();
assertThat(mapped.name()).hasSize(512);
assertThat(mapped.detail()).hasSize(2048);
assertThat(PsnTrophies.toTrophy("N", longUrl).orElseThrow().iconUrl()).isNull();
assertThat(PsnTrophies.toTrophy("N", ok).orElseThrow().iconUrl()).isEqualTo("HTTPS://x/i.png");
assertThat(PsnTrophies.toTrophy("N", new PsnApiService.Trophy(4, "legendary", "n", "d", null, false, "default")))
.isEqualTo(Optional.empty());
}
}
@@ -0,0 +1,386 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.PsnEarned;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.PsnTrophy;
import core_service.modules.library.domain.PsnTrophySet;
import core_service.modules.library.domain.PsnTrophySetState;
import core_service.modules.library.domain.SyncChange;
import core_service.modules.library.repo.GameMergeRepository;
import core_service.modules.library.repo.PsnTitleRepository;
import core_service.modules.library.repo.PsnTrophyRepository;
import core_service.modules.psn_adapter.service.PsnApiService;
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;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.http.HttpStatus;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(OutputCaptureExtension.class)
class PsnTrophyStepTest extends AbstractUnitTest {
private static final long USER = 1L;
private static final String TOKEN = "ACCESS-SECRET";
private static final Instant UPDATED = Instant.parse("2026-10-01T20:00:00Z");
@Mock
private PsnApiService apiService;
@Mock
private PsnTrophyRepository repository;
@Mock
private PsnTitleRepository titleRepository;
@Mock
private GameMergeRepository mergeRepository;
@Mock
private PlatformTransactionManager transactionManager;
private PsnTrophyStep step;
private final Map<String, PsnTitle> links = new HashMap<>();
@BeforeEach
void setUp() {
step = new PsnTrophyStep(apiService, repository, titleRepository, mergeRepository, transactionManager, 30);
lenient().when(titleRepository.findAll(USER)).thenReturn(links);
}
private static PsnApiService.Title title(int n) {
return new PsnApiService.Title("PPSA" + n + "_00", "Game " + n, "ps5_native_game", Duration.ZERO, (long) n, null);
}
private static PsnApiService.TrophySet set(int n, int earnedBronze, Instant updated) {
return new PsnApiService.TrophySet("NPWR" + n + "_00", "trophy2", "01.00", "Game " + n, "https://x/i.png", "PS5",
new PsnApiService.Counts(5, 0, 0, 0), new PsnApiService.Counts(earnedBronze, 0, 0, 0), updated, false);
}
// Title n is linked to appid -n and all its title ids are known to have no set: the name mapping applies.
private List<PsnApiService.Title> titles(int count) {
List<PsnApiService.Title> titles = new ArrayList<>();
Map<String, String> known = new HashMap<>();
for (int n = 1; n <= count; n++) {
titles.add(title(n));
links.put("c" + n, new PsnTitle("c" + n, PsnTitle.State.LINKED, (long) -n));
known.put("PPSA" + n + "_00", null);
}
when(repository.findTitleSets(anyCollection(), any())).thenReturn(known);
return titles;
}
private static PsnApiService.TrophySets complete(List<PsnApiService.TrophySet> sets) {
return new PsnApiService.TrophySets(sets, true);
}
private void state(int n, String listVersion, Integer earned, Instant listUpdatedAt) {
Map<String, PsnTrophySetState> states = new HashMap<>();
states.put("NPWR" + n + "_00", new PsnTrophySetState("NPWR" + n + "_00", listVersion, earned, listUpdatedAt));
when(repository.findStates(eq(USER), anyCollection())).thenReturn(states);
}
@Test
void run_UnchangedSet_FetchesNoListAtAll_ButSavesTheSummary() {
List<PsnApiService.Title> titles = titles(1);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 2, UPDATED))));
state(1, "01.00", 2, UPDATED);
List<SyncChange> changes = step.run(USER, TOKEN, titles);
assertThat(changes).isEmpty();
verify(apiService, never()).getTrophies(anyString(), anyString(), anyString());
verify(apiService, never()).getEarnedTrophies(anyString(), anyString(), anyString());
verify(repository).saveSet(eq(USER), eq(-1L), any(PsnTrophySet.class));
verify(repository, never()).replaceTrophies(anyString(), anyString(), anyList());
verify(repository, never()).replaceEarned(anyLong(), anyString(), any(), anyList());
}
@Test
void run_MoreEarnedTrophies_ReportsTheGain_AndFetchesTheEarnedListOnly() {
List<PsnApiService.Title> titles = titles(1);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 5, UPDATED.plusSeconds(60)))));
state(1, "01.00", 2, UPDATED);
when(apiService.getEarnedTrophies(TOKEN, "NPWR1_00", "trophy2"))
.thenReturn(List.of(new PsnApiService.EarnedTrophy(1, true, UPDATED, 10.0)));
List<SyncChange> changes = step.run(USER, TOKEN, titles);
assertThat(changes).containsExactly(SyncChange.achievements(-1L, 3));
verify(apiService, never()).getTrophies(anyString(), anyString(), anyString());
verify(repository).replaceEarned(USER, "NPWR1_00", UPDATED.plusSeconds(60),
List.of(new PsnEarned(1, true, UPDATED, 10.0)));
}
@Test
void run_FirstSightOfASet_IsABaseline_AndFetchesBothLists() {
List<PsnApiService.Title> titles = titles(1);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 3, UPDATED))));
when(apiService.getTrophies(TOKEN, "NPWR1_00", "trophy2"))
.thenReturn(new PsnApiService.TrophyList("01.00", List.of(
new PsnApiService.Trophy(1, "bronze", "n", "d", "https://x/1.png", false, "default"),
new PsnApiService.Trophy(2, "weird", "n", "d", null, false, "default"))));
when(apiService.getEarnedTrophies(TOKEN, "NPWR1_00", "trophy2")).thenReturn(List.of());
List<SyncChange> changes = step.run(USER, TOKEN, titles);
assertThat(changes).isEmpty();
ArgumentCaptor<List<PsnTrophy>> trophies = ArgumentCaptor.captor();
verify(repository).replaceTrophies(eq("NPWR1_00"), eq("01.00"), trophies.capture());
assertThat(trophies.getValue()).extracting(PsnTrophy::trophyId).containsExactly(1);
verify(repository).replaceEarned(USER, "NPWR1_00", UPDATED, List.of());
}
@Test
void run_StoredSetWithoutUserRow_IsABaselineToo() {
List<PsnApiService.Title> titles = titles(1);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 3, UPDATED))));
state(1, "01.00", null, null);
assertThat(step.run(USER, TOKEN, titles)).isEmpty();
}
@Test
void run_ANewVersion_FetchesTheGlobalListAgain() {
List<PsnApiService.Title> titles = titles(1);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 2, UPDATED))));
state(1, "00.90", 2, UPDATED);
when(apiService.getTrophies(TOKEN, "NPWR1_00", "trophy2")).thenReturn(new PsnApiService.TrophyList("01.00", List.of()));
step.run(USER, TOKEN, titles);
verify(apiService).getTrophies(TOKEN, "NPWR1_00", "trophy2");
verify(apiService, never()).getEarnedTrophies(anyString(), anyString(), anyString());
verify(repository).replaceTrophies("NPWR1_00", "01.00", List.of());
}
@Test
void run_SetWithNothingEarned_NeedsNoEarnedCall() {
List<PsnApiService.Title> titles = titles(1);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 0, null))));
when(apiService.getTrophies(TOKEN, "NPWR1_00", "trophy2")).thenReturn(new PsnApiService.TrophyList("01.00", List.of()));
step.run(USER, TOKEN, titles);
verify(apiService, never()).getEarnedTrophies(anyString(), anyString(), anyString());
}
@Test
void run_FortySetsWithABudgetOf30_MakesAtMost30ListCalls_ButSavesEverySummary() {
List<PsnApiService.Title> titles = titles(40);
List<PsnApiService.TrophySet> sets = new ArrayList<>();
for (int n = 1; n <= 40; n++) {
sets.add(set(n, 1, UPDATED.plusSeconds(n)));
}
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(sets));
when(apiService.getTrophies(anyString(), anyString(), anyString()))
.thenReturn(new PsnApiService.TrophyList("01.00", List.of()));
when(apiService.getEarnedTrophies(anyString(), anyString(), anyString())).thenReturn(List.of());
step.run(USER, TOKEN, titles);
verify(apiService, times(15)).getTrophies(anyString(), anyString(), anyString());
verify(apiService, times(15)).getEarnedTrophies(anyString(), anyString(), anyString());
verify(repository, times(40)).saveSet(eq(USER), anyLong(), any(PsnTrophySet.class));
// newest first: set 40 is served, set 1 is not
verify(apiService).getTrophies(TOKEN, "NPWR40_00", "trophy2");
verify(apiService, never()).getTrophies(TOKEN, "NPWR1_00", "trophy2");
}
@Test
void run_ThreeErrorsInARow_StopTheListCalls_ButSummariesAreSaved() {
List<PsnApiService.Title> titles = titles(6);
List<PsnApiService.TrophySet> sets = new ArrayList<>();
for (int n = 1; n <= 6; n++) {
sets.add(set(n, 0, UPDATED));
}
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(sets));
when(apiService.getTrophies(anyString(), anyString(), anyString()))
.thenThrow(new HttpServerErrorException(HttpStatus.BAD_GATEWAY));
step.run(USER, TOKEN, titles);
verify(apiService, times(PsnTrophyStep.MAX_ERRORS_IN_A_ROW)).getTrophies(anyString(), anyString(), anyString());
verify(repository, times(6)).saveSet(eq(USER), anyLong(), any(PsnTrophySet.class));
verify(repository, never()).replaceTrophies(anyString(), anyString(), anyList());
}
@Test
void run_ARateLimitedCall_CountsAsAnError_AndDoesNotThrow() {
List<PsnApiService.Title> titles = titles(1);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 0, null))));
when(apiService.getTrophies(anyString(), anyString(), anyString()))
.thenThrow(RequestNotPermitted.createRequestNotPermitted(
io.github.resilience4j.ratelimiter.RateLimiter.ofDefaults("t")));
assertThat(step.run(USER, TOKEN, titles)).isEmpty();
verify(repository).saveSet(eq(USER), eq(-1L), any(PsnTrophySet.class));
}
@Test
void run_SetNowMappedToAnotherGame_IsSavedWithTheNewAppid() {
List<PsnApiService.Title> titles = titles(1);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 2, UPDATED))));
state(1, "01.00", 2, UPDATED);
when(mergeRepository.findTargets(anyCollection())).thenReturn(Map.of(-1L, -77L));
step.run(USER, TOKEN, titles);
verify(repository).saveSet(eq(USER), eq(-77L), any(PsnTrophySet.class));
}
@Test
void run_TrophySetsFail_ReturnsNoChanges_AndWritesNothing(CapturedOutput output) {
when(apiService.getTrophySets(TOKEN)).thenThrow(new HttpServerErrorException(HttpStatus.BAD_GATEWAY, "body " + TOKEN));
assertThat(step.run(USER, TOKEN, List.of(title(1)))).isEmpty();
verifyNoInteractions(repository, mergeRepository);
assertThat(output.getAll()).doesNotContain(TOKEN).contains("status 502");
}
@Test
void run_TitleIdsAlreadyKnown_AreNotAskedAgain_UnknownOnesAre() {
List<PsnApiService.Title> titles = new ArrayList<>(List.of(title(1), title(2)));
links.put("c1", new PsnTitle("c1", PsnTitle.State.LINKED, -1L));
links.put("c2", new PsnTitle("c2", PsnTitle.State.LINKED, -2L));
Map<String, String> known = new HashMap<>();
known.put("PPSA1_00", "NPWR1_00");
when(repository.findTitleSets(anyCollection(), any())).thenReturn(known);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 0, null), set(2, 0, null))));
when(apiService.getTrophySetIds(eq(TOKEN), anyList())).thenReturn(new PsnApiService.TrophySetIds(Map.of("PPSA2_00", "NPWR2_00"),
List.of("PPSA2_00"), 1, null));
when(apiService.getTrophies(anyString(), anyString(), anyString()))
.thenReturn(new PsnApiService.TrophyList("01.00", List.of()));
step.run(USER, TOKEN, titles);
verify(apiService).getTrophySetIds(TOKEN, List.of("PPSA2_00"));
verify(repository).saveTitleSets(List.of("PPSA2_00"), Map.of("PPSA2_00", "NPWR2_00"));
verify(repository).saveSet(eq(USER), eq(-1L), any(PsnTrophySet.class));
verify(repository).saveSet(eq(USER), eq(-2L), any(PsnTrophySet.class));
}
@Test
void run_TheTitleIdEndpointGivesNothing_TheNameFallbackMapsTheSet_AndTheLogSaysWhichPath(CapturedOutput output) {
List<PsnApiService.Title> titles = List.of(title(1));
links.put("c1", new PsnTitle("c1", PsnTitle.State.LINKED, -1L));
when(repository.findTitleSets(anyCollection(), any())).thenReturn(new HashMap<>());
when(apiService.getTrophySetIds(eq(TOKEN), anyList())).thenReturn(new PsnApiService.TrophySetIds(Map.of(),
List.of("PPSA1_00"), 1, null));
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 0, null))));
when(apiService.getTrophies(anyString(), anyString(), anyString()))
.thenReturn(new PsnApiService.TrophyList("01.00", List.of()));
step.run(USER, TOKEN, titles);
verify(repository, atLeastOnce()).saveSet(eq(USER), eq(-1L), any(PsnTrophySet.class));
assertThat(output.getAll()).contains("mapped by title id=0 by name=1").doesNotContain(TOKEN);
}
@Test
void run_AFailedTitleIdCall_MarksNoTitleAsNoSet_AndTakesBudgetOnlyForTheCallsMade() {
step = new PsnTrophyStep(apiService, repository, titleRepository, mergeRepository, transactionManager, 2);
List<PsnApiService.Title> titles = new ArrayList<>();
for (int n = 1; n <= 2; n++) {
titles.add(title(n));
links.put("c" + n, new PsnTitle("c" + n, PsnTitle.State.LINKED, (long) -n));
}
when(repository.findTitleSets(anyCollection(), any())).thenReturn(new HashMap<>());
when(apiService.getTrophySetIds(eq(TOKEN), anyList())).thenReturn(new PsnApiService.TrophySetIds(Map.of(),
List.of(), 1, new HttpServerErrorException(HttpStatus.TOO_MANY_REQUESTS)));
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 0, UPDATED), set(2, 0, null))));
when(apiService.getTrophies(anyString(), anyString(), anyString()))
.thenReturn(new PsnApiService.TrophyList("01.00", List.of()));
step.run(USER, TOKEN, titles);
verify(repository).saveTitleSets(List.of(), Map.of());
verify(apiService, times(1)).getTrophies(anyString(), anyString(), anyString());
}
@Test
void run_ACompleteSummary_RemovesTheSetsOfAnEarlierAccount_AndKeepsTheBaselineOfTheSameAccount() {
List<PsnApiService.Title> titles = titles(1);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 2, UPDATED), set(7, 0, null))));
state(1, "01.00", 2, UPDATED);
List<SyncChange> changes = step.run(USER, TOKEN, titles);
assertThat(changes).isEmpty();
verify(repository).deleteUserSetsExcept(USER, List.of("NPWR1_00", "NPWR7_00"));
}
@Test
void run_ASummaryThatEndedOnABrokenPage_RemovesNothing_ButStillSavesWhatItHas() {
List<PsnApiService.Title> titles = titles(1);
when(apiService.getTrophySets(TOKEN)).thenReturn(new PsnApiService.TrophySets(List.of(set(1, 2, UPDATED)), false));
state(1, "01.00", 2, UPDATED);
step.run(USER, TOKEN, titles);
verify(repository, never()).deleteUserSetsExcept(anyLong(), anyCollection());
verify(repository).saveSet(eq(USER), eq(-1L), any(PsnTrophySet.class));
}
@Test
void run_AnEmptyOrPartialSummary_RemovesNothing() {
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of()));
step.run(USER, TOKEN, List.of(title(1)));
List<PsnApiService.TrophySet> capped = new ArrayList<>();
for (int n = 0; n < PsnApiService.MAX_TITLES; n++) {
capped.add(set(n, 0, null));
}
when(apiService.getTrophySets(TOKEN)).thenReturn(new PsnApiService.TrophySets(capped, false));
step.run(USER, TOKEN, List.of(title(1)));
verify(repository, never()).deleteUserSetsExcept(anyLong(), anyCollection());
}
@Test
void run_HiddenSet_HasItsUserRowsDeleted_AndIsNotStored() {
List<PsnApiService.Title> titles = titles(1);
PsnApiService.TrophySet hidden = new PsnApiService.TrophySet("NPWR9_00", "trophy", "1", "Game 1", null, "PS4",
new PsnApiService.Counts(1, 0, 0, 0), new PsnApiService.Counts(1, 0, 0, 0), UPDATED, true);
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(hidden)));
step.run(USER, TOKEN, titles);
verify(repository).deleteUserSets(USER, List.of("NPWR9_00"));
verify(repository, never()).saveSet(anyLong(), anyLong(), any());
}
@Test
void run_ARepositoryThrows_NeverThrowsAndReturnsTheChangesSoFar() {
when(apiService.getTrophySets(TOKEN)).thenReturn(complete(List.of(set(1, 0, null))));
when(titleRepository.findAll(USER)).thenThrow(new IllegalStateException("database down"));
assertThat(step.run(USER, TOKEN, List.of(title(1)))).isEmpty();
}
}
@@ -4,13 +4,16 @@ import core_service.shared.test.AbstractUnitTest;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
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 java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -18,6 +21,7 @@ import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class PsnApiServiceTest extends AbstractUnitTest {
@@ -41,7 +45,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 +59,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,10 +78,172 @@ 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());
assertThatThrownBy(() -> service.getPlayedTitles("t")).isInstanceOf(RestClientException.class);
}
@Test
void getTrophySets_FollowsNextOffset_ReadsCountsHiddenFlagAndInstant_WithBearer() {
server.expect(requestTo(containsString("/trophy/v1/users/me/trophyTitles?")))
.andExpect(requestTo(containsString("limit=800")))
.andExpect(requestTo(containsString("offset=0")))
.andExpect(header("Authorization", "Bearer ACCESS-1"))
.andRespond(withSuccess("""
{"trophyTitles":[{"npServiceName":"trophy2","npCommunicationId":"NPWR1_00","trophySetVersion":"01.10",
"trophyTitleName":"Ghost","trophyTitleIconUrl":"https://x/i.png","trophyTitlePlatform":"PS5",
"hiddenFlag":false,"definedTrophies":{"bronze":3,"silver":2,"gold":1,"platinum":1},
"earnedTrophies":{"bronze":1,"silver":1,"gold":0,"platinum":0},
"lastUpdatedDateTime":"2026-10-01T20:41:07Z","extra":1},
{"npCommunicationId":"NPWR2_00"}],"totalItemCount":3,"nextOffset":800}""",
MediaType.APPLICATION_JSON));
server.expect(requestTo(containsString("offset=800")))
.andExpect(header("Authorization", "Bearer ACCESS-1"))
.andRespond(withSuccess("""
{"trophyTitles":[{"npServiceName":"trophy","npCommunicationId":"NPWR3_00","trophyTitleName":"Mega",
"trophyTitlePlatform":"PS4,PSVITA","hiddenFlag":true,"definedTrophies":{"bronze":1,"silver":0,
"gold":0,"platinum":0}}],"nextOffset":null}""", MediaType.APPLICATION_JSON));
PsnApiService.TrophySets result = service.getTrophySets("ACCESS-1");
List<PsnApiService.TrophySet> sets = result.sets();
assertThat(sets).extracting(PsnApiService.TrophySet::npCommunicationId).containsExactly("NPWR1_00", "NPWR3_00");
PsnApiService.TrophySet ghost = sets.getFirst();
assertThat(ghost.defined().total()).isEqualTo(7);
assertThat(ghost.earned()).isEqualTo(new PsnApiService.Counts(1, 1, 0, 0));
assertThat(ghost.lastUpdatedAt()).isEqualTo(Instant.parse("2026-10-01T20:41:07Z"));
assertThat(ghost.hidden()).isFalse();
assertThat(sets.get(1).hidden()).isTrue();
assertThat(sets.get(1).earned().total()).isZero();
assertThat(sets.get(1).lastUpdatedAt()).isNull();
// totalItemCount says 3, one entry had no name: not complete
assertThat(result.complete()).isFalse();
server.verify();
}
@Test
void getTrophySets_CompleteOnlyWhenTheCountEqualsTheTotal_AClearedPageIsPartial() {
server.expect(requestTo(containsString("offset=0"))).andRespond(withSuccess("""
{"trophyTitles":[{"npCommunicationId":"NPWR1_00","trophyTitleName":"A"}],"totalItemCount":1,"nextOffset":null}""",
MediaType.APPLICATION_JSON));
assertThat(service.getTrophySets("t").complete()).isTrue();
server.reset();
server.expect(requestTo(containsString("offset=0"))).andRespond(withSuccess("""
{"trophyTitles":[{"npCommunicationId":"NPWR1_00","trophyTitleName":"A"}],"totalItemCount":3,"nextOffset":1}""",
MediaType.APPLICATION_JSON));
server.expect(requestTo(containsString("offset=1"))).andRespond(withSuccess("{}", MediaType.APPLICATION_JSON));
PsnApiService.TrophySets broken = service.getTrophySets("t");
assertThat(broken.sets()).hasSize(1);
assertThat(broken.complete()).isFalse();
}
@Test
void getTrophySetIds_SixIds_AreAskedAsFiveAndOne_FirstSetPerTitleWins() {
server.expect(requestTo(containsString("/trophy/v1/users/me/titles/trophyTitles?")))
.andExpect(requestTo(containsString("npTitleIds=A_00,B_00,C_00,D_00,E_00")))
.andExpect(header("Authorization", "Bearer ACCESS-1"))
.andRespond(withSuccess("""
{"titles":[{"npTitleId":"A_00","trophyTitles":[{"npCommunicationId":"NPWR1_00"},
{"npCommunicationId":"NPWR9_00"}]},
{"npTitleId":"B_00","trophyTitles":[]}]}""", MediaType.APPLICATION_JSON));
server.expect(requestTo(containsString("npTitleIds=F_00")))
.andExpect(header("Authorization", "Bearer ACCESS-1"))
.andRespond(withSuccess("""
{"titles":[{"npTitleId":"F_00","trophyTitles":[{"npCommunicationId":"NPWR2_00"}]}]}""",
MediaType.APPLICATION_JSON));
PsnApiService.TrophySetIds ids = service.getTrophySetIds("ACCESS-1",
List.of("A_00", "B_00", "C_00", "D_00", "E_00", "F_00"));
assertThat(ids.found()).containsOnly(Map.entry("A_00", "NPWR1_00"), Map.entry("F_00", "NPWR2_00"));
assertThat(ids.answered()).containsExactly("A_00", "B_00", "C_00", "D_00", "E_00", "F_00");
assertThat(ids.calls()).isEqualTo(2);
assertThat(ids.failure()).isNull();
server.verify();
}
@Test
void getTrophySetIds_AFailedCallEndsTheCalls_OnlyAnsweredTitlesAreAnswered_AndTheFailedCallCounts() {
server.expect(requestTo(containsString("npTitleIds=A_00,B_00,C_00,D_00,E_00")))
.andRespond(withSuccess("""
{"titles":[{"npTitleId":"A_00","trophyTitles":[{"npCommunicationId":"NPWR1_00"}]}]}""",
MediaType.APPLICATION_JSON));
server.expect(requestTo(containsString("npTitleIds=F_00,G_00,H_00,I_00,J_00")))
.andRespond(withStatus(HttpStatus.TOO_MANY_REQUESTS));
PsnApiService.TrophySetIds ids = service.getTrophySetIds("t", List.of("A_00", "B_00", "C_00", "D_00", "E_00",
"F_00", "G_00", "H_00", "I_00", "J_00", "K_00"));
assertThat(ids.found()).containsOnly(Map.entry("A_00", "NPWR1_00"));
assertThat(ids.answered()).containsExactly("A_00", "B_00", "C_00", "D_00", "E_00");
assertThat(ids.calls()).isEqualTo(2);
assertThat(ids.failure()).isInstanceOf(RestClientException.class);
server.verify();
}
@Test
void getTrophySetIds_ServerErrorIsReturnedAsFailure_NotThrown() {
server.expect(requestTo(containsString("npTitleIds="))).andRespond(withServerError());
PsnApiService.TrophySetIds ids = service.getTrophySetIds("t", List.of("A_00"));
assertThat(ids.answered()).isEmpty();
assertThat(ids.failure()).isInstanceOf(RestClientException.class);
}
@Test
void getTrophies_SendsTheServiceName_AndReadsVersionTypeGroupAndHiddenFlag() {
server.expect(requestTo(containsString("/trophy/v1/npCommunicationIds/NPWR1_00/trophyGroups/all/trophies?")))
.andExpect(requestTo(containsString("npServiceName=trophy2")))
.andExpect(header("Authorization", "Bearer ACCESS-1"))
.andRespond(withSuccess("""
{"trophySetVersion":"01.10","trophies":[
{"trophyId":0,"trophyHidden":false,"trophyType":"platinum","trophyName":"Legend",
"trophyDetail":"All","trophyIconUrl":"https://x/0.png","trophyGroupId":"default"},
{"trophyId":7,"trophyHidden":true,"trophyType":"bronze","trophyGroupId":"001"},
{"trophyId":8},{"trophyType":"gold"}]}""", MediaType.APPLICATION_JSON));
PsnApiService.TrophyList list = service.getTrophies("ACCESS-1", "NPWR1_00", "trophy2");
assertThat(list.version()).isEqualTo("01.10");
assertThat(list.trophies()).containsExactly(
new PsnApiService.Trophy(0, "platinum", "Legend", "All", "https://x/0.png", false, "default"),
new PsnApiService.Trophy(7, "bronze", null, null, null, true, "001"));
server.verify();
}
@Test
void getEarnedTrophies_SendsTheServiceName_ParsesTheRate_AndABadRateIsNull() {
server.expect(requestTo(containsString("/trophy/v1/users/me/npCommunicationIds/NPWR1_00/trophyGroups/all/trophies?")))
.andExpect(requestTo(containsString("npServiceName=trophy")))
.andExpect(header("Authorization", "Bearer ACCESS-1"))
.andRespond(withSuccess("""
{"trophies":[
{"trophyId":0,"earned":true,"earnedDateTime":"2026-09-20T18:00:00Z","trophyEarnedRate":"12.3"},
{"trophyId":1,"earned":false,"trophyEarnedRate":"x"},
{"trophyId":2,"earned":false,"trophyEarnedRate":"NaN"}]}""", MediaType.APPLICATION_JSON));
List<PsnApiService.EarnedTrophy> earned = service.getEarnedTrophies("ACCESS-1", "NPWR1_00", "trophy");
assertThat(earned).containsExactly(
new PsnApiService.EarnedTrophy(0, true, Instant.parse("2026-09-20T18:00:00Z"), 12.3),
new PsnApiService.EarnedTrophy(1, false, null, null),
new PsnApiService.EarnedTrophy(2, false, null, null));
server.verify();
}
}
@@ -110,6 +110,19 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
});
}
@Test
void finishCandidates_APlatinumTrophySetNeverCountsAsAllAchievements() {
game("BACKLOG", 120);
setStatus("COMPLETED");
jdbc.update("insert into psn_trophy_set (np_communication_id, service_name, platform, name, defined_bronze, "
+ "defined_silver, defined_gold, defined_platinum) values ('NPWR1_00', 'trophy', 'PS4', 'Game', 4, 0, 0, 1)");
jdbc.update("insert into user_psn_trophy_set (user_id, np_communication_id, appid, earned_total, platinum_earned) "
+ "values (?, 'NPWR1_00', ?, 5, true)", USER, GAME);
assertThat(repository.finishCandidates(USER, BERLIN)).singleElement()
.satisfies(f -> assertThat(f.allAchievements()).isFalse());
}
@Test
void finishCandidates_GoneOnceAFinishedRowExistsOrTheGameWentBackToBacklog() {
game("BACKLOG", 120);
@@ -245,4 +258,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));
}
}