Compare commits

...
7 Commits
Author SHA1 Message Date
lobo e11fae8e31 feat(follow): add player search with rate limit (#57)
Build, Test & Push Core Service / quality-check (push) Successful in 2m19s
Build, Test & Push Core Service / docker (push) Successful in 1m34s
Find public profiles by name: GET /api/follows/players?q=.
Queries under 2 characters find nothing; more than 30 searches
per minute get 429. LIKE escaping is shared with the library search.
2026-10-21 14:54:48 +02:00
lobo c42526bc16 feat(library): sort by name, last played and time to beat (#56)
Sort by name ignores upper and lower case.
Games never played, or without a time to beat, come last.
2026-10-21 11:09:44 +02:00
lobo 4fbf572f32 perf(datasource): reuse query plans on core connections (#49)
Build, Test & Push Core Service / quality-check (push) Successful in 2m11s
Build, Test & Push Core Service / docker (push) Successful in 1m29s
Planning was 94% of Dashboard DB time, and prod never reused a plan.
Force generic plans and prepare on first use. Burst: 443 -> 229 ms.
2026-10-20 11:41:31 +02:00
lobo 6f2d7b4885 fix(library): book psn play on its last played day (#53)
Build, Test & Push Core Service / quality-check (push) Successful in 2m35s
Build, Test & Push Core Service / docker (push) Successful in 1m29s
A newly linked PSN title booked its whole history on the sync day.
Now only new play counts, on its last played day. A day is capped
at 24 h. V49 deletes the wrong old day rows.
2026-10-20 09:06:53 +02:00
lobo d3c9c7558c feat(follow): add feed link data and game endpoints (#52, #51)
Build, Test & Push Core Service / quality-check (push) Successful in 2m34s
Build, Test & Push Core Service / docker (push) Successful in 1m52s
Feed notes get profileName and appid; follows get lastActivityAt.
New GET /api/library/games/{appid}/preview: catalog data, any game.
New GET /api/public/profiles/library/games/{appid}: one profile game.
2026-10-14 16:23:11 +02:00
lobo ec66bd5ead chore: remove unused dev compose and prometheus dependency
Build, Test & Push Core Service / quality-check (push) Successful in 3m9s
Build, Test & Push Core Service / docker (push) Successful in 2m4s
Prometheus is not in use. Only /actuator/health is exposed.
2026-10-14 15:22:20 +02:00
lobo 54946ef0eb docs(readme): sync features, endpoints and config with code
Build, Test & Push Core Service / quality-check (push) Successful in 2m38s
Build, Test & Push Core Service / docker (push) Successful in 2m3s
The readme had drifted from the code. Update sync rules, thread pools,
API tables, env vars and the CI setup.
2026-10-14 14:51:40 +02:00
52 changed files with 1223 additions and 156 deletions

No files matched your search

-22
View File
@@ -1,22 +0,0 @@
# docker-compose.dev.yml
services:
# ─────────────────────────────────────────────
# LOCAL DATABASE FOR DEVELOPMENT
# ─────────────────────────────────────────────
tbp-db-local:
image: postgres:17-alpine
container_name: tbp_db_local
restart: unless-stopped
ports:
# Map the container port 5432 to your local machine port 5432
- "5432:5432"
environment:
- POSTGRES_DB=tbp_core
- POSTGRES_USER=tbp_user
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
# Use a local volume to persist data across container restarts
- tbp_local_db_data:/var/lib/postgresql/data
volumes:
tbp_local_db_data:
-5
View File
@@ -89,11 +89,6 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId> <artifactId>spring-boot-starter-actuator</artifactId>
</dependency> </dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
<scope>runtime</scope>
</dependency>
<!-- testing --> <!-- testing -->
<dependency> <dependency>
+89 -46
View File
@@ -1,22 +1,27 @@
# To Be Played – Core Service # To Be Played – Core Service
Business service of **To Be Played**, a Steam backlog tracker. It syncs the user's Steam library, stores the backlog Business service of **To Be Played**, a backlog tracker for your Steam and PlayStation libraries, plus games added
(status, rating, notes, priority), tracks playtime per day, syncs the wishlist and adds time-to-beat data from IGDB. by hand or imported from GOG Galaxy, Playnite, Grouvee and HowLongToBeat files. It syncs the user's Steam library and
the linked PlayStation account, stores the backlog (status, rating, notes, priority), tracks playtime per day, syncs
the wishlist and adds time-to-beat data from IGDB.
To Be Played is split into three repositories: To Be Played is split into four repositories:
| Repository | Role | | Repository | Role |
|---|---| |---|---|
| ToBePlayed-Auth | Users, login, settings, signs JWTs | | ToBePlayed-Auth | Users, login, settings, signs JWTs |
| **ToBePlayed-Core** (this one) | Steam library, backlog, wishlist, stats; verifies JWTs | | **ToBePlayed-Core** (this one) | Steam library, backlog, wishlist, stats; verifies JWTs |
| ToBePlayed-Frontend | React single-page app | | ToBePlayed-Frontend | React single-page app |
| ToBePlayed-Deployment | Docker Compose stack, env template, ROADMAP.md (history), plans |
## Features ## Features
- **Steam sync**: owned games, playtime and achievements through the Steam Web API. Manual sync plus a nightly - **Steam sync**: owned games, playtime and achievements through the Steam Web API. Manual sync plus a nightly
sync. Resilience4j rate-limits every Steam call and tries the owned games up to three times (never again on a 4xx; sync. Resilience4j rate-limits every Steam call (2 per second) and retries only where a repeat is safe: owned games,
the wishlist and the calls a page waits for once); Caffeine caches game counts and player summaries. A sync commits its claim (`RUNNING`, with achievement schema and player achievements up to 3 times (`steamApi`, never on a 4xx); player summary and wishlist
`sync_started_at`), its writes and its finish apart, and calls Steam and IGDB outside any transaction: a second once with an empty fallback (`steamApiOnce`); store, release and global-percentage calls not at all. Caffeine caches
player summaries (`steamPlayerSummary`), IGDB searches and the landing numbers, not game counts. A sync commits its
claim (`RUNNING`, with `sync_started_at`), its writes and its finish apart, and calls Steam and IGDB outside any transaction: a second
sync sees `RUNNING` and skips, a failed one stores `FAILED`. A run older than `app.library.running-stale-minutes` sync sees `RUNNING` and skips, a failed one stores `FAILED`. A run older than `app.library.running-stale-minutes`
can be taken over; its own finish then changes nothing. can be taken over; its own finish then changes nothing.
- **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority, - **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority,
@@ -40,16 +45,35 @@ To Be Played is split into three repositories:
`SERIES`, `studioName` for `STUDIO`, `friendName`, `friendFinished` and `friendPlaytimeMinutes` for `FRIENDS` or `firstSeenAt` for `LONGEST`). `SERIES`, `studioName` for `STUDIO`, `friendName`, `friendFinished` and `friendPlaytimeMinutes` for `FRIENDS` or `firstSeenAt` for `LONGEST`).
Games already shown can be excluded for a reroll. Moods `STORY`, `COOP`, Games already shown can be excluded for a reroll. Moods `STORY`, `COOP`,
`CHILL`, `ACTION`, `SCARY` come from IGDB genres, themes and game modes; `SPEEDRUN` from speedrun.com. `CHILL`, `ACTION`, `SCARY` come from IGDB genres, themes and game modes; `SPEEDRUN` from speedrun.com.
```mermaid
flowchart LR
NEXT_UP --> Rows
SERIES --> Rows
SIMILAR --> Rows
STUDIO --> Rows
FRIENDS --> Rows
LONGEST --> Rows
RANDOM --> Rows
Rows["up to 3 games per row (ROW_SIZE), empty rows left out"] --> Filters["budget + exclude: every row; mood: RANDOM only"]
Filters --> Out["games + reason"]
```
- **Follow users (#27)**: a logged-in user follows people with a public profile by display name (`/api/follows`, at most - **Follow users (#27)**: a logged-in user follows people with a public profile by display name (`/api/follows`, at most
500, never an id in an answer) and gets one inbox note per followed user and batch (`/api/inbox`): a sync with its 500, never an id in an answer; `GET /api/follows/players?q=` finds public profiles by name, rate-limited) and gets
achievement run, or the hand changes of a quiet 10 minutes. A note counts new games, achievement unlocks, status one inbox note per followed user and batch (`/api/inbox`): a sync with its achievement run, or the hand changes of a
changes and Steam wishlist adds, and leaves out what the followed user's profile hides (`public_user.sections`). quiet 10 minutes. A note counts new games, achievement unlocks, status changes and Steam wishlist adds, and leaves
out what the followed user's profile hides (`public_user.sections`).
Follows end for good, with a note, when the followed user goes private, and without a note when the user is blocked or Follows end for good, with a note, when the followed user goes private, and without a note when the user is blocked or
deleted. Each note keeps one copy of the lines behind its counts (`inbox_note_detail`: games, achievement or trophy deleted. Each note keeps one copy of the lines behind its counts (`inbox_note_detail`: games, achievement or trophy
names, status moves collapsed per game, wishlist adds; at most 20 achievements per game, the newest unlocks stand for names, status moves collapsed per game, wishlist adds; at most 20 achievements per game, the newest unlocks stand for
the new ones). `GET /api/inbox/{id}/details` shows them only while the reader still follows the subject, the subject the new ones). `GET /api/inbox/{id}/details` shows them only while the reader still follows the subject, the subject
is public, else `404`. Rows of a hidden part or a hidden game are left out. The nightly job at 03:15 deletes notes is public, else `404`. Rows of a hidden part or a hidden game are left out. The nightly job at 03:15 deletes notes
older than 90 days (`app.inbox.keep-days`); the list shows the newest 50. older than 90 days (`app.inbox.keep-days`); the list shows the newest 50. A note also carries `profileName` (the
subject's current name while the profile is public, else null), a detail line its `appid`, and each followed person
in `GET /api/follows` `lastActivityAt` (the newest digest note the caller got about them): the Frontend links the feed
to the game and to the profile view of that game. `GET /api/library/games/{appid}/preview` gives the catalog data of
any game (for "not in your library yet"), `GET /api/public/profiles/library/games/{appid}` one listed game of a public
profile, filtered by the ticket's sections; a hidden, removed or not owned game answers the same `404`.
- **Backlog order**: every listed `BACKLOG` game (not removed, not hidden) has a row in `backlog_order (user_id, - **Backlog order**: every listed `BACKLOG` game (not removed, not hidden) has a row in `backlog_order (user_id,
appid, position)`, kept by a database trigger on `user_game` like the status history: a game entering the backlog appid, position)`, kept by a database trigger on `user_game` like the status history: a game entering the backlog
goes to the bottom, a game leaving it loses its row. V34 seeded the first order (priority HIGH, MEDIUM, rest, then goes to the bottom, a game leaving it loses its row. V34 seeded the first order (priority HIGH, MEDIUM, rest, then
@@ -61,16 +85,6 @@ To Be Played is split into three repositories:
sum). It keeps its status and shows `coming` / `comingOn`. Date: IGDB `first_release_date`, else Steam's store sum). It keeps its status and shows `coming` / `comingOn`. Date: IGDB `first_release_date`, else Steam's store
(`GetItems` with `include_release`), else unknown (= released). View `coming_game`, table `game_release`; a (`GetItems` with `include_release`), else unknown (= released). View `coming_game`, table `game_release`; a
year-only IGDB date stays a placeholder until the real date comes (coming rows refresh daily). year-only IGDB date stays a placeholder until the real date comes (coming rows refresh daily).
```mermaid
flowchart LR
A["anchor: random of top 5 played"] --> S[similar]
S --> M["PickMix: rotate 4 sources, dedupe, random fallback"]
L[longest waiting] --> M
R[random] --> M
N["next up: random of top 5 in order"] --> M
M --> O["games + reason"]
```
- **Speedrun games**: a backlog game counts as a speedrun game when speedrun.com has a game with the same name (case, - **Speedrun games**: a backlog game counts as a speedrun game when speedrun.com has a game with the same name (case,
punctuation, ™ and roman numerals ignored) and a release year at most one year off, when IGDB knows it, with at punctuation, ™ and roman numerals ignored) and a release year at most one year off, when IGDB knows it, with at
least one verified run. An edition that finds nothing tries its IGDB parent game with the parent's year, else its least one verified run. An edition that finds nothing tries its IGDB parent game with the parent's year, else its
@@ -85,8 +99,8 @@ To Be Played is split into three repositories:
of the rate limit; it stops after 3 Steam errors in a row or when Steam hides the achievements. A Steam error never of the rate limit; it stops after 3 Steam errors in a row or when Steam hides the achievements. A Steam error never
overwrites stored data. Summary: unlocked total, perfect games, average completion over started games, the 5 overwrites stored data. Summary: unlocked total, perfect games, average completion over started games, the 5
games closest to 100 %. games closest to 100 %.
- **Playtime history**: one row per game and day, for the activity view and the "most played in the last two weeks" - **Playtime history**: one row per game and day (a day counts at most 24 h in the stats and the chart), for the
dashboard banner. activity view and the "most played in the last two weeks" dashboard banner.
- **Finished from achievements or playtime**: `EndingAchievement` finds the one achievement of a game that means - **Finished from achievements or playtime**: `EndingAchievement` finds the one achievement of a game that means
"story end" (English text rule, Steam's global unlock rate between 1 and 80 %, the easiest variant wins, more than "story end" (English text rule, Steam's global unlock rate between 1 and 80 %, the easiest variant wins, more than
3 candidates = a game without one end). When the user unlocks it, never chose the status and never answered 3 candidates = a game without one end). When the user unlocks it, never chose the status and never answered
@@ -178,8 +192,8 @@ flowchart LR
header picture (`IStoreBrowseService/GetItems` with assets) goes to `steam_game.image_url`. When a few achievement header picture (`IStoreBrowseService/GetItems` with assets) goes to `steam_game.image_url`. When a few achievement
icons do not load, the schema comes fresh from Steam; an empty answer keeps the stored one. A report that cannot be repaired is tried again on the next report, at most 3 times, icons do not load, the schema comes fresh from Steam; an empty answer keeps the stored one. A report that cannot be repaired is tried again on the next report, at most 3 times,
then after 30 days. then after 30 days.
- **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the - **Time to beat**: IGDB's `hastily`, `normally` and `completely` minutes (`game_time_to_beat`, V6). The Frontend labels
feature is off and the rest works. them story / extras / 100 %. 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 - **PlayStation link** (optional, unofficial API): the user pastes an NPSSO once, Core keeps only the encrypted refresh
token (about 10 days). Every library sync then adds the played PS4/PS5 titles that match IGDB exactly, keeps the unsure 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 ones for the import review, and updates playtime and last played date. The same sync reads the PS4/PS5 **trophy
@@ -237,12 +251,14 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name, unlock `progress` per track, `earlySince` for the rules text) | JWT | | `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name, unlock `progress` per track, `earlySince` for the rules text) | JWT |
| `GET` | `/api/rewards/expected?appids=` (`{appid: points}`, the full finish value, 0 when paid; read-only, no settle; `400 TOO_MANY` over 200 appids) | JWT | | `GET` | `/api/rewards/expected?appids=` (`{appid: points}`, the full finish value, 0 when paid; read-only, no settle; `400 TOO_MANY` over 200 appids) | JWT |
| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT | | `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT |
| `GET` | `/api/rewards/games/{appid}` (settles, then the paid rows of the game and its set, `bookedNow`, one reason per missing kind; `404 GAME_NOT_FOUND`) | JWT |
| `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT | | `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT |
| `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT | | `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT |
| `GET` | `/api/library/games` (one row per set, the counted edition, with `platforms[]` of all its editions: Steam, PlayStation, then manual, and `totalPlaytimeMinutes` (all platforms; `playtimeMinutes` stays the counted edition's own); `name` matches any edition; `source=STEAM,PSN` keeps rows with a platform of that source, a PlayStation title on a Steam row counts as `PSN`; ignored with `hidden=true`, which lists every hidden row; `400` on an unknown source; an old `hideDuplicates` param is ignored), `/api/library/games/{appid}` | JWT | | `GET` | `/api/library/games` (`sort` = `playtimeMinutes`, `firstSeenAt`, `lastSeenAt`, `rating`, `priority`, `name`, `lastPlayedAt` or `timeToBeat` plus `,asc` or `,desc`; a game never played or without a time to beat comes last; one row per set, the counted edition, with `platforms[]` of all its editions: Steam, PlayStation, then manual, and `totalPlaytimeMinutes` (all platforms; `playtimeMinutes` stays the counted edition's own); `name` matches any edition; `source=STEAM,PSN` keeps rows with a platform of that source, a PlayStation title on a Steam row counts as `PSN`; ignored with `hidden=true`, which lists every hidden row; `400` on an unknown source; an old `hideDuplicates` param is ignored) | JWT |
| `GET` | `/api/library/dashboard-games?top=10&playing=8` (`{top, playing}`: the Dashboard tiles in one call, listed entries by set playtime, most first; `playing` only status PLAYING; same rows and order as `/games` with `sort=playtimeMinutes,desc` / `status=PLAYING`, no count query; limits clamped to 1..20; calls `syncIfStale` like `/games`) | JWT | | `GET` | `/api/library/dashboard-games?top=10&playing=8` (`{top, playing}`: the Dashboard tiles in one call, listed entries by set playtime, most first; `playing` only status PLAYING; same rows and order as `/games` with `sort=playtimeMinutes,desc` / `status=PLAYING`, no count query; limits clamped to 1..20; calls `syncIfStale` like `/games`) | JWT |
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT | | `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT |
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`, `time_to_beat` = `{normallyMinutes, completelyMinutes}`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR | | `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`, `time_to_beat` = `{normallyMinutes, completelyMinutes}`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
| `GET` | `/api/library/games/{appid}/preview` (`{appid, name, imageUrl, genres, timeToBeat}`: catalog data of any game, a merged key answers with the kept key; `404 GAME_NOT_FOUND`) | JWT |
| `GET` | `/api/library/catalog?q=&page=&size=` (every game some user still has, by name part, `PageDto` of `{appid, name, imageUrl, platforms}`; `q` at most 100 characters; size 20, 40 or 80) | JWT, AUDITOR | | `GET` | `/api/library/catalog?q=&page=&size=` (every game some user still has, by name part, `PageDto` of `{appid, name, imageUrl, platforms}`; `q` at most 100 characters; size 20, 40 or 80) | JWT, AUDITOR |
| `GET` | `/api/library/catalog/{appid}` (what the correction dialog reads: name, image, speedrun, moods, endless, genres, timeToBeat, corrections; `404 GAME_NOT_FOUND`) | JWT, AUDITOR | | `GET` | `/api/library/catalog/{appid}` (what the correction dialog reads: name, image, speedrun, moods, endless, genres, timeToBeat, corrections; `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
| `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR | | `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
@@ -259,6 +275,12 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT | | `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT |
| `GET` | `/api/library/removed-games` (`{results: [{appid, name, imageUrl, source, platform, removedAt}]}`: the caller's removed `MANUAL` and `PSN` games, newest first) | JWT | | `GET` | `/api/library/removed-games` (`{results: [{appid, name, imageUrl, source, platform, removedAt}]}`: the caller's removed `MANUAL` and `PSN` games, newest first) | JWT |
| `POST` | `/api/library/games/{appid}/restore` (a removed game comes back with its old data; `204`, also when it is already back; `404 GAME_NOT_FOUND`, `409 STEAM_GAME`) | JWT | | `POST` | `/api/library/games/{appid}/restore` (a removed game comes back with its old data; `204`, also when it is already back; `404 GAME_NOT_FOUND`, `409 STEAM_GAME`) | JWT |
| `GET` | `/api/library/game-search?q=` (IGDB search for "add a game", `q` 2-100 characters; `{available, results: [{appid, inLibrary, ...}]}`; `502 IGDB_FAILED`) | JWT |
| `POST` | `/api/library/games` (`{igdbId, platform, status, playtimeMinutes?}`; `201 {appid}`, `400 UNKNOWN_GAME`, `409 ALREADY_IN_LIBRARY`, `503 IGDB_OFF`) | JWT |
| `PUT` | `/api/library/games/{appid}/manual` (`{platform, playtimeMinutes}`, MANUAL and PSN rows, a PSN row keeps its playtime; `204`, `404 GAME_NOT_FOUND`, `409 STEAM_GAME`) | JWT |
| `DELETE` | `/api/library/games/{appid}` (soft remove, same answers) | JWT |
| `GET` | `/api/library/sync-changes` (`{syncedAt, startedBy, seen, importedGames, changes[]}` of the last sync; `204` when none) | JWT |
| `PUT` | `/api/library/sync-changes/seen` (`{syncedAt}`; idempotent; `204`) | JWT |
| `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; Steam and manual games) | JWT | | `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; Steam and manual games) | JWT |
| `POST` | `/api/library/imports` (`{source: TEXT\|CSV\|SEARCH\|GALAXY\|GROUVEE\|HLTB\|PLAYNITE\|BOOKMARKLET, rows: [{title, platform, playtimeMinutes, status, igdbId}]}`, 1–1000 rows; `202 {id}` in `QUEUED`, `409 IMPORT_RUNNING`, `503 IGDB_OFF`) | JWT | | `POST` | `/api/library/imports` (`{source: TEXT\|CSV\|SEARCH\|GALAXY\|GROUVEE\|HLTB\|PLAYNITE\|BOOKMARKLET, rows: [{title, platform, playtimeMinutes, status, igdbId}]}`, 1–1000 rows; `202 {id}` in `QUEUED`, `409 IMPORT_RUNNING`, `503 IGDB_OFF`) | JWT |
| `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 | | `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 |
@@ -268,7 +290,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `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/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 | | `GET`, `DELETE` | `/api/library/imports/{id}/undo-check` (`{games, changed}`), `/api/library/imports/{id}/games` (undo; `204`, `409 IMPORT_NOT_SAVED`) | JWT |
| `POST` | `/api/library/sync` | JWT | | `POST` | `/api/library/sync` | JWT |
| `GET` | `/api/library/games/{appid}` (detail; `refresh=false` answers from stored data and sets `achievementsStale` when a due Steam refresh was skipped (default `refresh=true` refreshes, as before); 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; `platforms[]` = `{source, platform, appid, playtimeMinutes, lastPlayedAt, achievementsUnlocked, achievementsTotal, perfect}` one line per platform of the entry, Steam, PS5, PS4, manual, each with its own progress, `appid` = the edition whose detail lists them; `totalPlaytimeMinutes` = their sum) | JWT | | `GET` | `/api/library/games/{appid}` (detail; `refresh=false` answers from stored data and sets `achievementsStale` when a due Steam refresh was skipped; `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; `platforms[]` = `{source, platform, appid, playtimeMinutes, lastPlayedAt, achievementsUnlocked, achievementsTotal, perfect}` one line per platform of the entry, Steam, PS5, PS4, manual, each with its own progress, `appid` = the edition whose detail lists them; `totalPlaytimeMinutes` = their sum) | JWT |
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | 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 | | `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 | | `PUT` | `/api/library/backlog` (`{appids}` = the whole order; `204`) | JWT |
@@ -279,15 +301,17 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` (`canSync`: a Steam ID or a PlayStation link; `sources`: the sources of the user's games) | JWT | | `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` (`canSync`: a Steam ID or a PlayStation link; `sources`: the sources of the user's games) | JWT |
| `GET` | `/api/public/profiles/library` | profile ticket | | `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/library/stats/games?key=&arg=&page=` (`403 NOT_SHARED`, `400 BAD_ARG`) | profile ticket |
| `GET` | `/api/public/profiles/library/games/{appid}` (`{appid, name, imageUrl, status, playtimeMinutes, achievementsUnlocked, achievementsTotal, completionPercent}`, a field of a hidden section is null; `404 GAME_NOT_FOUND` for a hidden, removed or not owned game or when every field is hidden, `401 INVALID_TICKET`) | profile ticket |
| `GET` | `/api/public/profiles/rewards` (`{equipped}`, the worn looks) | profile ticket | | `GET` | `/api/public/profiles/rewards` (`{equipped}`, the worn looks) | profile ticket |
| `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | public | | `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | public |
| `GET` | `/api/stats/owners?appid=&key=&arg=&page=` (who owns a game in the set behind a community number, `key` default `OWNED`: `listed` display names only, `listedTotal`, `privateCount`, `includesYou`, not cached, `400 BAD_ARG`) | login | | `GET` | `/api/stats/owners?appid=&key=&arg=&page=` (who owns a game in the set behind a community number, `key` default `OWNED`: `listed` display names only, `listedTotal`, `privateCount`, `includesYou`, not cached, `400 BAD_ARG`) | login |
| `GET` | `/api/stats/public/games?key=&arg=&page=` (the games behind a landing number, per game `libraries` and `minutes`; `400 BAD_ARG`) | public | | `GET` | `/api/stats/public/games?key=&arg=&page=` (the games behind a landing number, per game `libraries` and `minutes`; `400 BAD_ARG`) | public |
| `GET` | `/api/follows` (`{following: [{id, displayName}], followers: [{id, displayName}]}`, `id` is the follow row; a follower with a private profile has `displayName` null) | login | | `GET` | `/api/follows` (`{following: [{id, displayName, lastActivityAt}], followers: [{id, displayName, lastActivityAt}]}`, `id` is the follow row; a follower with a private profile has `displayName` null; `lastActivityAt` = the newest digest note the caller got about that person, null for followers and without a note) | login |
| `PUT`, `DELETE` | `/api/follows/by-name/{displayName}` (follow, unfollow; `204`; PUT: `404 PROFILE_NOT_FOUND` for an unknown, private, blocked or deleted name, `400 FOLLOW_SELF`, `400 FOLLOW_LIMIT`) | login | | `PUT`, `DELETE` | `/api/follows/by-name/{displayName}` (follow, unfollow; `204`; PUT: `404 PROFILE_NOT_FOUND` for an unknown, private, blocked or deleted name, `400 FOLLOW_SELF`, `400 FOLLOW_LIMIT`) | login |
| `DELETE` | `/api/follows/{id}` (unfollow, or remove a follower; only rows of the caller change; `204`) | login | | `DELETE` | `/api/follows/{id}` (unfollow, or remove a follower; only rows of the caller change; `204`) | login |
| `GET` | `/api/inbox` (`{results: [{id, kind, displayName, newGames, achievements, statusChanges, wishlistAdds, createdAt, read, details}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | login | | `GET` | `/api/follows/players?q=` (`{results: [{displayName}]}`: public profiles whose name contains `q`, case-insensitive, not blocked or deleted, at most 20, names that start with `q` first, the caller's own name included; `q` under 2 characters answers `[]`; `429 TOO_MANY_SEARCHES` beyond 30 searches per user and minute, `app.follow.search-per-minute`) | login |
| `GET` | `/api/inbox/{id}/details` (`{results: [{kind, gameName, itemName, fromStatus, toStatus}]}`, `kind` `NEW_GAME`, `ACHIEVEMENT`, `STATUS` or `WISHLIST`; `404` for a note that is not the caller's or not open any more) | login | | `GET` | `/api/inbox` (`{results: [{id, kind, displayName, profileName, newGames, achievements, statusChanges, wishlistAdds, createdAt, read, details}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | login |
| `GET` | `/api/inbox/{id}/details` (`{results: [{appid, kind, gameName, itemName, fromStatus, toStatus}]}`, `kind` `NEW_GAME`, `ACHIEVEMENT`, `STATUS` or `WISHLIST`; `404` for a note that is not the caller's or not open any more) | login |
| `PUT` | `/api/inbox/read` (marks all read; `204`) | login | | `PUT` | `/api/inbox/read` (marks all read; `204`) | login |
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token | | `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked, roleChangedAt, listed, publicProfile, profileSections}]}`, `displayName`, `roleChangedAt`, `listed` and `publicProfile` optional (`listed`: Auth lists the user by name, kept in `listed_user`, `null` keeps it; `publicProfile`: the profile is public, kept as a row in `public_user` with `profileSections`, `null` keeps row and sections, `false` ends the follows of the user with one `VISIBILITY` note each; `profileSections` `null` = all shown), `displayName` at most 100 characters, kept in `account_name` for the audit log, `roleChangedAt` kept in `account_role_change`; POST updates, PUT replaces the whole set; `204`) | service token | | `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked, roleChangedAt, listed, publicProfile, profileSections}]}`, `displayName`, `roleChangedAt`, `listed` and `publicProfile` optional (`listed`: Auth lists the user by name, kept in `listed_user`, `null` keeps it; `publicProfile`: the profile is public, kept as a row in `public_user` with `profileSections`, `null` keeps row and sections, `false` ends the follows of the user with one `VISIBILITY` note each; `profileSections` `null` = all shown), `displayName` at most 100 characters, kept in `account_name` for the audit log, `roleChangedAt` kept in `account_role_change`; POST updates, PUT replaces the whole set; `204`) | service token |
@@ -300,9 +324,9 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
- PostgreSQL 17, Flyway migrations - PostgreSQL 17, Flyway migrations
- Resilience4j: retry and rate limiter for Steam, rate limiters for IGDB and speedrun.com - Resilience4j: retry and rate limiter for Steam, rate limiters for IGDB and speedrun.com
- JJWT with Ed25519 public key - JJWT with Ed25519 public key
- Spring Boot Actuator + Micrometer (Prometheus) - Spring Boot Actuator (`/actuator/health` only)
- Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit - Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit
- CI: Forgejo Actions, multi-stage Docker image - CI: Gitea Actions (`.gitea/workflows/publish-core.yml`), multi-stage Docker image
## Architecture ## Architecture
@@ -344,7 +368,7 @@ flowchart LR
LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user; inbox_note (PSN expiry notes)"| Db[("PostgreSQL tbp_core")] LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user; inbox_note (PSN expiry notes)"| Db[("PostgreSQL tbp_core")]
Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user, listed_user, account_name, game_alias"| Db 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, inbox_note_detail, public_user, sync_change, user_game_status_change, user_game_achievement, user_psn_trophy, wishlist_game, account_name"| Db Follow -->|"follow, follow_digest, inbox_note, inbox_note_detail, public_user, sync_change, user_game_status_change, user_game_achievement, user_psn_trophy, wishlist_game, account_name"| Db
Inbox -->|"inbox_note, inbox_note_detail, follow, public_user, user_game"| Db Inbox -->|"inbox_note, inbox_note_detail, follow, public_user, user_game, account_name, blocked_user, deleted_user"| Db
Auth -->|"service token<br/>/internal/accounts"| Account Auth -->|"service token<br/>/internal/accounts"| Account
Account -->|"per-user rows, deleted_user, blocked_user, listed_user, public_user, follow, account_name, user_seen_at"| Db Account -->|"per-user rows, deleted_user, blocked_user, listed_user, public_user, follow, account_name, user_seen_at"| Db
``` ```
@@ -398,8 +422,10 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
### Sync flow ### Sync flow
1. `GET /api/library/games`, `/dashboard-games`, `/games-count` and `/picker` call `LibrarySyncService.syncIfStale`. It starts a sync 1. `GET /api/library/games`, `/dashboard-games`, `/games-count` and `/picker` call `LibrarySyncService.syncIfStale`. It starts a sync
when the last one is older than 24 hours, older than 15 minutes after a failure, or was for another Steam ID. when the last success is older than 20 hours (`app.library.sync-ttl-hours`) and the last try older than 60 minutes
`POST /api/library/sync` starts one at once, but at most every 60 seconds (else `429` with `Retry-After`). (`login-retry-minutes`, also after a failure), or when it was for another Steam ID.
`POST /api/library/sync` starts one at once, but at most every 60 seconds (`force-sync-cooldown-seconds`),
else `429` with `Retry-After`.
2. A nightly job (23:55, Europe/Berlin) syncs every account used in the last 14 days, one after the other. A 2. A nightly job (23:55, Europe/Berlin) syncs every account used in the last 14 days, one after the other. A
request without a `steamId` claim (the user unlinked the Steam account in Auth) takes the user out of it. request without a `steamId` claim (the user unlinked the Steam account in Auth) takes the user out of it.
Afterwards it writes every user's pile size of the day to `backlog_snapshot`, also for users without Steam, and Afterwards it writes every user's pile size of the day to `backlog_snapshot`, also for users without Steam, and
@@ -463,18 +489,20 @@ At 01:30 `ImageRepairService` repairs the pictures reported through `POST /api/l
### Background work ### Background work
Background work runs on two pools (`AsyncConfig`; every `@Async` names one, ArchUnit checks it): Background work runs on three pools (`AsyncConfig`; every `@Async` names one, ArchUnit checks it):
- The default pool (4 threads, queue 100) runs the syncs and the time-to-beat and mood refreshes a page view - The default pool (4 threads, queue 100) runs the syncs and the time-to-beat and mood refreshes a page view
starts. A page view queues only games without a fresh row. When the queue is full, the task is dropped with starts. A page view queues only games without a fresh row. When the queue is full, the task is dropped with
a warning, and the next view or sync asks again. Known limit: a dropped sync still answers `202`; the user a warning, and the next view or sync asks again. Known limit: a dropped sync still answers `202`; the user
starts it again. starts it again.
- The follow-up pool (2 threads, no limit on the queue) runs the achievement runs after a sync and every speedrun - The follow-up pool (2 threads, no limit on the queue) runs the achievement runs after a sync and the speedrun
refresh. They hold a thread for minutes because of the Steam and speedrun.com pace, so they do not take the page refreshes (`@Async`). They hold a thread for minutes because of the Steam and speedrun.com pace, so they do not take
views' threads. A task is small (a user or a few appids), and none is dropped. the page views' threads. A task is small (a user or a few appids), and none is dropped.
- The speedrun drainer (`speedrunExecutor`, 1 thread) works the speedrun.com queue (`SpeedrunService`).
Imports have their own two threads, and `game_import` is their queue. Scheduled jobs (nightly sync, image repair, Imports run in two lanes, `game-import-match` and `game-import-save`, 1 thread each, queue 1; `game_import` is their
import watchdog) run on the scheduler. queue. Scheduled jobs run on the scheduler: nightly sync 23:55, image repair 01:30, rewards settle 02:30, inbox purge
03:15, merge-undo purge 03:45, import watchdog every minute, credit backfill every 30 s, follow digest every 10 min.
```mermaid ```mermaid
flowchart LR flowchart LR
@@ -482,10 +510,12 @@ flowchart LR
Sync["sync request"] --> Pool Sync["sync request"] --> Pool
Synced["sync, after commit"] --> FollowUp["follow-up pool<br/>2 threads"] Synced["sync, after commit"] --> FollowUp["follow-up pool<br/>2 threads"]
View -->|"speedrun"| FollowUp View -->|"speedrun"| FollowUp
FollowUp --> Drainer["speedrunExecutor<br/>1 thread"]
Pool --> Apis["Steam, IGDB, speedrun.com"] Pool --> Apis["Steam, IGDB, speedrun.com"]
FollowUp --> Apis FollowUp --> Apis
Drainer --> Apis
Import["POST /imports"] --> Lanes["GameImportWorker<br/>2 own threads"] Import["POST /imports"] --> Lanes["GameImportWorker<br/>2 own threads"]
Cron["scheduler"] --> Jobs["nightly sync, image repair, import watchdog"] Cron["scheduler"] --> Jobs["nightly sync, image repair, rewards settle, inbox purge, merge-undo purge, import watchdog, credit backfill, follow digest"]
``` ```
Core runs as one instance. The pools, the speedrun queue, the per-user achievement runs and the achievement locks Core runs as one instance. The pools, the speedrun queue, the per-user achievement runs and the achievement locks
@@ -758,7 +788,9 @@ project ships it; all values in `app.psn.*`). `PsnAccountService` in `library` h
- Every `LINKED` or `STEAM_OWNED` title also keeps its own platform, minutes and last played in `user_psn_title` (V44), - Every `LINKED` or `STEAM_OWNED` title also keeps its own platform, minutes and last played in `user_psn_title` (V44),
so the detail shows PlayStation play next to Steam's on a shared row. The Steam row's own playtime stays Steam's. so the detail shows PlayStation play next to Steam's on a shared row. The Steam row's own playtime stays Steam's.
- A `LINKED` title updates its row (`source = PSN`, not removed): playtime, `last_played_at`, `PLAYTIME` changes and the - 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 removed stays playtime history; `playtime_2weeks` is the sum of the history of the last 14 days. The minutes a title grew since
its stored play are booked on its last played day (Berlin), only when its last played moved; a title linked in this
run is the start point and books nothing (V49 dropped the old wrong rows). A game the user removed stays
`LINKED` and the sync does not add it again; its row stays removed, so the sync skips it until the user restores it: `LINKED` and the sync does not add it again; its row stays removed, so the sync skips it until the user restores it:
a restore takes the stored title play (`psn_title_play`) and the sync updates the row again. Flyway V46 gave games a restore takes the stored title play (`psn_title_play`) and the sync updates the row again. Flyway V46 gave games
removed before (soft delete) a removed `PSN` row and turned a `MANUAL` row re-added on a `LINKED` key back to `PSN`. A game also owned on Steam stays one row (the Steam row wins). removed before (soft delete) a removed `PSN` row and turned a `MANUAL` row re-added on a `LINKED` key back to `PSN`. A game also owned on Steam stays one row (the Steam row wins).
@@ -812,7 +844,7 @@ other, then start them in this order (Core also starts without Auth and finds it
3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173` 3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173`
(`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development (`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development
values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`. values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`.
4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 8 4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 30
games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers. games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers.
What the `local` profile does: What the `local` profile does:
@@ -853,10 +885,11 @@ Needs Java 25, Docker and your own keys.
``` ```
Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the
service listens on `http://localhost:8090`. Health: `/actuator/health`, metrics: `/actuator/prometheus`. service listens on `http://localhost:8090`. Health: `/actuator/health` (the only open actuator endpoint).
In the `dev` profile a request without an `Authorization` header gets a dummy user, so the API is usable without a In the `dev` profile a request without an `Authorization` header gets a dummy user, so the API is usable without a
running Auth service. running Auth service. There is no `application-dev.yml`: `dev` only swaps the JWT filter
(`DevJwtAuthenticationFilter`, `@Profile("dev")`); everything else is `application.yml`.
Configuration comes from environment variables: Configuration comes from environment variables:
@@ -867,7 +900,9 @@ Configuration comes from environment variables:
| `STEAM_API_KEY` | Steam Web API key | | `STEAM_API_KEY` | Steam Web API key |
| `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Twitch app for IGDB (optional) | | `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Twitch app for IGDB (optional) |
| `APP_PSN_TOKEN_KEY` | Key for the stored PlayStation tokens, `openssl rand -base64 32` (optional; empty = PlayStation link off) | | `APP_PSN_TOKEN_KEY` | Key for the stored PlayStation tokens, `openssl rand -base64 32` (optional; empty = PlayStation link off) |
| `DB_PASSWORD` | Local PostgreSQL password | | `APP_FRONTEND_URL` | CORS origin, default the production host |
| `APP_ACCOUNT_CLEANUP_DRY_RUN` | Default `true`: the purge and prune only count |
| `APP_BUILD_COMMIT`, `APP_BUILD_TIME` | Feed `GET /api/version`; defaults `dev` and empty. CI passes `GIT_SHA` and `BUILD_TIME` as Docker build args |
### Request timing log ### Request timing log
@@ -876,6 +911,14 @@ duration, e.g. `GET /api/library/games 200 28 ms`. The pattern is `-` when no ha
routing). The line never holds ids, query strings, user data or tokens. `/actuator/**` is skipped. An exception routing). The line never holds ids, query strings, user data or tokens. `/actuator/**` is skipped. An exception
that leaves the filter chain is logged as `500`. that leaves the filter chain is logged as `500`.
### Database connection settings
Every Core connection runs with `plan_cache_mode = force_generic_plan` and `prepareThreshold=1`
(`spring.datasource.hikari` in `application.yml`). Planning was 94% of the Dashboard DB time and prod never reused a
plan (few users, a new connection every 30 min). With a cached generic plan the Dashboard burst dropped from 443 to
229 ms on 2 CPUs (#49). Cost: the first burst on fresh connections costs about 100 ms more (cold start: 1101 -> 1205
ms), and a generic plan ignores parameter values.
## Tests ## Tests
```bash ```bash
@@ -1,14 +1,17 @@
package core_service.modules.follow.api; package core_service.modules.follow.api;
import core_service.modules.follow.api.dto.FollowsDto; import core_service.modules.follow.api.dto.FollowsDto;
import core_service.modules.follow.api.dto.PlayersDto;
import core_service.modules.follow.domain.FollowEntry; import core_service.modules.follow.domain.FollowEntry;
import core_service.modules.follow.domain.FollowResult; import core_service.modules.follow.domain.FollowResult;
import core_service.modules.follow.domain.Follows; import core_service.modules.follow.domain.Follows;
import core_service.modules.follow.domain.PlayerSearch;
import core_service.modules.follow.service.FollowService; import core_service.modules.follow.service.FollowService;
import core_service.shared.api.ErrorResponse; import core_service.shared.api.ErrorResponse;
import core_service.shared.security.UserPrincipal; import core_service.shared.security.UserPrincipal;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
@@ -16,6 +19,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
@@ -33,6 +37,20 @@ public class FollowController {
return ResponseEntity.ok(new FollowsDto(toDto(follows.following()), toDto(follows.followers()))); return ResponseEntity.ok(new FollowsDto(toDto(follows.following()), toDto(follows.followers())));
} }
/**
* The caller's own name is a normal hit.
*/
@GetMapping("/players")
public ResponseEntity<?> players(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam("q") @Size(max = 100) String q) {
PlayerSearch search = followService.searchPlayers(principal.getIdAsLong(), q);
if (search.limited()) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(new ErrorResponse("TOO_MANY_SEARCHES", "Too many searches"));
}
return ResponseEntity.ok(new PlayersDto(search.names().stream().map(PlayersDto.Player::new).toList()));
}
@PutMapping("/by-name/{displayName}") @PutMapping("/by-name/{displayName}")
public ResponseEntity<?> follow(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<?> follow(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("displayName") @Size(max = 100) String displayName) { @PathVariable("displayName") @Size(max = 100) String displayName) {
@@ -63,6 +81,6 @@ public class FollowController {
} }
private static List<FollowsDto.Person> toDto(List<FollowEntry> entries) { private static List<FollowsDto.Person> toDto(List<FollowEntry> entries) {
return entries.stream().map(entry -> new FollowsDto.Person(entry.id(), entry.displayName())).toList(); return entries.stream().map(entry -> new FollowsDto.Person(entry.id(), entry.displayName(), entry.lastActivityAt())).toList();
} }
} }
@@ -1,5 +1,6 @@
package core_service.modules.follow.api.dto; package core_service.modules.follow.api.dto;
import java.time.Instant;
import java.util.List; import java.util.List;
/** /**
@@ -10,7 +11,8 @@ public record FollowsDto(List<Person> following, List<Person> followers) {
/** /**
* @param id the follow row, to end it; not a user id * @param id the follow row, to end it; not a user id
* @param displayName null for a follower with a private profile * @param displayName null for a follower with a private profile
* @param lastActivityAt following only: when the newest feed message about this person arrived; null without one
*/ */
public record Person(long id, String displayName) { public record Person(long id, String displayName, Instant lastActivityAt) {
} }
} }
@@ -0,0 +1,15 @@
package core_service.modules.follow.api.dto;
import java.util.List;
/**
* Public profiles found by name: names that start with the query first, then by name.
*/
public record PlayersDto(List<Player> results) {
/**
* @param displayName the key of the profile URL
*/
public record Player(String displayName) {
}
}
@@ -1,8 +1,11 @@
package core_service.modules.follow.domain; package core_service.modules.follow.domain;
import java.time.Instant;
/** /**
* @param id the follow row, not a user id * @param id the follow row, not a user id
* @param displayName null for a follower with a private profile * @param displayName null for a follower with a private profile
* @param lastActivityAt the newest digest note the caller got about this user; null for followers and without a note
*/ */
public record FollowEntry(long id, String displayName) { public record FollowEntry(long id, String displayName, Instant lastActivityAt) {
} }
@@ -0,0 +1,17 @@
package core_service.modules.follow.domain;
import java.util.List;
/**
* The answer of a player search: the names found, or "limited" when the caller searched too often.
*/
public record PlayerSearch(List<String> names, boolean limited) {
public static PlayerSearch tooMany() {
return new PlayerSearch(List.of(), true);
}
public static PlayerSearch of(List<String> names) {
return new PlayerSearch(names, false);
}
}
@@ -1,12 +1,14 @@
package core_service.modules.follow.repo; package core_service.modules.follow.repo;
import core_service.modules.follow.domain.FollowEntry; import core_service.modules.follow.domain.FollowEntry;
import core_service.shared.sql.LikePattern;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -19,9 +21,13 @@ import java.util.Optional;
public class FollowRepository { public class FollowRepository {
static final int LIST_LIMIT = 500; static final int LIST_LIMIT = 500;
public static final int SEARCH_LIMIT = 20;
private static final RowMapper<FollowEntry> ENTRY = (rs, i) -> new FollowEntry(rs.getLong("id"), private static final RowMapper<FollowEntry> ENTRY = (rs, i) -> {
rs.getString("display_name")); Timestamp lastActivityAt = rs.getTimestamp("last_activity_at");
return new FollowEntry(rs.getLong("id"), rs.getString("display_name"),
lastActivityAt == null ? null : lastActivityAt.toInstant());
};
private final NamedParameterJdbcTemplate jdbc; private final NamedParameterJdbcTemplate jdbc;
@@ -41,6 +47,23 @@ public class FollowRepository {
return ids.size() == 1 ? Optional.of(ids.get(0)) : Optional.empty(); return ids.size() == 1 ? Optional.of(ids.get(0)) : Optional.empty();
} }
/**
* Public names that contain the query, case-insensitive; names that start with it come first. Same visibility
* rules as {@link #findFollowable}. Wildcards in the query match themselves.
*/
public List<String> searchPublicNames(String query, int limit) {
String escaped = LikePattern.escapeLower(query);
return jdbc.queryForList("""
select a.display_name from account_name a
join public_user p on p.user_id = a.user_id
where lower(a.display_name) like :pattern escape '\\'
and not exists (select 1 from blocked_user b where b.user_id = a.user_id)
and not exists (select 1 from deleted_user d where d.user_id = a.user_id)
order by (lower(a.display_name) like :prefix escape '\\') desc, lower(a.display_name)
limit :limit""", new MapSqlParameterSource("pattern", "%" + escaped + "%")
.addValue("prefix", escaped + "%").addValue("limit", limit), String.class);
}
/** /**
* Checks the visibility again in the same statement, so a user who went private in between gets no follower. * Checks the visibility again in the same statement, so a user who went private in between gets no follower.
* *
@@ -91,7 +114,11 @@ public class FollowRepository {
public List<FollowEntry> findFollowing(long userId) { public List<FollowEntry> findFollowing(long userId) {
return jdbc.query(""" return jdbc.query("""
select f.id, a.display_name from follow f select f.id, a.display_name,
(select max(n.created_at) from inbox_note n
where n.user_id = f.user_id and n.subject_user_id = f.followed_user_id
and n.kind = 'DIGEST') as last_activity_at
from follow f
left join account_name a on a.user_id = f.followed_user_id left join account_name a on a.user_id = f.followed_user_id
where f.user_id = :userId where f.user_id = :userId
order by lower(a.display_name), f.id order by lower(a.display_name), f.id
@@ -103,7 +130,8 @@ public class FollowRepository {
*/ */
public List<FollowEntry> findFollowers(long userId) { public List<FollowEntry> findFollowers(long userId) {
return jdbc.query(""" return jdbc.query("""
select f.id, case when p.user_id is not null then a.display_name end as display_name select f.id, case when p.user_id is not null then a.display_name end as display_name,
null::timestamptz as last_activity_at
from follow f from follow f
left join public_user p on p.user_id = f.user_id left join public_user p on p.user_id = f.user_id
left join account_name a on a.user_id = f.user_id left join account_name a on a.user_id = f.user_id
@@ -2,6 +2,7 @@ package core_service.modules.follow.service;
import core_service.modules.follow.domain.FollowResult; import core_service.modules.follow.domain.FollowResult;
import core_service.modules.follow.domain.Follows; import core_service.modules.follow.domain.Follows;
import core_service.modules.follow.domain.PlayerSearch;
import core_service.modules.follow.repo.FollowRepository; import core_service.modules.follow.repo.FollowRepository;
import core_service.shared.security.RequiresRole; import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role; import core_service.shared.security.Role;
@@ -9,6 +10,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional; import java.util.Optional;
@Service @Service
@@ -17,8 +19,10 @@ import java.util.Optional;
public class FollowService { public class FollowService {
static final int MAX_FOLLOWING = 500; static final int MAX_FOLLOWING = 500;
static final int MIN_QUERY_LENGTH = 2;
private final FollowRepository followRepository; private final FollowRepository followRepository;
private final PlayerSearchLimiter limiter;
/** /**
* Unknown, private, blocked and deleted names all answer NOT_FOUND, so a name cannot be probed. * Unknown, private, blocked and deleted names all answer NOT_FOUND, so a name cannot be probed.
@@ -57,4 +61,20 @@ public class FollowService {
public Follows follows(long userId) { public Follows follows(long userId) {
return new Follows(followRepository.findFollowing(userId), followRepository.findFollowers(userId)); return new Follows(followRepository.findFollowing(userId), followRepository.findFollowers(userId));
} }
/**
* A query shorter than two characters finds nothing and does not count against the limit.
*/
@RequiresRole(Role.USER)
@Transactional(readOnly = true)
public PlayerSearch searchPlayers(long userId, String query) {
String stripped = query.strip();
if (stripped.length() < MIN_QUERY_LENGTH) {
return PlayerSearch.of(List.of());
}
if (!limiter.allow(userId)) {
return PlayerSearch.tooMany();
}
return PlayerSearch.of(followRepository.searchPublicNames(stripped, FollowRepository.SEARCH_LIMIT));
}
} }
@@ -0,0 +1,32 @@
package core_service.modules.follow.service;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.concurrent.ConcurrentHashMap;
/**
* Searches per user and minute. One limiter per user who searched since boot stays in memory: small, and bounded by
* the number of users. The limit holds per instance.
*/
@Component
public class PlayerSearchLimiter {
private final RateLimiterConfig config;
private final ConcurrentHashMap<Long, RateLimiter> limiters = new ConcurrentHashMap<>();
public PlayerSearchLimiter(@Value("${app.follow.search-per-minute:30}") int perMinute) {
this.config = RateLimiterConfig.custom()
.limitForPeriod(perMinute)
.limitRefreshPeriod(Duration.ofMinutes(1))
.timeoutDuration(Duration.ZERO)
.build();
}
public boolean allow(long userId) {
return limiters.computeIfAbsent(userId, id -> RateLimiter.of("player-search-" + id, config)).acquirePermission();
}
}
@@ -27,7 +27,7 @@ public class InboxController {
public ResponseEntity<InboxDto> inbox(@AuthenticationPrincipal UserPrincipal principal) { public ResponseEntity<InboxDto> inbox(@AuthenticationPrincipal UserPrincipal principal) {
Inbox inbox = inboxService.inbox(principal.getIdAsLong()); Inbox inbox = inboxService.inbox(principal.getIdAsLong());
return ResponseEntity.ok(new InboxDto(inbox.notes().stream() return ResponseEntity.ok(new InboxDto(inbox.notes().stream()
.map(note -> new InboxDto.Note(note.id(), note.kind().name(), note.subjectName(), note.newGames(), .map(note -> new InboxDto.Note(note.id(), note.kind().name(), note.subjectName(), note.profileName(), note.newGames(),
note.achievements(), note.statusChanges(), note.wishlistAdds(), note.createdAt(), note.read(), note.details())) note.achievements(), note.statusChanges(), note.wishlistAdds(), note.createdAt(), note.read(), note.details()))
.toList(), inbox.unread())); .toList(), inbox.unread()));
} }
@@ -36,7 +36,7 @@ public class InboxController {
public ResponseEntity<?> details(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id) { public ResponseEntity<?> details(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id) {
return inboxService.details(principal.getIdAsLong(), id) return inboxService.details(principal.getIdAsLong(), id)
.<ResponseEntity<?>>map(details -> ResponseEntity.ok(new NoteDetailsDto(details.stream() .<ResponseEntity<?>>map(details -> ResponseEntity.ok(new NoteDetailsDto(details.stream()
.map(detail -> new NoteDetailsDto.Detail(detail.kind().name(), detail.gameName(), .map(detail -> new NoteDetailsDto.Detail(detail.appid(), detail.kind().name(), detail.gameName(),
detail.itemName(), detail.fromStatus(), detail.toStatus())) detail.itemName(), detail.fromStatus(), detail.toStatus()))
.toList()))) .toList())))
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
@@ -11,9 +11,10 @@ public record InboxDto(List<Note> results, int unread) {
/** /**
* @param kind DIGEST, VISIBILITY, PSN_EXPIRING or PSN_EXPIRED (the PSN kinds have no displayName) * @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 * @param displayName the name of the followed user when the note was written; never a user id
* @param profileName the subject's current name while the profile is public, else null; never an id
* @param details the note can be opened: it has detail rows and the subject is still followed and public * @param details the note can be opened: it has detail rows and the subject is still followed and public
*/ */
public record Note(long id, String kind, String displayName, int newGames, int achievements, int statusChanges, public record Note(long id, String kind, String displayName, String profileName, int newGames, int achievements, int statusChanges,
int wishlistAdds, Instant createdAt, boolean read, boolean details) { int wishlistAdds, Instant createdAt, boolean read, boolean details) {
} }
} }
@@ -5,11 +5,12 @@ import java.util.List;
public record NoteDetailsDto(List<Detail> results) { public record NoteDetailsDto(List<Detail> results) {
/** /**
* @param appid the game key as stored in the note; may be a merged key
* @param kind NEW_GAME, ACHIEVEMENT, STATUS or WISHLIST * @param kind NEW_GAME, ACHIEVEMENT, STATUS or WISHLIST
* @param itemName ACHIEVEMENT only: the achievement or trophy name * @param itemName ACHIEVEMENT only: the achievement or trophy name
* @param fromStatus STATUS only * @param fromStatus STATUS only
* @param toStatus STATUS only * @param toStatus STATUS only
*/ */
public record Detail(String kind, String gameName, String itemName, String fromStatus, String toStatus) { public record Detail(long appid, String kind, String gameName, String itemName, String fromStatus, String toStatus) {
} }
} }
@@ -4,8 +4,9 @@ import java.time.Instant;
/** /**
* @param subjectName the name of the followed user when the note was written * @param subjectName the name of the followed user when the note was written
* @param profileName the current name of the subject while the profile is public, else null
* @param details the note can be opened: it has detail rows and the subject is still followed and public * @param details the note can be opened: it has detail rows and the subject is still followed and public
*/ */
public record InboxNote(long id, NoteKind kind, String subjectName, int newGames, int achievements, public record InboxNote(long id, NoteKind kind, String subjectName, String profileName, int newGames, int achievements,
int statusChanges, int wishlistAdds, Instant createdAt, boolean read, boolean details) { int statusChanges, int wishlistAdds, Instant createdAt, boolean read, boolean details) {
} }
@@ -3,5 +3,5 @@ package core_service.modules.inbox.domain;
/** /**
* One line behind a digest note. Names are snapshots from when the note was written. * One line behind a digest note. Names are snapshots from when the note was written.
*/ */
public record NoteDetail(NoteDetailKind kind, String gameName, String itemName, String fromStatus, String toStatus) { public record NoteDetail(long appid, NoteDetailKind kind, String gameName, String itemName, String fromStatus, String toStatus) {
} }
@@ -26,6 +26,10 @@ public class InboxRepository {
return jdbc.query(""" return jdbc.query("""
select n.id, n.kind, n.subject_name, n.new_games, n.achievements, n.status_changes, n.wishlist_adds, select n.id, n.kind, n.subject_name, n.new_games, n.achievements, n.status_changes, n.wishlist_adds,
n.created_at, n.read_at, n.created_at, n.read_at,
(select a.display_name from account_name a join public_user p on p.user_id = a.user_id
where a.user_id = n.subject_user_id
and not exists (select 1 from blocked_user b where b.user_id = a.user_id)
and not exists (select 1 from deleted_user d where d.user_id = a.user_id)) as profile_name,
exists (select 1 from inbox_note_detail d where d.note_id = n.id) exists (select 1 from inbox_note_detail d where d.note_id = n.id)
and exists (select 1 from follow f where f.user_id = n.user_id and f.followed_user_id = n.subject_user_id) and exists (select 1 from follow f where f.user_id = n.user_id and f.followed_user_id = n.subject_user_id)
and exists (select 1 from public_user p where p.user_id = n.subject_user_id) as details and exists (select 1 from public_user p where p.user_id = n.subject_user_id) as details
@@ -33,7 +37,7 @@ public class InboxRepository {
order by n.created_at desc, n.id desc order by n.created_at desc, n.id desc
limit :limit""", new MapSqlParameterSource("userId", userId).addValue("limit", limit), limit :limit""", new MapSqlParameterSource("userId", userId).addValue("limit", limit),
(rs, i) -> new InboxNote(rs.getLong("id"), NoteKind.valueOf(rs.getString("kind")), (rs, i) -> new InboxNote(rs.getLong("id"), NoteKind.valueOf(rs.getString("kind")),
rs.getString("subject_name"), rs.getInt("new_games"), rs.getInt("achievements"), rs.getString("subject_name"), rs.getString("profile_name"), rs.getInt("new_games"), rs.getInt("achievements"),
rs.getInt("status_changes"), rs.getInt("wishlist_adds"), rs.getInt("status_changes"), rs.getInt("wishlist_adds"),
rs.getTimestamp("created_at").toInstant(), rs.getTimestamp("read_at") != null, rs.getTimestamp("created_at").toInstant(), rs.getTimestamp("read_at") != null,
rs.getBoolean("details"))); rs.getBoolean("details")));
@@ -74,12 +78,12 @@ public class InboxRepository {
*/ */
public List<NoteDetail> findDetails(long noteId, long subjectUserId) { public List<NoteDetail> findDetails(long noteId, long subjectUserId) {
return jdbc.query(""" return jdbc.query("""
select d.kind, d.game_name, d.item_name, d.from_status, d.to_status from inbox_note_detail d select d.appid, d.kind, d.game_name, d.item_name, d.from_status, d.to_status from inbox_note_detail d
where d.note_id = :noteId where d.note_id = :noteId
and not exists (select 1 from user_game ug and not exists (select 1 from user_game ug
where ug.user_id = :subjectUserId and ug.appid = d.appid and ug.hidden_at is not null) where ug.user_id = :subjectUserId and ug.appid = d.appid and ug.hidden_at is not null)
order by d.id""", new MapSqlParameterSource("noteId", noteId).addValue("subjectUserId", subjectUserId), order by d.id""", new MapSqlParameterSource("noteId", noteId).addValue("subjectUserId", subjectUserId),
(rs, i) -> new NoteDetail(NoteDetailKind.valueOf(rs.getString("kind")), rs.getString("game_name"), (rs, i) -> new NoteDetail(rs.getLong("appid"), NoteDetailKind.valueOf(rs.getString("kind")), rs.getString("game_name"),
rs.getString("item_name"), rs.getString("from_status"), rs.getString("to_status"))); rs.getString("item_name"), rs.getString("from_status"), rs.getString("to_status")));
} }
@@ -10,6 +10,7 @@ import core_service.modules.library.api.dto.AuditEntryDto;
import core_service.modules.library.api.dto.BrokenImageReport; import core_service.modules.library.api.dto.BrokenImageReport;
import core_service.modules.library.api.dto.CatalogGameDto; import core_service.modules.library.api.dto.CatalogGameDto;
import core_service.modules.library.api.dto.CatalogGameDetailDto; import core_service.modules.library.api.dto.CatalogGameDetailDto;
import core_service.modules.library.api.dto.CatalogPreviewDto;
import core_service.modules.library.api.dto.CatalogHitDto; import core_service.modules.library.api.dto.CatalogHitDto;
import core_service.modules.library.api.dto.CorrectionMarkDto; import core_service.modules.library.api.dto.CorrectionMarkDto;
import core_service.modules.library.api.dto.GameDetailDto; import core_service.modules.library.api.dto.GameDetailDto;
@@ -39,6 +40,7 @@ import core_service.modules.library.domain.CatalogGameDetail;
import core_service.modules.library.domain.CatalogHit; import core_service.modules.library.domain.CatalogHit;
import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.LinkPair; import core_service.modules.library.domain.LinkPair;
import core_service.modules.library.domain.Mood; import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.PickSource; import core_service.modules.library.domain.PickSource;
@@ -101,7 +103,8 @@ public class LibraryController {
private static final int MAX_PICKER_EXCLUDES = 100; private static final int MAX_PICKER_EXCLUDES = 100;
private static final int MAX_BACKLOG_GAMES = 20; private static final int MAX_BACKLOG_GAMES = 20;
private static final int MAX_DASHBOARD_GAMES = 20; private static final int MAX_DASHBOARD_GAMES = 20;
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority"); private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority",
"name", "lastPlayedAt", "timeToBeat");
private final LibrarySyncService syncService; private final LibrarySyncService syncService;
private final LibraryQueryService queryService; private final LibraryQueryService queryService;
@@ -189,6 +192,14 @@ public class LibraryController {
} }
} }
@GetMapping("/games/{appid}/preview")
public ResponseEntity<?> getGamePreview(@PathVariable("appid") Long appid) {
return gameDetailService.preview(appid)
.<ResponseEntity<?>>map(p -> ResponseEntity.ok(new CatalogPreviewDto(p.appid(), p.name(), p.imageUrl(),
p.genres(), toDto(p.timeToBeat()))))
.orElseGet(LibraryController::unknownGame);
}
@GetMapping("/games/{appid}") @GetMapping("/games/{appid}")
public ResponseEntity<?> getGameDetail(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<?> getGameDetail(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid, @PathVariable("appid") Long appid,
@@ -367,13 +378,16 @@ public class LibraryController {
} }
private static CatalogGameDetailDto toDto(CatalogGameDetail d) { private static CatalogGameDetailDto toDto(CatalogGameDetail d) {
var t = d.timeToBeat();
return new CatalogGameDetailDto(d.appid(), d.name(), d.imageUrl(), d.speedrunUrl(), d.moods(), d.endless(), return new CatalogGameDetailDto(d.appid(), d.name(), d.imageUrl(), d.speedrunUrl(), d.moods(), d.endless(),
d.genres(), t == null ? null : new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(), d.genres(), toDto(d.timeToBeat()),
t.getNormallyMinutes(), t.getCompletelyMinutes()),
d.corrections().stream().map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList()); d.corrections().stream().map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList());
} }
private static GameDetailDto.TimeToBeatDto toDto(GameTimeToBeat t) {
return t == null ? null : new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(), t.getNormallyMinutes(),
t.getCompletelyMinutes());
}
private static CatalogHitDto toDto(CatalogHit hit) { private static CatalogHitDto toDto(CatalogHit hit) {
return new CatalogHitDto(hit.appid(), hit.name(), hit.imageUrl(), hit.platforms()); return new CatalogHitDto(hit.appid(), hit.name(), hit.imageUrl(), hit.platforms());
} }
@@ -11,6 +11,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
@@ -38,6 +39,17 @@ public class PublicProfileController {
.orElseGet(PublicProfileController::invalidTicket); .orElseGet(PublicProfileController::invalidTicket);
} }
@GetMapping("/library/games/{appid}")
public ResponseEntity<?> getPublicGame(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket,
@PathVariable("appid") long appid) {
return verify(ticket)
.<ResponseEntity<?>>map(t -> queryService.getPublicGame(t.userId(), t.sections(), appid)
.<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("GAME_NOT_FOUND", "No such game on this profile"))))
.orElseGet(PublicProfileController::invalidTicket);
}
@GetMapping("/library/stats/games") @GetMapping("/library/stats/games")
public ResponseEntity<?> getPublicStatGames(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket, public ResponseEntity<?> getPublicStatGames(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket,
@RequestParam StatKey key, @RequestParam StatKey key,
@@ -0,0 +1,13 @@
package core_service.modules.library.api.dto;
import java.util.List;
/**
* A game the caller may not own: catalog data only, nothing per user.
*
* @param appid the kept key when the asked key was merged
* @param timeToBeat null when unknown
*/
public record CatalogPreviewDto(long appid, String name, String imageUrl, List<String> genres,
GameDetailDto.TimeToBeatDto timeToBeat) {
}
@@ -0,0 +1,14 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GameStatus;
/**
* One game of a public profile. A field is null when a hidden section covers it or the value is unknown.
*
* @param status hidden with the STATUS, PLAYING and FINISHED sections
* @param playtimeMinutes hidden with the SUMMARY section, like the achievements
* @param achievementsUnlocked null when the game has no achievements, Steam hides them or none were fetched
*/
public record PublicGameDto(long appid, String name, String imageUrl, GameStatus status, Integer playtimeMinutes,
Integer achievementsUnlocked, Integer achievementsTotal, Double completionPercent) {
}
@@ -0,0 +1,11 @@
package core_service.modules.library.domain;
import java.util.List;
/**
* Catalog data of one game key, the same for every user.
*
* @param timeToBeat null when unknown
*/
public record CatalogPreview(long appid, String name, String imageUrl, List<String> genres, GameTimeToBeat timeToBeat) {
}
@@ -1,10 +1,17 @@
package core_service.modules.library.domain; package core_service.modules.library.domain;
import java.time.Instant;
/** /**
* A PlayStation title the sync has seen for a user (V39). A LINKED title has the library key of the row PlayStation * 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. * made or feeds; STEAM_OWNED has the key of a Steam row it only matched; DISMISSED titles were left unchecked in a review.
* The stored play (playtimeMinutes, lastPlayedAt) is what the last sync saw; both are null until the first savePlays.
*/ */
public record PsnTitle(String psnKey, State state, Long appid) { public record PsnTitle(String psnKey, State state, Long appid, Integer playtimeMinutes, Instant lastPlayedAt) {
public PsnTitle(String psnKey, State state, Long appid) {
this(psnKey, state, appid, null, null);
}
public enum State { LINKED, STEAM_OWNED, DISMISSED } public enum State { LINKED, STEAM_OWNED, DISMISSED }
} }
@@ -264,6 +264,13 @@ public class GameMergeRepository {
return targets; return targets;
} }
/**
* @return the key a merged key now lives under, else the key itself
*/
public long findTarget(long appid) {
return findTargets(List.of(appid)).getOrDefault(appid, appid);
}
/** /**
* @return the saved copy of a merge, locked until the transaction ends; empty when there is none * @return the saved copy of a merge, locked until the transaction ends; empty when there is none
*/ */
@@ -433,7 +440,7 @@ public class GameMergeRepository {
insert into user_game_playtime_day (user_id, day, appid, minutes) insert into user_game_playtime_day (user_id, day, appid, minutes)
select user_id, day, :to, minutes from user_game_playtime_day where user_id = :user and appid = :from select user_id, day, :to, minutes from user_game_playtime_day where user_id = :user and appid = :from
on conflict (user_id, day, appid) do update on conflict (user_id, day, appid) do update
set minutes = user_game_playtime_day.minutes + excluded.minutes set minutes = least(user_game_playtime_day.minutes + excluded.minutes, 1440)
""", params); """, params);
jdbc.update("delete from user_game_playtime_day where user_id = :user and appid = :from", params); jdbc.update("delete from user_game_playtime_day where user_id = :user and appid = :from", params);
jdbc.update("update user_psn_title set appid = :to where user_id = :user and appid = :from", params); jdbc.update("update user_psn_title set appid = :to where user_id = :user and appid = :from", params);
@@ -25,10 +25,11 @@ public class PsnTitleRepository {
public Map<String, PsnTitle> findAll(long userId) { public Map<String, PsnTitle> findAll(long userId) {
Map<String, PsnTitle> titles = new LinkedHashMap<>(); Map<String, PsnTitle> titles = new LinkedHashMap<>();
jdbc.query("select psn_key, state, appid from user_psn_title where user_id = :user", jdbc.query("select psn_key, state, appid, playtime_minutes, last_played_at from user_psn_title where user_id = :user",
new MapSqlParameterSource("user", userId), rs -> { new MapSqlParameterSource("user", userId), rs -> {
titles.put(rs.getString("psn_key"), new PsnTitle(rs.getString("psn_key"), titles.put(rs.getString("psn_key"), new PsnTitle(rs.getString("psn_key"),
PsnTitle.State.valueOf(rs.getString("state")), rs.getObject("appid", Long.class))); PsnTitle.State.valueOf(rs.getString("state")), rs.getObject("appid", Long.class),
rs.getObject("playtime_minutes", Integer.class), instant(rs.getTimestamp("last_played_at"))));
}); });
return titles; return titles;
} }
@@ -10,6 +10,7 @@ import core_service.modules.library.domain.RemovedGame;
import core_service.modules.library.domain.StatusCount; import core_service.modules.library.domain.StatusCount;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.shared.sql.LikePattern;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
@@ -28,7 +29,6 @@ import java.util.EnumMap;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Locale;
import java.util.Optional; import java.util.Optional;
public interface UserGameRepository extends JpaRepository<UserGame, UserGameId> { public interface UserGameRepository extends JpaRepository<UserGame, UserGameId> {
@@ -366,7 +366,8 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
// ponytail: the view is joined on every page, not only for the playtime sort: about +4 ms per page. Upgrade path: // ponytail: the view is joined on every page, not only for the playtime sort: about +4 ms per page. Upgrade path:
// join it only for the playtime sort or the PLAYING status, with a second query. // join it only for the playtime sort or the PLAYING status, with a second query.
@Query(value = "select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " + @Query(value = "select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
"left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid " + OWNED_WHERE, "left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid " +
"left join GameTimeToBeat tb on tb.appid = ug.appid " + OWNED_WHERE,
countQuery = "select count(ug) from UserGame ug join SteamGame sg on sg.appid = ug.appid " + OWNED_WHERE) countQuery = "select count(ug) from UserGame ug join SteamGame sg on sg.appid = ug.appid " + OWNED_WHERE)
Page<UserGame> findOwnedEntries(@Param("userId") Long userId, Page<UserGame> findOwnedEntries(@Param("userId") Long userId,
@Param("hidden") boolean hidden, @Param("hidden") boolean hidden,
@@ -418,7 +419,9 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
/** /**
* Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last * Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last
* in both directions, priority sorts by HIGH, MEDIUM, LOW (the enum names would sort alphabetically), and * in both directions, priority sorts by HIGH, MEDIUM, LOW (the enum names would sort alphabetically), and
* appid breaks every tie so pages do not repeat or skip games. Playtime sorts by the set total. * appid breaks every tie so pages do not repeat or skip games. Playtime sorts by the set total. A game never
* played or without a known time to beat comes last in both directions; the name sorts case-insensitive.
* The time to beat needs {@code left join GameTimeToBeat tb on tb.appid = ug.appid}.
*/ */
static Sort querySort(Sort requested) { static Sort querySort(Sort requested) {
Sort result = Sort.unsorted(); Sort result = Sort.unsorted();
@@ -430,6 +433,10 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
case "priority" -> Sort.by(JpaSort.unsafe(order.getDirection(), case "priority" -> Sort.by(JpaSort.unsafe(order.getDirection(),
"(case ug.priority when 'HIGH' then 3 when 'MEDIUM' then 2 when 'LOW' then 1 end)") "(case ug.priority when 'HIGH' then 3 when 'MEDIUM' then 2 when 'LOW' then 1 end)")
.iterator().next().nullsLast()); .iterator().next().nullsLast());
case "name" -> Sort.by(JpaSort.unsafe(order.getDirection(), "lower(sg.name)").iterator().next());
case "lastPlayedAt" -> Sort.by(order.nullsLast());
case "timeToBeat" -> Sort.by(JpaSort.unsafe(order.getDirection(), "tb.normallyMinutes")
.iterator().next().nullsLast());
default -> Sort.by(order); default -> Sort.by(order);
}); });
} }
@@ -441,9 +448,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
*/ */
static String containsPattern(String query) { static String containsPattern(String query) {
if (query == null || query.isBlank()) return null; if (query == null || query.isBlank()) return null;
String escaped = query.trim().toLowerCase(Locale.ROOT) return "%" + LikePattern.escapeLower(query.trim()) + "%";
.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
return "%" + escaped + "%";
} }
/** /**
@@ -103,7 +103,7 @@ public class UserStatsRepository {
private static final String PILE_TTB_SET = "select ug.appid, t.normally_minutes as minutes, " + NO_EVENT + ", " private static final String PILE_TTB_SET = "select ug.appid, t.normally_minutes as minutes, " + NO_EVENT + ", "
+ NO_MOVE + " " + PILE_TTB_FROM + "and t.normally_minutes is not null"; + NO_MOVE + " " + PILE_TTB_FROM + "and t.normally_minutes is not null";
private static final String LONGEST_DAY_SET = "select coalesce(e.appid, p.appid) as appid, sum(p.minutes) as minutes, " private static final String LONGEST_DAY_SET = "select coalesce(e.appid, p.appid) as appid, least(sum(p.minutes), 1440) as minutes, "
+ NO_EVENT + ", " + NO_MOVE + " " + ENTRY_DAYS + "and p.day = :arg group by coalesce(e.appid, p.appid)"; + NO_EVENT + ", " + NO_MOVE + " " + ENTRY_DAYS + "and p.day = :arg group by coalesce(e.appid, p.appid)";
private static final String FINISHED_YEAR_SET = "select ug.appid, " + NO_MINUTES + ", " + FINISHED_AT private static final String FINISHED_YEAR_SET = "select ug.appid, " + NO_MINUTES + ", " + FINISHED_AT
+ " as event_at, " + NO_MOVE + " " + FINISHED_FROM + "and " + FINISHED_YEAR + " = :arg"; + " as event_at, " + NO_MOVE + " " + FINISHED_FROM + "and " + FINISHED_YEAR + " = :arg";
@@ -242,7 +242,7 @@ public class UserStatsRepository {
} }
public Optional<DayMinutes> longestDay(long userId) { public Optional<DayMinutes> longestDay(long userId) {
return jdbc.query("select p.day, sum(p.minutes) as minutes " + PLAYTIME_DAYS_NOT_HIDDEN + return jdbc.query("select p.day, least(sum(p.minutes), 1440) as minutes " + PLAYTIME_DAYS_NOT_HIDDEN +
"group by p.day order by minutes desc, p.day desc limit 1", user(userId), "group by p.day order by minutes desc, p.day desc limit 1", user(userId),
(rs, i) -> new DayMinutes(rs.getDate("day").toLocalDate(), rs.getLong("minutes"))) (rs, i) -> new DayMinutes(rs.getDate("day").toLocalDate(), rs.getLong("minutes")))
.stream().findFirst(); .stream().findFirst();
@@ -9,6 +9,7 @@ import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.api.dto.PlatformPlayDto; import core_service.modules.library.api.dto.PlatformPlayDto;
import core_service.modules.library.domain.AchievementKind; import core_service.modules.library.domain.AchievementKind;
import core_service.modules.library.domain.AchievementProgress; import core_service.modules.library.domain.AchievementProgress;
import core_service.modules.library.domain.CatalogPreview;
import core_service.modules.library.domain.ComingGame; import core_service.modules.library.domain.ComingGame;
import core_service.modules.library.domain.Edition; import core_service.modules.library.domain.Edition;
import core_service.modules.library.domain.GameAchievementSchema; import core_service.modules.library.domain.GameAchievementSchema;
@@ -125,6 +126,19 @@ public class GameDetailService {
return Optional.of(assemble(refreshed.userGame(), meta, refreshed.hasSchema(), refreshed.achievementsStale())); return Optional.of(assemble(refreshed.userGame(), meta, refreshed.hasSchema(), refreshed.achievementsStale()));
} }
/**
* Catalog data of a key the caller may not own. No call to Steam or IGDB.
*
* @return empty when the catalog has no such game
*/
public Optional<CatalogPreview> preview(long askedAppid) {
long appid = mergeRepository.findTarget(askedAppid);
return steamGameRepository.findById(appid).map(game -> new CatalogPreview(appid, game.getName(),
game.getImageUrl(),
gameMoodsRepository.findById(appid).map(GameMoods::getGenres).orElse(List.of()),
gameTimeToBeatRepository.findById(appid).orElse(null)));
}
/** /**
* @param userGame the game after the refresh, re-read when the finish rule changed its status * @param userGame the game after the refresh, re-read when the finish rule changed its status
* @param achievementsStale a due Steam refresh was skipped * @param achievementsStale a due Steam refresh was skipped
@@ -11,6 +11,7 @@ import core_service.modules.library.api.dto.PickedGameDto;
import core_service.modules.library.api.dto.PickerCategoryDto; import core_service.modules.library.api.dto.PickerCategoryDto;
import core_service.modules.library.api.dto.PickerDto; import core_service.modules.library.api.dto.PickerDto;
import core_service.modules.library.api.dto.PlatformDto; import core_service.modules.library.api.dto.PlatformDto;
import core_service.modules.library.api.dto.PublicGameDto;
import core_service.modules.library.api.dto.PublicLibraryDto; import core_service.modules.library.api.dto.PublicLibraryDto;
import core_service.modules.library.api.dto.RecentGameDto; import core_service.modules.library.api.dto.RecentGameDto;
import core_service.modules.library.api.dto.WishlistDto; import core_service.modules.library.api.dto.WishlistDto;
@@ -39,6 +40,7 @@ import core_service.modules.library.repo.FriendPlayRepository;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameCreditRepository; import core_service.modules.library.repo.GameCreditRepository;
import core_service.modules.library.repo.GameLinkRepository; import core_service.modules.library.repo.GameLinkRepository;
import core_service.modules.library.repo.GameMergeRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.PlaytimeDayRepository; import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.PsnTrophyRepository; import core_service.modules.library.repo.PsnTrophyRepository;
@@ -108,6 +110,7 @@ public class LibraryQueryService {
private final ReleaseDateService releaseDateService; private final ReleaseDateService releaseDateService;
private final SpeedrunService speedrunService; private final SpeedrunService speedrunService;
private final GameLinkRepository gameLinkRepository; private final GameLinkRepository gameLinkRepository;
private final GameMergeRepository mergeRepository;
private final GameCreditRepository gameCreditRepository; private final GameCreditRepository gameCreditRepository;
private final FriendPlayRepository friendPlayRepository; private final FriendPlayRepository friendPlayRepository;
private final PsnTrophyRepository psnTrophyRepository; private final PsnTrophyRepository psnTrophyRepository;
@@ -390,7 +393,8 @@ public class LibraryQueryService {
Map<LocalDate, Long> played = playtimeDayRepository.minutesPerDay(userId, from).stream() Map<LocalDate, Long> played = playtimeDayRepository.minutesPerDay(userId, from).stream()
.collect(Collectors.toMap(DayMinutes::day, DayMinutes::minutes)); .collect(Collectors.toMap(DayMinutes::day, DayMinutes::minutes));
List<DayMinutes> perDay = from.datesUntil(today.plusDays(1)) List<DayMinutes> perDay = from.datesUntil(today.plusDays(1))
.map(day -> new DayMinutes(day, played.getOrDefault(day, 0L))) // A day holds at most 24 h; rows from before V18/V49 or several games can sum higher.
.map(day -> new DayMinutes(day, Math.min(played.getOrDefault(day, 0L), 1440)))
.toList(); .toList();
long total = perDay.stream().mapToLong(DayMinutes::minutes).sum(); long total = perDay.stream().mapToLong(DayMinutes::minutes).sum();
@@ -447,6 +451,64 @@ public class LibraryQueryService {
shared.isEmpty() ? null : userStatsService.getStats(userId, shared)); shared.isEmpty() ? null : userStatsService.getStats(userId, shared));
} }
/**
* One listed game of a public profile. Loads the achievement rows of the whole library of the owner and keeps
* the one game.
*
* @param sectionNames see {@link ProfileSection#shown}
* @return empty when the owner does not have the game, removed or hid it, or every field is hidden: all the same
* answer, so the caller learns nothing about a hidden game
*/
@Public
public Optional<PublicGameDto> getPublicGame(Long userId, Collection<String> sectionNames, long askedAppid) {
Set<ProfileSection> sections = ProfileSection.shown(sectionNames);
boolean showStatus = sections.contains(ProfileSection.STATUS) || sections.contains(ProfileSection.PLAYING)
|| sections.contains(ProfileSection.FINISHED);
boolean showSummary = sections.contains(ProfileSection.SUMMARY);
if (!showStatus && !showSummary) {
return Optional.empty();
}
return userGameRepository.findById(new UserGameId(userId, mergeRepository.findTarget(askedAppid)))
.filter(ug -> ug.getRemovedAt() == null && ug.getHiddenAt() == null)
.filter(ug -> showsEveryGame(sections) || listedBySection(sections, ug.getStatus()))
.map(userGame -> toPublicGame(userId, userGame, showStatus, showSummary));
}
/** STATUS and SUMMARY already expose the whole library (status lists, totals). */
private static boolean showsEveryGame(Set<ProfileSection> sections) {
return sections.contains(ProfileSection.STATUS) || sections.contains(ProfileSection.SUMMARY);
}
/** Only PLAYING or FINISHED shown: a game is revealed only when a shown section already lists its status. */
private static boolean listedBySection(Set<ProfileSection> sections, GameStatus status) {
return sections.contains(ProfileSection.PLAYING) && status == GameStatus.PLAYING
|| sections.contains(ProfileSection.FINISHED)
&& (status == GameStatus.FINISHED || status == GameStatus.COMPLETED);
}
private PublicGameDto toPublicGame(Long userId, UserGame userGame, boolean showStatus, boolean showSummary) {
long appid = userGame.getAppid();
SteamGame meta = steamGameRepository.findById(appid).orElse(null);
Integer playtime = showSummary
? gameLinkRepository.entryPlaytimes(userId, List.of(appid)).getOrDefault(appid, userGame.getPlaytimeMinutes())
: null;
AchievementProgress achievements = showSummary ? visibleAchievements(userId, appid) : null;
return new PublicGameDto(appid, meta == null ? null : meta.getName(), meta == null ? null : meta.getImageUrl(),
showStatus ? userGame.getStatus() : null, playtime,
achievements == null ? null : achievements.unlockedCapped(),
achievements == null ? null : achievements.total(),
achievements == null ? null : achievements.percent());
}
/**
* @return null when the game has no achievements or Steam hides them
*/
private AchievementProgress visibleAchievements(Long userId, long appid) {
return AchievementProgress.perGame(progress(userId).stream()
.filter(p -> p.appid() == appid && p.hasAchievements() && p.visible()).toList())
.stream().findFirst().orElse(null);
}
/** /**
* @return null for a hidden section * @return null for a hidden section
*/ */
@@ -164,7 +164,7 @@ public class LibrarySyncWorker {
} }
List<SyncChange> steamChanges = hasSteam ? saveGames(userId, steamId, games, syncStartedAt, playedOn) List<SyncChange> steamChanges = hasSteam ? saveGames(userId, steamId, games, syncStartedAt, playedOn)
: new ArrayList<>(); : new ArrayList<>();
List<SyncChange> psnChanges = psnSyncStep.apply(userId, psn, syncStartedAt, syncDay); List<SyncChange> psnChanges = psnSyncStep.apply(userId, psn, syncStartedAt, syncDay, zone);
// The first sync of an account is a baseline: a count, not a list. // The first sync of an account is a baseline: a count, not a list.
int imported = 0; int imported = 0;
List<SyncChange> listed = new ArrayList<>(); List<SyncChange> listed = new ArrayList<>();
@@ -22,6 +22,7 @@ import org.springframework.transaction.support.TransactionTemplate;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
@@ -47,17 +48,22 @@ public class PsnSyncStep {
* A LINKED title's PSN values, applied in the sync transaction. * A LINKED title's PSN values, applied in the sync transaction.
* *
* @param lastPlayedAt may be null * @param lastPlayedAt may be null
* @param played minutes to book on the day of lastPlayedAt, 0 when the title is a start point or was not played
* since the last sync
*/ */
public record Playtime(long appid, int minutes, Instant lastPlayedAt) {} public record Playtime(long appid, int minutes, Instant lastPlayedAt, int played) {}
/** /**
* What {@link #run} did outside the transaction. * What {@link #run} did outside the transaction.
* *
* @param changes the ADDED rows of this run * @param changes the ADDED rows of this run
* @param plays the title plays {@link #apply} stores; stored there so a skipped or rolled-back apply keeps the
* old baseline
* @param baseline the user had no PSN title before 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 record Prepared(List<SyncChange> changes, List<Playtime> playtimes, List<TitlePlay> plays,
public static final Prepared NONE = new Prepared(List.of(), List.of(), false); boolean baseline) {
public static final Prepared NONE = new Prepared(List.of(), List.of(), List.of(), false);
} }
private final PsnAccountService accountService; private final PsnAccountService accountService;
@@ -106,26 +112,38 @@ public class PsnSyncStep {
List<GameImport.Row> review = new ArrayList<>(); List<GameImport.Row> review = new ArrayList<>();
for (GameImport.Line line : lines) { for (GameImport.Line line : lines) {
PsnTitle seen = known.get(line.psnKey()); PsnTitle seen = known.get(line.psnKey());
Instant lastPlayedAt = lastPlayed.get(line.psnKey());
if (seen != null && seen.state() == PsnTitle.State.LINKED) { if (seen != null && seen.state() == PsnTitle.State.LINKED) {
playtimes.add(new Playtime(seen.appid(), line.playtimeMinutes(), lastPlayed.get(line.psnKey()))); playtimes.add(new Playtime(seen.appid(), line.playtimeMinutes(), lastPlayedAt,
played(seen, line.playtimeMinutes(), lastPlayedAt)));
} else if (seen == null && !open.contains(line.psnKey())) { } else if (seen == null && !open.contains(line.psnKey())) {
matchNew(userId, line, lastPlayed.get(line.psnKey()), changes, review); matchNew(userId, line, lastPlayedAt, changes, review);
} }
} }
saveReview(userId, review); saveReview(userId, review);
psnTitleRepository.savePlays(userId, lines.stream().map(line -> new TitlePlay(line.psnKey(), null, List<TitlePlay> plays = lines.stream().map(line -> new TitlePlay(line.psnKey(), null,
line.platform(), line.playtimeMinutes(), lastPlayed.get(line.psnKey()))).toList()); line.platform(), line.playtimeMinutes(), lastPlayed.get(line.psnKey()))).toList();
int added = changes.size(); int added = changes.size();
changes.addAll(trophyStep.run(userId, found.get().accessToken(), titles)); changes.addAll(trophyStep.run(userId, found.get().accessToken(), titles));
log.info("PSN sync user={} added {} titles, {} wait for review, {} games gained trophies", userId, added, log.info("PSN sync user={} added {} titles, {} wait for review, {} games gained trophies", userId, added,
review.size(), changes.size() - added); review.size(), changes.size() - added);
return new Prepared(changes, mergedKeys(playtimes), baseline); return new Prepared(changes, mergedKeys(playtimes), plays, baseline);
} catch (RuntimeException e) { } catch (RuntimeException e) {
log.error("PSN step failed for user {}", userId, e); log.error("PSN step failed for user {}", userId, e);
return new Prepared(changes, playtimes, baseline); return new Prepared(changes, playtimes, List.of(), baseline);
} }
} }
// Only the title's own new play counts: no stored play is a start point, and a total that grew without a newer
// last played is no play of this sync.
private static int played(PsnTitle seen, int minutes, Instant lastPlayedAt) {
if (seen.playtimeMinutes() == null || lastPlayedAt == null
|| (seen.lastPlayedAt() != null && !lastPlayedAt.isAfter(seen.lastPlayedAt()))) {
return 0;
}
return Math.max(0, minutes - seen.playtimeMinutes());
}
private Optional<PsnAccountService.FetchResult.Titles> fetch(Long userId) { private Optional<PsnAccountService.FetchResult.Titles> fetch(Long userId) {
switch (accountService.fetchTitles(userId)) { switch (accountService.fetchTitles(userId)) {
case PsnAccountService.FetchResult.Titles found -> { case PsnAccountService.FetchResult.Titles found -> {
@@ -194,7 +212,7 @@ public class PsnSyncStep {
private List<Playtime> mergedKeys(List<Playtime> playtimes) { private List<Playtime> mergedKeys(List<Playtime> playtimes) {
Map<Long, Long> targets = mergeRepository.findTargets(playtimes.stream().map(Playtime::appid).toList()); 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()), return playtimes.stream().map(playtime -> new Playtime(targets.getOrDefault(playtime.appid(), playtime.appid()),
playtime.minutes(), playtime.lastPlayedAt())).toList(); playtime.minutes(), playtime.lastPlayedAt(), playtime.played())).toList();
} }
// Several titles can be one game (PS4 and PS5 versions, a review pick): its playtime is their sum, so the row // Several titles can be one game (PS4 and PS5 versions, a review pick): its playtime is their sum, so the row
@@ -205,33 +223,35 @@ public class PsnSyncStep {
byGame.merge(playtime.appid(), playtime, (a, b) -> new Playtime(a.appid(), byGame.merge(playtime.appid(), playtime, (a, b) -> new Playtime(a.appid(),
(int) Math.min((long) a.minutes() + b.minutes(), Integer.MAX_VALUE), (int) Math.min((long) a.minutes() + b.minutes(), Integer.MAX_VALUE),
a.lastPlayedAt() == null || (b.lastPlayedAt() != null && b.lastPlayedAt().isAfter(a.lastPlayedAt())) a.lastPlayedAt() == null || (b.lastPlayedAt() != null && b.lastPlayedAt().isAfter(a.lastPlayedAt()))
? b.lastPlayedAt() : a.lastPlayedAt())); ? b.lastPlayedAt() : a.lastPlayedAt(),
(int) Math.min((long) a.played() + b.played(), Integer.MAX_VALUE)));
} }
return byGame.values(); return byGame.values();
} }
/** /**
* In the sync transaction, after the Steam games are saved: playtime, last played and two-weeks minutes of the * 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. * PSN rows. The played minutes of a game are booked on the day it was last played, in the given zone. No status
* change: PSN rows have statusSetByUser.
* *
* @return all PSN changes of the run (ADDED and PLAYTIME) * @return all PSN changes of the run (ADDED and PLAYTIME)
*/ */
public List<SyncChange> apply(Long userId, Prepared prepared, Instant now, LocalDate day) { public List<SyncChange> apply(Long userId, Prepared prepared, Instant now, LocalDate day, ZoneId zone) {
List<SyncChange> changes = new ArrayList<>(prepared.changes()); List<SyncChange> changes = new ArrayList<>(prepared.changes());
Map<Long, UserGame> touched = new HashMap<>(); Map<Long, UserGame> touched = new HashMap<>();
for (Playtime playtime : perGame(prepared.playtimes())) { for (Playtime playtime : perGame(prepared.playtimes())) {
userGameRepository.findById(new UserGameId(userId, playtime.appid())) userGameRepository.findById(new UserGameId(userId, playtime.appid()))
.filter(game -> game.getSource() == GameSource.PSN && game.getRemovedAt() == null) .filter(game -> game.getSource() == GameSource.PSN && game.getRemovedAt() == null)
.ifPresent(game -> { .ifPresent(game -> {
int growth = playtime.minutes() - game.getPlaytimeMinutes();
game.setPlaytimeMinutes(playtime.minutes()); game.setPlaytimeMinutes(playtime.minutes());
game.setLastSeenAt(now); game.setLastSeenAt(now);
if (playtime.lastPlayedAt() != null) { if (playtime.lastPlayedAt() != null) {
game.setLastPlayedAt(playtime.lastPlayedAt()); game.setLastPlayedAt(playtime.lastPlayedAt());
} }
if (growth > 0) { if (playtime.played() > 0) {
changes.add(SyncChange.playtime(game.getAppid(), growth)); LocalDate playedOn = LocalDate.ofInstant(playtime.lastPlayedAt(), zone);
playtimeDayRepository.addMinutes(userId, game.getAppid(), day, growth); changes.add(SyncChange.playtime(game.getAppid(), playtime.played()));
playtimeDayRepository.addMinutes(userId, game.getAppid(), playedOn, playtime.played());
} }
touched.put(game.getAppid(), game); touched.put(game.getAppid(), game);
}); });
@@ -243,6 +263,9 @@ public class PsnSyncStep {
touched.forEach((appid, game) -> touched.forEach((appid, game) ->
game.setPlaytime2Weeks((int) Math.min(twoWeeks.getOrDefault(appid, 0L), Integer.MAX_VALUE))); game.setPlaytime2Weeks((int) Math.min(twoWeeks.getOrDefault(appid, 0L), Integer.MAX_VALUE)));
} }
if (!prepared.plays().isEmpty()) {
psnTitleRepository.savePlays(userId, prepared.plays());
}
return changes; return changes;
} }
} }
@@ -0,0 +1,16 @@
package core_service.shared.sql;
import java.util.Locale;
public final class LikePattern {
private LikePattern() {}
/**
* Lower-cases the text and escapes {@code \}, {@code %} and {@code _}, so a {@code like ... escape '\'} matches
* them literally. The caller adds the wildcards.
*/
public static String escapeLower(String text) {
return text.toLowerCase(Locale.ROOT).replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
}
}
+8
View File
@@ -8,6 +8,12 @@ spring:
accept-float-as-int: false accept-float-as-int: false
jpa: jpa:
open-in-view: false open-in-view: false
datasource:
hikari:
# Plan once, not per request: Dashboard burst 443 -> 229 ms, 94% of the DB time was planning (#49).
data-source-properties:
prepareThreshold: 1
connection-init-sql: set plan_cache_mode = force_generic_plan
flyway: flyway:
enabled: true enabled: true
baseline-on-migrate: true baseline-on-migrate: true
@@ -100,6 +106,8 @@ app:
# The hand changes of a followed user are one batch after this quiet time. # The hand changes of a followed user are one batch after this quiet time.
digest-ms: 600000 digest-ms: 600000
quiet-minutes: 10 quiet-minutes: 10
# Player searches per user and minute; more answers 429.
search-per-minute: 30
inbox: inbox:
purge-cron: "0 15 3 * * *" purge-cron: "0 15 3 * * *"
keep-days: 90 keep-days: 90
@@ -0,0 +1,11 @@
-- Gitea #53: the PSN sync booked the whole history of a newly linked title on the sync day (24 h of a game last
-- played in 2025). A PSN row's day row more than one day after the day its titles were last played is no real
-- play: drop it. One day is kept: a sync after midnight booked the evening before on the next day.
-- Titles without last_played_at are left alone. Safe to run again.
DELETE FROM user_game_playtime_day p
USING user_game ug,
(SELECT user_id, appid, MAX(last_played_at) AS last_played_at FROM psn_title_play
GROUP BY user_id, appid) t
WHERE ug.user_id = p.user_id AND ug.appid = p.appid AND ug.source = 'PSN'
AND t.user_id = ug.user_id AND t.appid = ug.appid
AND p.day > (t.last_played_at AT TIME ZONE 'Europe/Berlin')::date + 1;
@@ -0,0 +1,28 @@
package core_service.configuration;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.assertj.core.api.Assertions.assertThat;
class DataSourceSettingsTest extends AbstractPersistenceTest {
@Autowired
private JdbcTemplate jdbc;
@Test
void coreConnection_ForcesGenericPlans() {
assertThat(jdbc.queryForObject("show plan_cache_mode", String.class)).isEqualTo("force_generic_plan");
}
@Test
void coreConnection_PreparesOnTheFirstUse() {
jdbc.queryForObject("select 1 where ? = 1", Integer.class, 1);
assertThat(jdbc.queryForObject(
"select count(*) from pg_prepared_statements where statement like 'select 1 where%'", Integer.class))
.isEqualTo(1);
}
}
@@ -14,6 +14,7 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilde
import tools.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper; import tools.jackson.databind.json.JsonMapper;
import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@@ -62,6 +63,7 @@ class FollowControllerTest extends AbstractIntegrationTest {
@AfterEach @AfterEach
void cleanUp() { void cleanUp() {
jdbc.update("delete from follow"); jdbc.update("delete from follow");
jdbc.update("delete from inbox_note");
jdbc.update("delete from follow_digest"); jdbc.update("delete from follow_digest");
jdbc.update("delete from public_user"); jdbc.update("delete from public_user");
jdbc.update("delete from account_name"); jdbc.update("delete from account_name");
@@ -82,7 +84,15 @@ class FollowControllerTest extends AbstractIntegrationTest {
} }
private JsonNode list(long userId) throws Exception { private JsonNode list(long userId) throws Exception {
String body = mockMvc.perform(as(get("/api/follows"), userId)).andExpect(status().isOk()) return okJson(get("/api/follows"), userId);
}
private JsonNode players(long userId, String query) throws Exception {
return okJson(get("/api/follows/players").param("q", query), userId);
}
private JsonNode okJson(MockHttpServletRequestBuilder request, long userId) throws Exception {
String body = mockMvc.perform(as(request, userId)).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString(); .andReturn().getResponse().getContentAsString();
return JsonMapper.builder().build().readTree(body); return JsonMapper.builder().build().readTree(body);
} }
@@ -204,6 +214,95 @@ class FollowControllerTest extends AbstractIntegrationTest {
assertThat(json.toString()).doesNotContain("SecretThree", String.valueOf(LOBO), String.valueOf(SECRET)); assertThat(json.toString()).doesNotContain("SecretThree", String.valueOf(LOBO), String.valueOf(SECRET));
Set<String> keys = new HashSet<>(); Set<String> keys = new HashSet<>();
followers.get(0).propertyNames().forEach(keys::add); followers.get(0).propertyNames().forEach(keys::add);
assertThat(keys).containsExactlyInAnyOrder("id", "displayName"); assertThat(keys).containsExactlyInAnyOrder("id", "displayName", "lastActivityAt");
}
private void digestNote(long readerId, long subjectId, String kind, int minutesAgo) {
jdbc.update("insert into inbox_note (user_id, kind, subject_user_id, subject_name, created_at) "
+ "values (?, ?, ?, 'x', now() - make_interval(mins => ?))", readerId, kind, subjectId, minutesAgo);
}
@Test
void following_LastActivityIsTheNewestDigestNoteOfTheCallerAboutThatUser() throws Exception {
jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?), (?, ?), (?, ?)",
VIEWER, LOBO, VIEWER, OTHER, OTHER, LOBO);
digestNote(VIEWER, LOBO, "DIGEST", 60);
digestNote(VIEWER, LOBO, "DIGEST", 10);
digestNote(VIEWER, LOBO, "VISIBILITY", 1);
digestNote(OTHER, LOBO, "DIGEST", 0);
java.time.Instant newest = jdbc.queryForObject("select max(created_at) from inbox_note where user_id = ? and kind = 'DIGEST'",
java.sql.Timestamp.class, VIEWER).toInstant();
JsonNode following = list(VIEWER).get("following");
JsonNode lobo = following.get(0).get("displayName").asString().equals("Lobo") ? following.get(0) : following.get(1);
JsonNode other = lobo == following.get(0) ? following.get(1) : following.get(0);
assertThat(java.time.Instant.parse(lobo.get("lastActivityAt").asString())).isEqualTo(newest);
assertThat(other.get("lastActivityAt").isNull()).isTrue();
}
@Test
void followers_HaveNoLastActivity() throws Exception {
jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?)", LOBO, VIEWER);
digestNote(VIEWER, LOBO, "DIGEST", 5);
assertThat(list(VIEWER).get("followers").get(0).get("lastActivityAt").isNull()).isTrue();
}
@Test
void players_FindsPublicNamesCaseInsensitiveAndNotPrivateBlockedDeleted() throws Exception {
JsonNode json = players(VIEWER, "ER");
List<String> names = new ArrayList<>();
json.get("results").forEach(player -> names.add(player.get("displayName").asString()));
assertThat(names).containsExactlyInAnyOrder("Viewer", "OtherSix");
assertThat(json.get("results").get(0).propertyNames()).containsExactly("displayName");
assertThat(players(VIEWER, "re").get("results")).isEmpty();
assertThat(players(VIEWER, "ed").get("results")).isEmpty();
}
@Test
void players_PrefixHitsComeFirst() throws Exception {
name(OTHER + 2, "Zolo");
jdbc.update("insert into public_user (user_id) values (?)", OTHER + 2);
JsonNode results = players(VIEWER, "lo").get("results");
assertThat(results.get(0).get("displayName").asString()).isEqualTo("Lobo");
assertThat(results.get(1).get("displayName").asString()).isEqualTo("Zolo");
}
@Test
void players_ShortQueryIsEmpty() throws Exception {
assertThat(players(VIEWER, "x").get("results")).isEmpty();
}
@Test
void players_EscapesLikeWildcards() throws Exception {
assertThat(players(VIEWER, "%").get("results")).isEmpty();
assertThat(players(VIEWER, "_o").get("results")).isEmpty();
}
@Test
void players_QueryLongerThan100_Is400() throws Exception {
mockMvc.perform(as(get("/api/follows/players").param("q", "x".repeat(101)), VIEWER))
.andExpect(status().isBadRequest());
}
@Test
void players_WithoutLogin_Is401() throws Exception {
mockMvc.perform(get("/api/follows/players").param("q", "lo")).andExpect(status().isUnauthorized());
}
@Test
void players_31stSearchInAMinute_Is429() throws Exception {
long searcher = 950_001L;
for (int i = 0; i < 30; i++) {
mockMvc.perform(as(get("/api/follows/players").param("q", "lo"), searcher)).andExpect(status().isOk());
}
mockMvc.perform(as(get("/api/follows/players").param("q", "lo"), searcher))
.andExpect(status().isTooManyRequests())
.andExpect(jsonPath("$.code").value("TOO_MANY_SEARCHES"));
} }
} }
@@ -0,0 +1,19 @@
package core_service.modules.follow.service;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PlayerSearchLimiterTest extends AbstractUnitTest {
@Test
void allow_RefusesTheSearchAfterTheLimitAndCountsEachUserOnItsOwn() {
PlayerSearchLimiter limiter = new PlayerSearchLimiter(2);
assertThat(limiter.allow(1)).isTrue();
assertThat(limiter.allow(1)).isTrue();
assertThat(limiter.allow(1)).isFalse();
assertThat(limiter.allow(2)).isTrue();
}
}
@@ -37,6 +37,7 @@ class InboxControllerTest extends AbstractIntegrationTest {
jdbc.update("delete from inbox_note"); jdbc.update("delete from inbox_note");
jdbc.update("delete from follow where user_id in (?, ?)", READER, OTHER); jdbc.update("delete from follow where user_id in (?, ?)", READER, OTHER);
jdbc.update("delete from public_user where user_id = 7"); jdbc.update("delete from public_user where user_id = 7");
jdbc.update("delete from account_name where user_id = 7");
jdbc.update("delete from user_game where user_id = 7"); jdbc.update("delete from user_game where user_id = 7");
jdbc.update("delete from steam_game where appid = 501"); jdbc.update("delete from steam_game where appid = 501");
} }
@@ -47,6 +48,7 @@ class InboxControllerTest extends AbstractIntegrationTest {
long id = jdbc.queryForObject("select max(id) from inbox_note", Long.class); long id = jdbc.queryForObject("select max(id) from inbox_note", Long.class);
jdbc.update("insert into follow (user_id, followed_user_id) values (?, 7)", READER); jdbc.update("insert into follow (user_id, followed_user_id) values (?, 7)", READER);
jdbc.update("insert into public_user (user_id) values (7)"); jdbc.update("insert into public_user (user_id) values (7)");
jdbc.update("insert into account_name (user_id, display_name) values (7, 'SubjectNow')");
jdbc.update("insert into inbox_note_detail (note_id, kind, appid, game_name, item_name, from_status, to_status) values " jdbc.update("insert into inbox_note_detail (note_id, kind, appid, game_name, item_name, from_status, to_status) values "
+ "(?, 'NEW_GAME', 501, 'Alpha', null, null, null), " + "(?, 'NEW_GAME', 501, 'Alpha', null, null, null), "
+ "(?, 'ACHIEVEMENT', 501, 'Alpha', 'Win', null, null), " + "(?, 'ACHIEVEMENT', 501, 'Alpha', 'Win', null, null), "
@@ -82,6 +84,7 @@ class InboxControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.results[0].kind").value("DIGEST")) .andExpect(jsonPath("$.results[0].kind").value("DIGEST"))
.andExpect(jsonPath("$.results[0].newGames").value(2)) .andExpect(jsonPath("$.results[0].newGames").value(2))
.andExpect(jsonPath("$.results[0].read").value(false)) .andExpect(jsonPath("$.results[0].read").value(false))
.andExpect(jsonPath("$.results[0].profileName").doesNotExist())
.andExpect(jsonPath("$.results[1].displayName").value("Older")) .andExpect(jsonPath("$.results[1].displayName").value("Older"))
.andExpect(jsonPath("$.unread").value(2)) .andExpect(jsonPath("$.unread").value(2))
.andReturn().getResponse().getContentAsString(); .andReturn().getResponse().getContentAsString();
@@ -120,12 +123,15 @@ class InboxControllerTest extends AbstractIntegrationTest {
String body = mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isOk()) String body = mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isOk())
.andExpect(jsonPath("$.results.length()").value(3)) .andExpect(jsonPath("$.results.length()").value(3))
.andExpect(jsonPath("$.results[0].kind").value("NEW_GAME")) .andExpect(jsonPath("$.results[0].kind").value("NEW_GAME"))
.andExpect(jsonPath("$.results[0].appid").value(501))
.andExpect(jsonPath("$.results[2].appid").value(502))
.andExpect(jsonPath("$.results[1].itemName").value("Win")) .andExpect(jsonPath("$.results[1].itemName").value("Win"))
.andExpect(jsonPath("$.results[2].fromStatus").value("BACKLOG")) .andExpect(jsonPath("$.results[2].fromStatus").value("BACKLOG"))
.andReturn().getResponse().getContentAsString(); .andReturn().getResponse().getContentAsString();
assertThat(body).doesNotContain("appid", "subject", "userId"); assertThat(body).doesNotContain("subject", "userId");
mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(jsonPath("$.results[0].details").value(true)); mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(jsonPath("$.results[0].details").value(true))
.andExpect(jsonPath("$.results[0].profileName").value("SubjectNow"));
} }
@Test @Test
@@ -1,5 +1,6 @@
package core_service.modules.inbox.repo; package core_service.modules.inbox.repo;
import core_service.modules.inbox.domain.NoteDetail;
import core_service.shared.test.AbstractPersistenceTest; import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -71,4 +72,36 @@ class InboxRepositoryTest extends AbstractPersistenceTest {
jdbc.update("delete from inbox_note_detail"); jdbc.update("delete from inbox_note_detail");
assertThat(repository.findNewest(1, 10).get(0).details()).isFalse(); assertThat(repository.findNewest(1, 10).get(0).details()).isFalse();
} }
@Test
void findNewest_ProfileNameIsTheCurrentNameOfAPublicSubjectOnly() {
jdbc.update("delete from inbox_note");
jdbc.update("delete from account_name where user_id = 2");
jdbc.update("delete from public_user where user_id = 2");
jdbc.update("delete from blocked_user where user_id = 2");
jdbc.update("delete from deleted_user where user_id = 2");
note(1, 1, false);
jdbc.update("insert into account_name (user_id, display_name) values (2, 'Renamed')");
assertThat(repository.findNewest(1, 10).get(0).profileName()).isNull();
jdbc.update("insert into public_user (user_id) values (2)");
assertThat(repository.findNewest(1, 10).get(0).profileName()).isEqualTo("Renamed");
jdbc.update("insert into blocked_user (user_id) values (2)");
assertThat(repository.findNewest(1, 10).get(0).profileName()).isNull();
jdbc.update("delete from blocked_user where user_id = 2");
jdbc.update("insert into deleted_user (user_id) values (2)");
assertThat(repository.findNewest(1, 10).get(0).profileName()).isNull();
jdbc.update("delete from deleted_user where user_id = 2");
}
@Test
void findDetails_CarriesTheGameKey() {
jdbc.update("delete from inbox_note");
note(1, 1, false);
jdbc.update("insert into inbox_note_detail (note_id, kind, appid) values (1, 'NEW_GAME', 5)");
assertThat(repository.findDetails(1, 2)).extracting(NoteDetail::appid).containsExactly(5L);
}
} }
@@ -0,0 +1,91 @@
package core_service.modules.library.api;
import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens;
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;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import static org.hamcrest.Matchers.contains;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc
@ActiveProfiles(value = "prod", inheritProfiles = false)
class GamePreviewControllerTest extends AbstractIntegrationTest {
private static final long CALLER = 6201L;
private static final long KEPT = -1_960_001L;
private static final long MERGED = -1_960_002L;
private static final long UNKNOWN = -1_960_003L;
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
@AfterEach
void cleanUp() {
jdbc.update("delete from game_alias where from_appid = ?", MERGED);
jdbc.update("delete from game_time_to_beat where appid = ?", KEPT);
jdbc.update("delete from game_mood where appid = ?", KEPT);
jdbc.update("delete from steam_game where appid in (?, ?)", KEPT, MERGED);
}
private void seed() {
jdbc.update("insert into steam_game (appid, name, image_url) values (?, 'Hades', 'http://img/h.png')", KEPT);
jdbc.update("insert into game_mood (appid, moods, genres, checked_at) values (?, '{}', '{Roguelike,Action}', now())", KEPT);
jdbc.update("insert into game_time_to_beat (appid, normally_minutes, completely_minutes, fetched_at) "
+ "values (?, 600, 1200, now())", KEPT);
}
private ResultActions preview(long appid) throws Exception {
return mockMvc.perform(get("/api/library/games/" + appid + "/preview")
.header("Authorization", "Bearer " + ContractTokens.loginToken(CALLER)));
}
@Test
void preview_OfAGameTheCallerDoesNotOwn_HasCatalogDataOnly() throws Exception {
seed();
preview(KEPT).andExpect(status().isOk())
.andExpect(jsonPath("$.appid").value(KEPT))
.andExpect(jsonPath("$.name").value("Hades"))
.andExpect(jsonPath("$.imageUrl").value("http://img/h.png"))
.andExpect(jsonPath("$.genres", contains("Roguelike", "Action")))
.andExpect(jsonPath("$.timeToBeat.normallyMinutes").value(600))
.andExpect(jsonPath("$.timeToBeat.completelyMinutes").value(1200))
.andExpect(jsonPath("$.status").doesNotExist());
}
@Test
void preview_OfAMergedKey_AnswersWithTheKeptKey() throws Exception {
seed();
jdbc.update("insert into steam_game (appid, name) values (?, 'Old Hades')", MERGED);
jdbc.update("insert into game_alias (from_appid, to_appid) values (?, ?)", MERGED, KEPT);
preview(MERGED).andExpect(status().isOk())
.andExpect(jsonPath("$.appid").value(KEPT))
.andExpect(jsonPath("$.name").value("Hades"));
}
@Test
void preview_OfAnUnknownKey_Is404() throws Exception {
preview(UNKNOWN).andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
}
@Test
void preview_WithoutLogin_Is401() throws Exception {
mockMvc.perform(get("/api/library/games/" + KEPT + "/preview")).andExpect(status().isUnauthorized());
}
}
@@ -91,6 +91,16 @@ class LibraryControllerActivityTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.recentGames[1].imageUrl").doesNotExist()); .andExpect(jsonPath("$.recentGames[1].imageUrl").doesNotExist());
} }
@Test
void activity_ADayCountsAtMost24Hours() throws Exception {
playtimeDayRepository.addMinutes(USER_ID, 220L, today, 1440);
playtimeDayRepository.addMinutes(USER_ID, 620L, today, 1440);
mockMvc.perform(get("/api/library/activity").param("days", "7"))
.andExpect(jsonPath("$.days[6].minutes").value(1440))
.andExpect(jsonPath("$.totalMinutes").value(1440));
}
@Test @Test
void activity_RecentGamesAreOneEntryPerSetWithTheSumOfItsEditions() throws Exception { void activity_RecentGamesAreOneEntryPerSetWithTheSumOfItsEditions() throws Exception {
jdbc.update("update user_game set playtime_2weeks = 50 where user_id = ? and appid = 700", USER_ID); jdbc.update("update user_game set playtime_2weeks = 50 where user_id = ? and appid = 700", USER_ID);
@@ -0,0 +1,69 @@
package core_service.modules.library.api;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
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.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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.
*/
@AutoConfigureMockMvc
class LibraryControllerSortTest extends AbstractMockedAdaptersTest {
private static final long USER_ID = 1L;
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
steamGameRepository.save(new SteamGame(220L, "Half-Life 2", null, Instant.now()));
steamGameRepository.save(new SteamGame(620L, "portal 2", null, Instant.now()));
steamGameRepository.save(new SteamGame(700L, "Antichamber", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 620L, 900, Instant.now(), Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 700L, 0, Instant.now(), Instant.now()));
}
@Test
void sortByName_ListsTheNamesAZ() throws Exception {
mockMvc.perform(get("/api/library/games?sort=name,asc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].appid").value(700))
.andExpect(jsonPath("$.content[1].appid").value(220))
.andExpect(jsonPath("$.content[2].appid").value(620));
}
@Test
void sortByTimeToBeatAndLastPlayed_AreAccepted() throws Exception {
mockMvc.perform(get("/api/library/games?sort=timeToBeat,asc")).andExpect(status().isOk());
mockMvc.perform(get("/api/library/games?sort=lastPlayedAt,desc")).andExpect(status().isOk());
}
@Test
void sortByLastSeenAt_StillWorks() throws Exception {
mockMvc.perform(get("/api/library/games?sort=lastSeenAt,desc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content.length()").value(3));
}
}
@@ -28,6 +28,8 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -247,13 +249,16 @@ class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
awaitSync(); awaitSync();
long importId = reviewImportId(); long importId = reviewImportId();
playing(185, Instant.parse("2026-10-06T20:00:00Z")); Instant playedAgain = Instant.now().minus(Duration.ofHours(2));
playing(185, playedAgain);
worker.runSyncNow(1L, null, SyncTrigger.AUTO); worker.runSyncNow(1L, null, SyncTrigger.AUTO);
assertThat(jdbc.queryForMap("select kind, appid, amount from sync_change where user_id = 1")) assertThat(jdbc.queryForMap("select kind, appid, amount from sync_change where user_id = 1"))
.containsEntry("kind", "PLAYTIME").containsEntry("appid", -300L).containsEntry("amount", 60); .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", assertThat(jdbc.queryForObject("select minutes from user_game_playtime_day where user_id = 1 and appid = -300",
Integer.class)).isEqualTo(60); Integer.class)).isEqualTo(60);
assertThat(jdbc.queryForObject("select day from user_game_playtime_day where user_id = 1 and appid = -300",
LocalDate.class)).isEqualTo(LocalDate.ofInstant(playedAgain, ZoneId.of("Europe/Berlin")));
assertThat(jdbc.queryForMap("select playtime_minutes, playtime_2weeks from user_game where appid = -300")) assertThat(jdbc.queryForMap("select playtime_minutes, playtime_2weeks from user_game where appid = -300"))
.containsEntry("playtime_minutes", 185).containsEntry("playtime_2weeks", 60); .containsEntry("playtime_minutes", 185).containsEntry("playtime_2weeks", 60);
assertThat(jdbc.queryForObject("select count(*) from game_import", Integer.class)).isEqualTo(1); assertThat(jdbc.queryForObject("select count(*) from game_import", Integer.class)).isEqualTo(1);
@@ -416,4 +416,95 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
statGames(ticket(USER_ID), "STATUS", "NOPE").andExpect(status().isBadRequest()) statGames(ticket(USER_ID), "STATUS", "NOPE").andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BAD_ARG")); .andExpect(jsonPath("$.code").value("BAD_ARG"));
} }
private ResultActions publicGame(String ticket, long appid) throws Exception {
return mockMvc.perform(get("/api/public/profiles/library/games/" + appid).header("X-Profile-Ticket", ticket));
}
private void achievementsOf220() {
schemaMetaRepository.save(new GameAchievementSchemaMeta(220L, 4, true, Instant.now()));
userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 220L, Instant.now(), true, 1, 300));
}
@Test
void publicGame_AllSectionsShown_HasStatusPlaytimeAndAchievements() throws Exception {
achievementsOf220();
publicGame(ticket(USER_ID), 220L).andExpect(status().isOk())
.andExpect(jsonPath("$.appid").value(220))
.andExpect(jsonPath("$.name").value("Half-Life 2"))
.andExpect(jsonPath("$.status").value("PLAYING"))
.andExpect(jsonPath("$.playtimeMinutes").value(300))
.andExpect(jsonPath("$.achievementsUnlocked").value(1))
.andExpect(jsonPath("$.achievementsTotal").value(4))
.andExpect(jsonPath("$.completionPercent").value(25.0))
.andExpect(jsonPath("$.notes").doesNotExist())
.andExpect(jsonPath("$.rating").doesNotExist());
}
@Test
void publicGame_SummaryHidden_HasNoPlaytimeAndNoAchievementsButTheStatus() throws Exception {
achievementsOf220();
publicGame(ticket(USER_ID, List.of(), List.of("STATUS")), 220L).andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PLAYING"))
.andExpect(jsonPath("$.playtimeMinutes").value(nullValue()))
.andExpect(jsonPath("$.achievementsUnlocked").value(nullValue()))
.andExpect(jsonPath("$.achievementsTotal").value(nullValue()))
.andExpect(jsonPath("$.completionPercent").value(nullValue()));
}
@Test
void publicGame_StatusSectionsHidden_HasNoStatusButThePlaytime() throws Exception {
publicGame(ticket(USER_ID, List.of(), List.of("SUMMARY")), 220L).andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(nullValue()))
.andExpect(jsonPath("$.playtimeMinutes").value(300));
}
@Test
void publicGame_OnlyStatsShown_Is404() throws Exception {
publicGame(ticket(USER_ID, List.of(), List.of("STATS")), 220L).andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
}
@Test
void publicGame_HiddenRemovedAndNotOwned_AnswerTheSame404() throws Exception {
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = 700", USER_ID);
String notOwned = publicGame(ticket(USER_ID), 12345L).andExpect(status().isNotFound())
.andReturn().getResponse().getContentAsString();
for (long appid : new long[]{700L, 800L, 99L}) {
String body = publicGame(ticket(USER_ID), appid).andExpect(status().isNotFound())
.andReturn().getResponse().getContentAsString();
org.junit.jupiter.api.Assertions.assertEquals(notOwned, body, "appid " + appid);
}
}
@Test
void publicGame_OnlyPlayingShown_AGameThatIsNotPlayingIs404() throws Exception {
String notOwned = publicGame(ticket(USER_ID, List.of(), List.of("PLAYING")), 12345L)
.andExpect(status().isNotFound()).andReturn().getResponse().getContentAsString();
String completed = publicGame(ticket(USER_ID, List.of(), List.of("PLAYING")), 620L)
.andExpect(status().isNotFound()).andReturn().getResponse().getContentAsString();
assertEquals(notOwned, completed);
}
@Test
void publicGame_SteamGameWithoutSchemaButWithPsnTrophies_Is200() throws Exception {
psnTrophyRepository.saveSet(USER_ID, 220L, new PsnTrophySet("NPWR1_00", "trophy2", "01.00", "Set", null, "PS5",
50, 0, 0, 0, 30, false, Instant.now()));
publicGame(ticket(USER_ID), 220L).andExpect(status().isOk())
.andExpect(jsonPath("$.achievementsUnlocked").value(30))
.andExpect(jsonPath("$.achievementsTotal").value(50));
}
@Test
void publicGame_WithoutOrWithABadTicket_Is401() throws Exception {
mockMvc.perform(get("/api/public/profiles/library/games/220")).andExpect(status().isUnauthorized());
publicGame("garbage", 220L).andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("INVALID_TICKET"));
}
} }
@@ -143,6 +143,20 @@ class GameMergeRepositoryTakeoverTest extends AbstractPersistenceTest {
.containsExactly("100:10"); .containsExactly("100:10");
} }
@Test
void rekeyUserGame_DayBothKeysHave_StaysUnder24Hours() {
game(USER, MANUAL, "MANUAL", "PC");
for (long appid : new long[]{MANUAL, STEAM}) {
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 1000)",
USER, appid);
}
repository.rekeyUserGame(USER, MANUAL, STEAM);
assertThat(jdbc.queryForList("select appid || ':' || minutes from user_game_playtime_day", String.class))
.containsExactly("100:1440");
}
@Test @Test
void rekeyUserGame_KindHeldOnTarget_StaysOnOldRef() { void rekeyUserGame_KindHeldOnTarget_StaysOnOldRef() {
game(USER, MANUAL, "MANUAL", "PC"); game(USER, MANUAL, "MANUAL", "PC");
@@ -36,7 +36,8 @@ class OwnedGamesQueryGuardTest extends AbstractPersistenceTest {
private static final String OLD_FROM = "from UserGame ug join SteamGame sg on sg.appid = ug.appid "; private static final String OLD_FROM = "from UserGame ug join SteamGame sg on sg.appid = ug.appid ";
private static final String OLD_SELECT = "select ug " + OLD_FROM private static final String OLD_SELECT = "select ug " + OLD_FROM
+ "left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid "; + "left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid "
+ "left join GameTimeToBeat tb on tb.appid = ug.appid ";
private static final String OLD_COUNT = "select count(ug) " + OLD_FROM; private static final String OLD_COUNT = "select count(ug) " + OLD_FROM;
private static final String OLD_WHERE = "where ug.userId = :userId and ug.removedAt is null " + private static final String OLD_WHERE = "where ug.userId = :userId and ug.removedAt is null " +
"and ((:hidden = true and ug.hiddenAt is not null) or (:hidden = false and ug.hiddenAt is null)) " + "and ((:hidden = true and ug.hiddenAt is not null) or (:hidden = false and ug.hiddenAt is null)) " +
@@ -55,7 +56,9 @@ class OwnedGamesQueryGuardTest extends AbstractPersistenceTest {
private static final List<Sort> SORTS = List.of( private static final List<Sort> SORTS = List.of(
Sort.by(Sort.Direction.DESC, "playtimeMinutes"), Sort.by(Sort.Direction.ASC, "playtimeMinutes"), Sort.by(Sort.Direction.DESC, "playtimeMinutes"), Sort.by(Sort.Direction.ASC, "playtimeMinutes"),
Sort.by(Sort.Direction.DESC, "lastSeenAt"), Sort.by(Sort.Direction.DESC, "firstSeenAt"), Sort.by(Sort.Direction.DESC, "lastSeenAt"), Sort.by(Sort.Direction.DESC, "firstSeenAt"),
Sort.by(Sort.Direction.DESC, "rating"), Sort.by(Sort.Direction.DESC, "priority")); Sort.by(Sort.Direction.DESC, "rating"), Sort.by(Sort.Direction.DESC, "priority"),
Sort.by(Sort.Direction.ASC, "name"), Sort.by(Sort.Direction.DESC, "lastPlayedAt"),
Sort.by(Sort.Direction.ASC, "timeToBeat"));
@Autowired @Autowired
private UserGameRepository userGameRepository; private UserGameRepository userGameRepository;
@@ -0,0 +1,71 @@
package core_service.modules.library.repo;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.ConnectionCallback;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.init.ScriptUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* V49 runs on a database that already ran it once: the test seeds the old rows and runs the file again.
*/
class PsnDayRowsMigrationTest extends AbstractPersistenceTest {
private static final long USER = 9L;
@Autowired
private JdbcTemplate jdbc;
private void game(long appid, String source, String lastPlayedAt) {
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, platform, playtime_minutes) "
+ "values (?, ?, 'PLAYING', ?, 'PS5', 5)", USER, appid, source);
jdbc.update("""
insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes, last_played_at)
values (?, ?, 'LINKED', ?, 'PS5', 5, ?::timestamptz)""", USER, "c" + appid, appid, lastPlayedAt);
}
private void day(long appid, String day) {
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, ?::date, ?, 60)",
USER, day, appid);
}
private void migrate() {
// The JdbcTemplate connection is the one of the test transaction; a pool connection would not see the seed.
jdbc.execute((ConnectionCallback<Void>) connection -> {
ScriptUtils.executeSqlScript(connection,
new ClassPathResource("db/migration/V49__Psn_Day_Rows_After_Last_Played.sql"));
return null;
});
}
private List<String> days(long appid) {
return jdbc.queryForList("select day::text from user_game_playtime_day where user_id = ? and appid = ? order by day",
String.class, USER, appid);
}
@Test
void psnRowsMoreThanADayAfterTheLastPlayedDay_AreDeleted() {
game(-10, "PSN", "2025-06-01T10:00:00Z");
game(-11, "STEAM", "2025-06-01T10:00:00Z");
game(-12, "PSN", null);
for (long appid : new long[]{-10, -11, -12}) {
day(appid, "2025-06-01");
day(appid, "2025-06-02");
day(appid, "2026-10-08");
}
migrate();
migrate();
assertThat(days(-10)).containsExactly("2025-06-01", "2025-06-02");
assertThat(days(-11)).containsExactly("2025-06-01", "2025-06-02", "2026-10-08");
assertThat(days(-12)).containsExactly("2025-06-01", "2025-06-02", "2026-10-08");
}
}
@@ -67,6 +67,15 @@ class PsnTitleRepositoryTest extends AbstractPersistenceTest {
Map.entry("t2", new PsnTitle("t2", PsnTitle.State.DISMISSED, null))); Map.entry("t2", new PsnTitle("t2", PsnTitle.State.DISMISSED, null)));
} }
@Test
void findAll_ReadsTheStoredPlay() {
Instant played = Instant.parse("2026-10-05T18:00:00Z");
repository.link(8L, "c1", -1L);
repository.savePlays(8L, List.of(new TitlePlay("c1", null, "PS5", 120, played)));
assertThat(repository.findAll(8L)).containsEntry("c1", new PsnTitle("c1", PsnTitle.State.LINKED, -1L, 120, played));
}
@Test @Test
void findAll_ReturnsOnlyTheUsersTitles() { void findAll_ReturnsOnlyTheUsersTitles() {
repository.link(3L, "c1", -1L); repository.link(3L, "c1", -1L);
@@ -348,6 +348,35 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
assertEquals(List.of(220L, 700L, 380L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "priority"))); assertEquals(List.of(220L, 700L, 380L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "priority")));
} }
@Test
void querySort_NameAsc_IsCaseInsensitiveAZ() {
steamGameRepository.saveAndFlush(new SteamGame(620L, "portal 2", null, Instant.now()));
assertEquals(List.of(700L, 220L, 380L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "name")));
assertEquals(List.of(620L, 380L, 220L, 700L), sortedAppids(Sort.by(Sort.Direction.DESC, "name")));
}
@Test
void querySort_LastPlayedDesc_PutsNeverPlayedLast() {
Instant now = Instant.now();
userGameRepository.setLastPlayedAt(USER_ID, 380L, now.minusSeconds(3600));
userGameRepository.setLastPlayedAt(USER_ID, 700L, now);
assertEquals(List.of(700L, 380L, 220L, 620L), sortedAppids(Sort.by(Sort.Direction.DESC, "lastPlayedAt")));
assertEquals(List.of(380L, 700L, 220L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "lastPlayedAt")));
}
@Test
void querySort_TimeToBeatAsc_PutsUnknownLast() {
gameTimeToBeatRepository.saveAll(List.of(
new GameTimeToBeat(620L, 300, 480, 900, Instant.now()),
new GameTimeToBeat(220L, 500, 700, 1000, Instant.now()),
new GameTimeToBeat(380L, 100, null, 200, Instant.now())));
assertEquals(List.of(620L, 220L, 380L, 700L), sortedAppids(Sort.by(Sort.Direction.ASC, "timeToBeat")));
assertEquals(List.of(220L, 620L, 380L, 700L), sortedAppids(Sort.by(Sort.Direction.DESC, "timeToBeat")));
}
private void setBacklog(long appid, Integer rating, GamePriority priority) { private void setBacklog(long appid, Integer rating, GamePriority priority) {
UserGame game = userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow(); UserGame game = userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow();
game.setRating(rating); game.setRating(rating);
@@ -262,6 +262,18 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
new UserStats.TagCount("ACTION", 1, 300)); new UserStats.TagCount("ACTION", 1, 300));
} }
@Test
void longestDay_IsCappedAt24Hours() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 300, GameStatus.PLAYING, Instant.now()),
game(620L, 900, GameStatus.PLAYING, Instant.now())));
addDay(220L, LocalDate.of(2026, 7, 1), 1440);
addDay(620L, LocalDate.of(2026, 7, 1), 1440);
addDay(620L, LocalDate.of(2026, 7, 2), 100);
assertThat(repository.longestDay(USER_ID)).contains(new DayMinutes(LocalDate.of(2026, 7, 1), 1440));
}
@Test @Test
void longestDay_SumsAllGamesOfTheDay() { void longestDay_SumsAllGamesOfTheDay() {
userGameRepository.saveAllAndFlush(List.of( userGameRepository.saveAllAndFlush(List.of(
@@ -535,6 +547,17 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.games(USER_ID, StatKey.PERFECT, List.of(220L, 620L), BERLIN, 0, 10)).hasSize(1); assertThat(repository.games(USER_ID, StatKey.PERFECT, List.of(220L, 620L), BERLIN, 0, 10)).hasSize(1);
} }
@Test
void games_LongestDay_CapsASetAt24Hours() {
seedSet();
LocalDate day = LocalDate.of(2026, 7, 1);
addDay(220L, day, 1440);
addDay(620L, day, 1440);
assertThat(repository.games(USER_ID, StatKey.LONGEST_DAY, day, BERLIN, 0, 10))
.extracting(UserStats.StatGame::minutes).containsExactly(1440L);
}
@Test @Test
void games_LongestDayListsASetOnceWithTheSumOfItsEditions() { void games_LongestDayListsASetOnceWithTheSumOfItsEditions() {
seedSet(); seedSet();
@@ -833,9 +833,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Test @Test
void runSync_PsnStepRunsBeforeTheTransaction_AndApplyAfterTheGamesAreSaved_ItsChangesJoinTheResult() { void runSync_PsnStepRunsBeforeTheTransaction_AndApplyAfterTheGamesAreSaved_ItsChangesJoinTheResult() {
PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(), false); PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(), List.of(), false);
when(psnSyncStep.run(USER_ID)).thenReturn(prepared); when(psnSyncStep.run(USER_ID)).thenReturn(prepared);
when(psnSyncStep.apply(eq(USER_ID), eq(prepared), any(), any())) when(psnSyncStep.apply(eq(USER_ID), eq(prepared), any(), any(), any()))
.thenReturn(List.of(SyncChange.added(-9L), SyncChange.playtime(-8L, 30))); .thenReturn(List.of(SyncChange.added(-9L), SyncChange.playtime(-8L, 30)));
owned(100); owned(100);
steamReturns(game(160, 60), STEAM_ID); steamReturns(game(160, 60), STEAM_ID);
@@ -845,7 +845,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
InOrder order = inOrder(steamApiService, psnSyncStep, userGameRepository, syncChangeRepository); InOrder order = inOrder(steamApiService, psnSyncStep, userGameRepository, syncChangeRepository);
order.verify(psnSyncStep).run(USER_ID); order.verify(psnSyncStep).run(USER_ID);
order.verify(userGameRepository).findByUserId(USER_ID); order.verify(userGameRepository).findByUserId(USER_ID);
order.verify(psnSyncStep).apply(eq(USER_ID), eq(prepared), any(), any()); order.verify(psnSyncStep).apply(eq(USER_ID), eq(prepared), any(), any(), any());
order.verify(syncChangeRepository).replace(eq(USER_ID), any(), eq(SyncTrigger.AUTO), eq(0), anyList()); order.verify(syncChangeRepository).replace(eq(USER_ID), any(), eq(SyncTrigger.AUTO), eq(0), anyList());
assertThat(replacedChanges(SyncTrigger.AUTO, 0)).containsExactlyInAnyOrder(SyncChange.playtime(220L, 60), assertThat(replacedChanges(SyncTrigger.AUTO, 0)).containsExactlyInAnyOrder(SyncChange.playtime(220L, 60),
SyncChange.added(-9L), SyncChange.playtime(-8L, 30)); SyncChange.added(-9L), SyncChange.playtime(-8L, 30));
@@ -853,9 +853,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Test @Test
void runSync_PsnBaselineAdds_AreCountedNotListed() { void runSync_PsnBaselineAdds_AreCountedNotListed() {
PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(), true); PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(), List.of(), true);
when(psnSyncStep.run(USER_ID)).thenReturn(prepared); when(psnSyncStep.run(USER_ID)).thenReturn(prepared);
when(psnSyncStep.apply(eq(USER_ID), eq(prepared), any(), any())) when(psnSyncStep.apply(eq(USER_ID), eq(prepared), any(), any(), any()))
.thenReturn(List.of(SyncChange.added(-9L), SyncChange.playtime(-8L, 30))); .thenReturn(List.of(SyncChange.added(-9L), SyncChange.playtime(-8L, 30)));
owned(100); owned(100);
steamReturns(game(100, 0), STEAM_ID); steamReturns(game(100, 0), STEAM_ID);
@@ -867,9 +867,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Test @Test
void runSync_WithoutSteamId_RunsOnlyThePsnPart_AndPublishesTheActivityEvent() { void runSync_WithoutSteamId_RunsOnlyThePsnPart_AndPublishesTheActivityEvent() {
PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(), false); PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(), List.of(), false);
when(psnSyncStep.run(USER_ID)).thenReturn(prepared); when(psnSyncStep.run(USER_ID)).thenReturn(prepared);
when(psnSyncStep.apply(eq(USER_ID), eq(prepared), any(), any())).thenReturn(List.of(SyncChange.added(-9L))); when(psnSyncStep.apply(eq(USER_ID), eq(prepared), any(), any(), any())).thenReturn(List.of(SyncChange.added(-9L)));
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(null), any(), any())).thenReturn(1); when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(null), any(), any())).thenReturn(1);
worker.runSync(USER_ID, null, SyncTrigger.AUTO); worker.runSync(USER_ID, null, SyncTrigger.AUTO);
@@ -26,6 +26,8 @@ import org.springframework.transaction.PlatformTransactionManager;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -34,6 +36,7 @@ import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
@@ -48,6 +51,8 @@ class PsnSyncStepTest extends AbstractUnitTest {
private static final long USER = 1L; private static final long USER = 1L;
private static final Instant LAST_PLAYED = Instant.parse("2026-10-05T18:30:00Z"); private static final Instant LAST_PLAYED = Instant.parse("2026-10-05T18:30:00Z");
private static final LocalDate DAY = LocalDate.of(2026, 10, 7); private static final LocalDate DAY = LocalDate.of(2026, 10, 7);
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
private static final Instant OLDER = Instant.parse("2026-10-01T10:00:00Z");
@Mock @Mock
private PsnAccountService accountService; private PsnAccountService accountService;
@@ -89,6 +94,14 @@ class PsnSyncStepTest extends AbstractUnitTest {
return new GameImport(id, USER, GameImport.Source.PSN, status, 1, 1, 0, 0, 0, false, 0, null, now, now, null); return new GameImport(id, USER, GameImport.Source.PSN, status, 1, 1, 0, 0, 0, false, 0, null, now, now, null);
} }
private PsnSyncStep.Prepared runWithStored(Integer minutes, Instant lastPlayedAt, PsnApiService.Title title) {
fetches(title);
when(psnTitleRepository.findAll(USER)).thenReturn(Map.of("c7",
new PsnTitle("c7", PsnTitle.State.LINKED, -1L, minutes, lastPlayedAt)));
when(mergeRepository.findTargets(List.of(-1L))).thenReturn(Map.of());
return step.run(USER);
}
@Test @Test
void run_ExactMatchOnThePlatform_AddsTheGame_LinksIt_SetsLastPlayed_AndIsABaseline() { void run_ExactMatchOnThePlatform_AddsTheGame_LinksIt_SetsLastPlayed_AndIsABaseline() {
fetches(ghost()); fetches(ghost());
@@ -191,11 +204,42 @@ class PsnSyncStepTest extends AbstractUnitTest {
PsnSyncStep.Prepared prepared = step.run(USER); PsnSyncStep.Prepared prepared = step.run(USER);
assertThat(prepared.playtimes()).containsExactly(new PsnSyncStep.Playtime(-2L, 120, LAST_PLAYED)); assertThat(prepared.playtimes()).containsExactly(new PsnSyncStep.Playtime(-2L, 120, LAST_PLAYED, 0));
assertThat(prepared.baseline()).isFalse(); assertThat(prepared.baseline()).isFalse();
verifyNoInteractions(manualGameService); verifyNoInteractions(manualGameService);
} }
@Test
void run_LinkedTitlePlayedSinceTheLastSync_GivesItsOwnGrowth() {
PsnSyncStep.Prepared prepared = runWithStored(60, OLDER, ghost());
assertThat(prepared.playtimes()).containsExactly(new PsnSyncStep.Playtime(-1L, 120, LAST_PLAYED, 60));
}
@Test
void run_LinkedTitleNotPlayedSinceTheLastSync_GivesNoGrowth() {
PsnSyncStep.Prepared prepared = runWithStored(60, LAST_PLAYED, ghost());
assertThat(prepared.playtimes()).extracting(PsnSyncStep.Playtime::played).containsExactly(0);
}
@Test
void run_LinkedTitleWithoutStoredPlay_IsAStartPoint() {
PsnSyncStep.Prepared prepared = runWithStored(null, null, ghost());
assertThat(prepared.playtimes()).extracting(PsnSyncStep.Playtime::played).containsExactly(0);
}
@Test
void run_LinkedTitleWithoutLastPlayed_GivesNoGrowth() {
PsnApiService.Title noDate = new PsnApiService.Title("PPSA1_00", "Ghost of Yotei", "ps5_native_game",
Duration.ofMinutes(120), 7L, null);
PsnSyncStep.Prepared prepared = runWithStored(60, OLDER, noDate);
assertThat(prepared.playtimes()).extracting(PsnSyncStep.Playtime::played).containsExactly(0);
}
@Test @Test
void run_SavesThePlayOfALinkedAndASteamOwnedTitle_AndGivesNoPlaytimeToTheSteamRow() { void run_SavesThePlayOfALinkedAndASteamOwnedTitle_AndGivesNoPlaytimeToTheSteamRow() {
PsnApiService.Title other = new PsnApiService.Title("PPSA2_00", "Other", "ps5_native_game", Duration.ofMinutes(30), 8L, null); PsnApiService.Title other = new PsnApiService.Title("PPSA2_00", "Other", "ps5_native_game", Duration.ofMinutes(30), 8L, null);
@@ -206,9 +250,8 @@ class PsnSyncStepTest extends AbstractUnitTest {
PsnSyncStep.Prepared prepared = step.run(USER); PsnSyncStep.Prepared prepared = step.run(USER);
ArgumentCaptor<List<TitlePlay>> plays = ArgumentCaptor.captor(); verify(psnTitleRepository, never()).savePlays(eq(USER), anyList());
verify(psnTitleRepository).savePlays(eq(USER), plays.capture()); assertThat(prepared.plays()).containsExactlyInAnyOrder(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED),
assertThat(plays.getValue()).containsExactlyInAnyOrder(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED),
new TitlePlay("c8", null, "PS5", 30, null)); new TitlePlay("c8", null, "PS5", 30, null));
assertThat(prepared.playtimes()).extracting(PsnSyncStep.Playtime::appid).containsExactly(-1L); assertThat(prepared.playtimes()).extracting(PsnSyncStep.Playtime::appid).containsExactly(-1L);
} }
@@ -221,11 +264,10 @@ class PsnSyncStepTest extends AbstractUnitTest {
when(manualGameService.addGame(eq(USER), eq(game), eq("PS5"), eq(GameStatus.PLAYING), eq(120), when(manualGameService.addGame(eq(USER), eq(game), eq("PS5"), eq(GameStatus.PLAYING), eq(120),
eq(GameSource.PSN), isNull())).thenReturn(new ManualGameService.AddResult.Added(-1L)); eq(GameSource.PSN), isNull())).thenReturn(new ManualGameService.AddResult.Added(-1L));
step.run(USER); PsnSyncStep.Prepared prepared = step.run(USER);
ArgumentCaptor<List<TitlePlay>> plays = ArgumentCaptor.captor(); assertThat(prepared.plays()).containsExactly(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED));
verify(psnTitleRepository).savePlays(eq(USER), plays.capture()); assertThat(prepared.playtimes()).isEmpty();
assertThat(plays.getValue()).containsExactly(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED));
} }
@Test @Test
@@ -297,20 +339,29 @@ class PsnSyncStepTest extends AbstractUnitTest {
} }
private PsnSyncStep.Prepared prepared(PsnSyncStep.Playtime... playtimes) { private PsnSyncStep.Prepared prepared(PsnSyncStep.Playtime... playtimes) {
return new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(playtimes), false); return new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(playtimes), List.of(), false);
} }
@Test @Test
void apply_GrowthIsBooked_AsChangeAndDayMinutes_WithTwoWeeksFromTheHistoryAndLastPlayed() { void apply_SavesThePlaysOfTheRun() {
List<TitlePlay> plays = List.of(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED));
step.apply(USER, new PsnSyncStep.Prepared(List.of(), List.of(), plays, false), Instant.now(), DAY, BERLIN);
verify(psnTitleRepository).savePlays(USER, plays);
}
@Test
void apply_PlayedMinutes_AreBookedAsChangeAndDayMinutes_WithTwoWeeksFromTheHistoryAndLastPlayed() {
UserGame game = psnRow(-1L, 30); UserGame game = psnRow(-1L, 30);
when(playtimeDayRepository.minutesPerGameSince(eq(USER), eq(Set.of(-1L)), eq(DAY.minusDays(13)))) when(playtimeDayRepository.minutesPerGameSince(eq(USER), eq(Set.of(-1L)), eq(DAY.minusDays(13))))
.thenReturn(List.of(new AppidMinutes(-1L, 75))); .thenReturn(List.of(new AppidMinutes(-1L, 75)));
Instant now = Instant.now(); Instant now = Instant.now();
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 90, LAST_PLAYED)), now, DAY); List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 90, LAST_PLAYED, 60)), now, DAY, BERLIN);
assertThat(changes).containsExactly(SyncChange.added(-9L), SyncChange.playtime(-1L, 60)); assertThat(changes).containsExactly(SyncChange.added(-9L), SyncChange.playtime(-1L, 60));
verify(playtimeDayRepository).addMinutes(USER, -1L, DAY, 60); verify(playtimeDayRepository).addMinutes(USER, -1L, LocalDate.of(2026, 10, 5), 60);
assertThat(game.getPlaytimeMinutes()).isEqualTo(90); assertThat(game.getPlaytimeMinutes()).isEqualTo(90);
assertThat(game.getPlaytime2Weeks()).isEqualTo(75); assertThat(game.getPlaytime2Weeks()).isEqualTo(75);
assertThat(game.getLastPlayedAt()).isEqualTo(LAST_PLAYED); assertThat(game.getLastPlayedAt()).isEqualTo(LAST_PLAYED);
@@ -322,7 +373,7 @@ class PsnSyncStepTest extends AbstractUnitTest {
UserGame game = psnRow(-1L, 90); UserGame game = psnRow(-1L, 90);
game.setLastPlayedAt(LAST_PLAYED); game.setLastPlayedAt(LAST_PLAYED);
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 60, null)), Instant.now(), DAY); List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 60, null, 0)), Instant.now(), DAY, BERLIN);
assertThat(changes).containsExactly(SyncChange.added(-9L)); assertThat(changes).containsExactly(SyncChange.added(-9L));
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt()); verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
@@ -339,9 +390,9 @@ class PsnSyncStepTest extends AbstractUnitTest {
removed.setRemovedAt(Instant.EPOCH); removed.setRemovedAt(Instant.EPOCH);
when(userGameRepository.findById(new UserGameId(USER, -3L))).thenReturn(Optional.empty()); when(userGameRepository.findById(new UserGameId(USER, -3L))).thenReturn(Optional.empty());
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 90, LAST_PLAYED), List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 90, LAST_PLAYED, 60),
new PsnSyncStep.Playtime(-2L, 90, LAST_PLAYED), new PsnSyncStep.Playtime(-3L, 90, LAST_PLAYED)), new PsnSyncStep.Playtime(-2L, 90, LAST_PLAYED, 60), new PsnSyncStep.Playtime(-3L, 90, LAST_PLAYED, 60)),
Instant.now(), DAY); Instant.now(), DAY, BERLIN);
assertThat(changes).containsExactly(SyncChange.added(-9L)); assertThat(changes).containsExactly(SyncChange.added(-9L));
assertThat(steam.getPlaytimeMinutes()).isEqualTo(30); assertThat(steam.getPlaytimeMinutes()).isEqualTo(30);
@@ -353,11 +404,51 @@ class PsnSyncStepTest extends AbstractUnitTest {
void apply_TwoTitlesOnOneGame_BookTheirSum_SoTheTotalNeverFlips() { void apply_TwoTitlesOnOneGame_BookTheirSum_SoTheTotalNeverFlips() {
UserGame game = psnRow(-1L, 600); UserGame game = psnRow(-1L, 600);
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 100, null), List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 100, LAST_PLAYED, 40),
new PsnSyncStep.Playtime(-1L, 500, null)), Instant.now(), DAY); new PsnSyncStep.Playtime(-1L, 500, OLDER, 60)), Instant.now(), DAY, BERLIN);
assertThat(game.getPlaytimeMinutes()).isEqualTo(600); assertThat(game.getPlaytimeMinutes()).isEqualTo(600);
assertThat(changes).containsExactly(SyncChange.added(-9L), SyncChange.playtime(-1L, 100));
verify(playtimeDayRepository).addMinutes(USER, -1L, LocalDate.of(2026, 10, 5), 100);
}
@Test
void apply_SecondTitleLinkedWithOldPlaytime_RaisesTheTotal_WritesNoDayRow() {
UserGame game = psnRow(-1L, 30);
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 400, LAST_PLAYED, 0)),
Instant.now(), DAY, BERLIN);
assertThat(game.getPlaytimeMinutes()).isEqualTo(400);
assertThat(changes).containsExactly(SyncChange.added(-9L)); assertThat(changes).containsExactly(SyncChange.added(-9L));
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt()); verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
} }
@Test
void apply_PlayedMinutes_AreBookedOnTheLastPlayedDayInTheZone() {
UserGame game = psnRow(-1L, 30);
List<SyncChange> changes = step.apply(USER, prepared(
new PsnSyncStep.Playtime(-1L, 90, Instant.parse("2026-10-05T22:30:00Z"), 60)), Instant.now(), DAY, BERLIN);
assertThat(changes).containsExactly(SyncChange.added(-9L), SyncChange.playtime(-1L, 60));
verify(playtimeDayRepository).addMinutes(USER, -1L, LocalDate.of(2026, 10, 6), 60);
assertThat(game.getPlaytimeMinutes()).isEqualTo(90);
}
// When the transaction never reaches apply (Steam gave no games) or rolls back, the next run sees the stored
// play as the baseline and books 0: the session never reaches user_game_playtime_day or sync_change.
@Test
void run_ApplyNeverRan_NextRunStillBooksThePlay() {
fetches(ghost());
Map<String, PsnTitle> stored = new HashMap<>(Map.of("c7", new PsnTitle("c7", PsnTitle.State.LINKED, -1L, 60, OLDER)));
when(psnTitleRepository.findAll(USER)).thenAnswer(invocation -> Map.copyOf(stored));
when(mergeRepository.findTargets(List.of(-1L))).thenReturn(Map.of());
step.run(USER); // the sync transaction returned before apply, or rolled back
PsnSyncStep.Prepared again = step.run(USER);
verify(psnTitleRepository, never()).savePlays(eq(USER), anyList());
assertThat(again.playtimes()).extracting(PsnSyncStep.Playtime::played).containsExactly(60);
}
} }