Public Access
Compare commits
2
Commits
7ac887ed5a
...
0ddb345a41
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ddb345a41 | ||
|
|
f5ca7237a9 |
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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
+2
-1
@@ -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) {}
|
||||
|
||||
+15
@@ -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) {}
|
||||
}
|
||||
+18
@@ -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) {}
|
||||
}
|
||||
+22
@@ -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")));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user