Public Access
Compare commits
7
Commits
dba4fedfa1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e11fae8e31 | ||
|
|
c42526bc16 | ||
|
|
4fbf572f32 | ||
|
|
6f2d7b4885 | ||
|
|
d3c9c7558c | ||
|
|
ec66bd5ead | ||
|
|
54946ef0eb |
No files matched your search
@@ -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:
|
||||
@@ -89,11 +89,6 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- testing -->
|
||||
<dependency>
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
# 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
|
||||
(status, rating, notes, priority), tracks playtime per day, syncs the wishlist and adds time-to-beat data from IGDB.
|
||||
Business service of **To Be Played**, a backlog tracker for your Steam and PlayStation libraries, plus games added
|
||||
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 |
|
||||
|---|---|
|
||||
| ToBePlayed-Auth | Users, login, settings, signs JWTs |
|
||||
| **ToBePlayed-Core** (this one) | Steam library, backlog, wishlist, stats; verifies JWTs |
|
||||
| ToBePlayed-Frontend | React single-page app |
|
||||
| ToBePlayed-Deployment | Docker Compose stack, env template, ROADMAP.md (history), plans |
|
||||
|
||||
## Features
|
||||
|
||||
- **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;
|
||||
the wishlist and the calls a page waits for once); Caffeine caches game counts and player summaries. 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. Resilience4j rate-limits every Steam call (2 per second) and retries only where a repeat is safe: owned games,
|
||||
achievement schema and player achievements up to 3 times (`steamApi`, never on a 4xx); player summary and wishlist
|
||||
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`
|
||||
can be taken over; its own finish then changes nothing.
|
||||
- **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`).
|
||||
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.
|
||||
|
||||
```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
|
||||
500, never an id in an answer) and gets one inbox note per followed user and batch (`/api/inbox`): a sync with its
|
||||
achievement run, or the hand changes of a 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`).
|
||||
500, never an id in an answer; `GET /api/follows/players?q=` finds public profiles by name, rate-limited) and gets
|
||||
one inbox note per followed user and batch (`/api/inbox`): a sync with its achievement run, or the hand changes of a
|
||||
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
|
||||
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
|
||||
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
|
||||
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,
|
||||
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
|
||||
@@ -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
|
||||
(`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).
|
||||
|
||||
```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,
|
||||
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
|
||||
@@ -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
|
||||
overwrites stored data. Summary: unlocked total, perfect games, average completion over started games, the 5
|
||||
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"
|
||||
dashboard banner.
|
||||
- **Playtime history**: one row per game and day (a day counts at most 24 h in the stats and the chart), for the
|
||||
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
|
||||
"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
|
||||
@@ -178,8 +192,8 @@ flowchart LR
|
||||
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,
|
||||
then after 30 days.
|
||||
- **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the
|
||||
feature is off and the rest works.
|
||||
- **Time to beat**: IGDB's `hastily`, `normally` and `completely` minutes (`game_time_to_beat`, V6). The Frontend labels
|
||||
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
|
||||
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
|
||||
@@ -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/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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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/{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 |
|
||||
@@ -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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -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/imports/{id}/undo-check` (`{games, changed}`), `/api/library/imports/{id}/games` (undo; `204`, `409 IMPORT_NOT_SAVED`) | JWT |
|
||||
| `POST` | `/api/library/sync` | JWT |
|
||||
| `GET` | `/api/library/games/{appid}` (detail; `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 |
|
||||
| `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 |
|
||||
@@ -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/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/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/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/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 |
|
||||
| `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/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/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` (`{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 |
|
||||
| `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 |
|
||||
@@ -300,9 +324,9 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
|
||||
- PostgreSQL 17, Flyway migrations
|
||||
- Resilience4j: retry and rate limiter for Steam, rate limiters for IGDB and speedrun.com
|
||||
- JJWT with Ed25519 public key
|
||||
- Spring Boot Actuator + Micrometer (Prometheus)
|
||||
- Spring Boot Actuator (`/actuator/health` only)
|
||||
- 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
|
||||
|
||||
@@ -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")]
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
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.
|
||||
`POST /api/library/sync` starts one at once, but at most every 60 seconds (else `429` with `Retry-After`).
|
||||
when the last success is older than 20 hours (`app.library.sync-ttl-hours`) and the last try older than 60 minutes
|
||||
(`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
|
||||
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
|
||||
@@ -463,18 +489,20 @@ At 01:30 `ImageRepairService` repairs the pictures reported through `POST /api/l
|
||||
|
||||
### 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
|
||||
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
|
||||
starts it again.
|
||||
- The follow-up pool (2 threads, no limit on the queue) runs the achievement runs after a sync and every speedrun
|
||||
refresh. They hold a thread for minutes because of the Steam and speedrun.com pace, so they do not take the page
|
||||
views' threads. A task is small (a user or a few appids), and none is dropped.
|
||||
- The follow-up pool (2 threads, no limit on the queue) runs the achievement runs after a sync and the speedrun
|
||||
refreshes (`@Async`). They hold a thread for minutes because of the Steam and speedrun.com pace, so they do not take
|
||||
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,
|
||||
import watchdog) run on the scheduler.
|
||||
Imports run in two lanes, `game-import-match` and `game-import-save`, 1 thread each, queue 1; `game_import` is their
|
||||
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
|
||||
flowchart LR
|
||||
@@ -482,10 +510,12 @@ flowchart LR
|
||||
Sync["sync request"] --> Pool
|
||||
Synced["sync, after commit"] --> FollowUp["follow-up pool<br/>2 threads"]
|
||||
View -->|"speedrun"| FollowUp
|
||||
FollowUp --> Drainer["speedrunExecutor<br/>1 thread"]
|
||||
Pool --> Apis["Steam, IGDB, speedrun.com"]
|
||||
FollowUp --> Apis
|
||||
Drainer --> Apis
|
||||
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
|
||||
@@ -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),
|
||||
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
|
||||
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:
|
||||
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).
|
||||
@@ -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`
|
||||
(`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`.
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
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:
|
||||
|
||||
@@ -867,7 +900,9 @@ Configuration comes from environment variables:
|
||||
| `STEAM_API_KEY` | Steam Web API key |
|
||||
| `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) |
|
||||
| `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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package core_service.modules.follow.api;
|
||||
|
||||
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.FollowResult;
|
||||
import core_service.modules.follow.domain.Follows;
|
||||
import core_service.modules.follow.domain.PlayerSearch;
|
||||
import core_service.modules.follow.service.FollowService;
|
||||
import core_service.shared.api.ErrorResponse;
|
||||
import core_service.shared.security.UserPrincipal;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
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.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
@@ -33,6 +37,20 @@ public class FollowController {
|
||||
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}")
|
||||
public ResponseEntity<?> follow(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable("displayName") @Size(max = 100) String displayName) {
|
||||
@@ -63,6 +81,6 @@ public class FollowController {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -8,9 +9,10 @@ import java.util.List;
|
||||
public record FollowsDto(List<Person> following, List<Person> followers) {
|
||||
|
||||
/**
|
||||
* @param id the follow row, to end it; not a user id
|
||||
* @param displayName null for a follower with a private profile
|
||||
* @param id the follow row, to end it; not a user id
|
||||
* @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;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* @param id the follow row, not a user id
|
||||
* @param displayName null for a follower with a private profile
|
||||
* @param id the follow row, not a user id
|
||||
* @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;
|
||||
|
||||
import core_service.modules.follow.domain.FollowEntry;
|
||||
import core_service.shared.sql.LikePattern;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -19,9 +21,13 @@ import java.util.Optional;
|
||||
public class FollowRepository {
|
||||
|
||||
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"),
|
||||
rs.getString("display_name"));
|
||||
private static final RowMapper<FollowEntry> ENTRY = (rs, i) -> {
|
||||
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;
|
||||
|
||||
@@ -41,6 +47,23 @@ public class FollowRepository {
|
||||
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.
|
||||
*
|
||||
@@ -91,7 +114,11 @@ public class FollowRepository {
|
||||
|
||||
public List<FollowEntry> findFollowing(long userId) {
|
||||
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
|
||||
where f.user_id = :userId
|
||||
order by lower(a.display_name), f.id
|
||||
@@ -103,7 +130,8 @@ public class FollowRepository {
|
||||
*/
|
||||
public List<FollowEntry> findFollowers(long userId) {
|
||||
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
|
||||
left join public_user p on p.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.Follows;
|
||||
import core_service.modules.follow.domain.PlayerSearch;
|
||||
import core_service.modules.follow.repo.FollowRepository;
|
||||
import core_service.shared.security.RequiresRole;
|
||||
import core_service.shared.security.Role;
|
||||
@@ -9,6 +10,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@@ -17,8 +19,10 @@ import java.util.Optional;
|
||||
public class FollowService {
|
||||
|
||||
static final int MAX_FOLLOWING = 500;
|
||||
static final int MIN_QUERY_LENGTH = 2;
|
||||
|
||||
private final FollowRepository followRepository;
|
||||
private final PlayerSearchLimiter limiter;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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) {
|
||||
Inbox inbox = inboxService.inbox(principal.getIdAsLong());
|
||||
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()))
|
||||
.toList(), inbox.unread()));
|
||||
}
|
||||
@@ -36,7 +36,7 @@ public class InboxController {
|
||||
public ResponseEntity<?> details(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id) {
|
||||
return inboxService.details(principal.getIdAsLong(), id)
|
||||
.<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()))
|
||||
.toList())))
|
||||
.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 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
|
||||
*/
|
||||
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) {
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,12 @@ import java.util.List;
|
||||
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 itemName ACHIEVEMENT only: the achievement or trophy name
|
||||
* @param fromStatus 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 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
|
||||
*/
|
||||
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) {
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
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("""
|
||||
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,
|
||||
(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)
|
||||
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
|
||||
@@ -33,7 +37,7 @@ public class InboxRepository {
|
||||
order by n.created_at desc, n.id desc
|
||||
limit :limit""", new MapSqlParameterSource("userId", userId).addValue("limit", limit),
|
||||
(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.getTimestamp("created_at").toInstant(), rs.getTimestamp("read_at") != null,
|
||||
rs.getBoolean("details")));
|
||||
@@ -74,12 +78,12 @@ public class InboxRepository {
|
||||
*/
|
||||
public List<NoteDetail> findDetails(long noteId, long subjectUserId) {
|
||||
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
|
||||
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)
|
||||
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")));
|
||||
}
|
||||
|
||||
|
||||
@@ -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.CatalogGameDto;
|
||||
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.CorrectionMarkDto;
|
||||
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.GameSource;
|
||||
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.Mood;
|
||||
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_BACKLOG_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 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}")
|
||||
public ResponseEntity<?> getGameDetail(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable("appid") Long appid,
|
||||
@@ -367,13 +378,16 @@ public class LibraryController {
|
||||
}
|
||||
|
||||
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(),
|
||||
d.genres(), t == null ? null : new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(),
|
||||
t.getNormallyMinutes(), t.getCompletelyMinutes()),
|
||||
d.genres(), toDto(d.timeToBeat()),
|
||||
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) {
|
||||
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.ResponseEntity;
|
||||
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.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -38,6 +39,17 @@ public class PublicProfileController {
|
||||
.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")
|
||||
public ResponseEntity<?> getPublicStatGames(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket,
|
||||
@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;
|
||||
|
||||
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
|
||||
* 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 }
|
||||
}
|
||||
@@ -264,6 +264,13 @@ public class GameMergeRepository {
|
||||
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
|
||||
*/
|
||||
@@ -433,7 +440,7 @@ public class GameMergeRepository {
|
||||
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
|
||||
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);
|
||||
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);
|
||||
|
||||
@@ -25,10 +25,11 @@ public class PsnTitleRepository {
|
||||
|
||||
public Map<String, PsnTitle> findAll(long userId) {
|
||||
Map<String, PsnTitle> titles = new LinkedHashMap<>();
|
||||
jdbc.query("select psn_key, state, appid from user_psn_title where user_id = :user",
|
||||
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 -> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import core_service.modules.library.domain.RemovedGame;
|
||||
import core_service.modules.library.domain.StatusCount;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
import core_service.modules.library.domain.UserGameId;
|
||||
import core_service.shared.sql.LikePattern;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
@@ -28,7 +29,6 @@ import java.util.EnumMap;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
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:
|
||||
// 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 " +
|
||||
"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)
|
||||
Page<UserGame> findOwnedEntries(@Param("userId") Long userId,
|
||||
@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
|
||||
* 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) {
|
||||
Sort result = Sort.unsorted();
|
||||
@@ -430,6 +433,10 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
||||
case "priority" -> Sort.by(JpaSort.unsafe(order.getDirection(),
|
||||
"(case ug.priority when 'HIGH' then 3 when 'MEDIUM' then 2 when 'LOW' then 1 end)")
|
||||
.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);
|
||||
});
|
||||
}
|
||||
@@ -441,9 +448,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
||||
*/
|
||||
static String containsPattern(String query) {
|
||||
if (query == null || query.isBlank()) return null;
|
||||
String escaped = query.trim().toLowerCase(Locale.ROOT)
|
||||
.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
|
||||
return "%" + escaped + "%";
|
||||
return "%" + LikePattern.escapeLower(query.trim()) + "%";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -103,7 +103,7 @@ public class UserStatsRepository {
|
||||
|
||||
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";
|
||||
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)";
|
||||
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";
|
||||
@@ -242,7 +242,7 @@ public class UserStatsRepository {
|
||||
}
|
||||
|
||||
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),
|
||||
(rs, i) -> new DayMinutes(rs.getDate("day").toLocalDate(), rs.getLong("minutes")))
|
||||
.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.domain.AchievementKind;
|
||||
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.Edition;
|
||||
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()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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.PickerDto;
|
||||
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.RecentGameDto;
|
||||
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.GameCreditRepository;
|
||||
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.PlaytimeDayRepository;
|
||||
import core_service.modules.library.repo.PsnTrophyRepository;
|
||||
@@ -108,6 +110,7 @@ public class LibraryQueryService {
|
||||
private final ReleaseDateService releaseDateService;
|
||||
private final SpeedrunService speedrunService;
|
||||
private final GameLinkRepository gameLinkRepository;
|
||||
private final GameMergeRepository mergeRepository;
|
||||
private final GameCreditRepository gameCreditRepository;
|
||||
private final FriendPlayRepository friendPlayRepository;
|
||||
private final PsnTrophyRepository psnTrophyRepository;
|
||||
@@ -390,7 +393,8 @@ public class LibraryQueryService {
|
||||
Map<LocalDate, Long> played = playtimeDayRepository.minutesPerDay(userId, from).stream()
|
||||
.collect(Collectors.toMap(DayMinutes::day, DayMinutes::minutes));
|
||||
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();
|
||||
long total = perDay.stream().mapToLong(DayMinutes::minutes).sum();
|
||||
|
||||
@@ -447,6 +451,64 @@ public class LibraryQueryService {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -164,7 +164,7 @@ public class LibrarySyncWorker {
|
||||
}
|
||||
List<SyncChange> steamChanges = hasSteam ? saveGames(userId, steamId, games, syncStartedAt, playedOn)
|
||||
: 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.
|
||||
int imported = 0;
|
||||
List<SyncChange> listed = new ArrayList<>();
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
@@ -47,17 +48,22 @@ public class PsnSyncStep {
|
||||
* A LINKED title's PSN values, applied in the sync transaction.
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
public record Prepared(List<SyncChange> changes, List<Playtime> playtimes, boolean baseline) {
|
||||
public static final Prepared NONE = new Prepared(List.of(), List.of(), false);
|
||||
public record Prepared(List<SyncChange> changes, List<Playtime> playtimes, List<TitlePlay> plays,
|
||||
boolean baseline) {
|
||||
public static final Prepared NONE = new Prepared(List.of(), List.of(), List.of(), false);
|
||||
}
|
||||
|
||||
private final PsnAccountService accountService;
|
||||
@@ -106,26 +112,38 @@ public class PsnSyncStep {
|
||||
List<GameImport.Row> review = new ArrayList<>();
|
||||
for (GameImport.Line line : lines) {
|
||||
PsnTitle seen = known.get(line.psnKey());
|
||||
Instant lastPlayedAt = lastPlayed.get(line.psnKey());
|
||||
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())) {
|
||||
matchNew(userId, line, lastPlayed.get(line.psnKey()), changes, review);
|
||||
matchNew(userId, line, lastPlayedAt, changes, review);
|
||||
}
|
||||
}
|
||||
saveReview(userId, review);
|
||||
psnTitleRepository.savePlays(userId, lines.stream().map(line -> new TitlePlay(line.psnKey(), null,
|
||||
line.platform(), line.playtimeMinutes(), lastPlayed.get(line.psnKey()))).toList());
|
||||
List<TitlePlay> plays = lines.stream().map(line -> new TitlePlay(line.psnKey(), null,
|
||||
line.platform(), line.playtimeMinutes(), lastPlayed.get(line.psnKey()))).toList();
|
||||
int added = changes.size();
|
||||
changes.addAll(trophyStep.run(userId, found.get().accessToken(), titles));
|
||||
log.info("PSN sync user={} added {} titles, {} wait for review, {} games gained trophies", userId, added,
|
||||
review.size(), changes.size() - added);
|
||||
return new Prepared(changes, mergedKeys(playtimes), baseline);
|
||||
return new Prepared(changes, mergedKeys(playtimes), plays, baseline);
|
||||
} catch (RuntimeException 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) {
|
||||
switch (accountService.fetchTitles(userId)) {
|
||||
case PsnAccountService.FetchResult.Titles found -> {
|
||||
@@ -194,7 +212,7 @@ public class PsnSyncStep {
|
||||
private List<Playtime> mergedKeys(List<Playtime> playtimes) {
|
||||
Map<Long, Long> targets = mergeRepository.findTargets(playtimes.stream().map(Playtime::appid).toList());
|
||||
return playtimes.stream().map(playtime -> new Playtime(targets.getOrDefault(playtime.appid(), playtime.appid()),
|
||||
playtime.minutes(), playtime.lastPlayedAt())).toList();
|
||||
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
|
||||
@@ -205,33 +223,35 @@ public class PsnSyncStep {
|
||||
byGame.merge(playtime.appid(), playtime, (a, b) -> new Playtime(a.appid(),
|
||||
(int) Math.min((long) a.minutes() + b.minutes(), Integer.MAX_VALUE),
|
||||
a.lastPlayedAt() == null || (b.lastPlayedAt() != null && b.lastPlayedAt().isAfter(a.lastPlayedAt()))
|
||||
? b.lastPlayedAt() : a.lastPlayedAt()));
|
||||
? b.lastPlayedAt() : a.lastPlayedAt(),
|
||||
(int) Math.min((long) a.played() + b.played(), Integer.MAX_VALUE)));
|
||||
}
|
||||
return byGame.values();
|
||||
}
|
||||
|
||||
/**
|
||||
* In the sync transaction, after the Steam games are saved: playtime, last played and two-weeks minutes of the
|
||||
* PSN rows. No status change: PSN rows have statusSetByUser.
|
||||
* 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)
|
||||
*/
|
||||
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());
|
||||
Map<Long, UserGame> touched = new HashMap<>();
|
||||
for (Playtime playtime : perGame(prepared.playtimes())) {
|
||||
userGameRepository.findById(new UserGameId(userId, playtime.appid()))
|
||||
.filter(game -> game.getSource() == GameSource.PSN && game.getRemovedAt() == null)
|
||||
.ifPresent(game -> {
|
||||
int growth = playtime.minutes() - game.getPlaytimeMinutes();
|
||||
game.setPlaytimeMinutes(playtime.minutes());
|
||||
game.setLastSeenAt(now);
|
||||
if (playtime.lastPlayedAt() != null) {
|
||||
game.setLastPlayedAt(playtime.lastPlayedAt());
|
||||
}
|
||||
if (growth > 0) {
|
||||
changes.add(SyncChange.playtime(game.getAppid(), growth));
|
||||
playtimeDayRepository.addMinutes(userId, game.getAppid(), day, growth);
|
||||
if (playtime.played() > 0) {
|
||||
LocalDate playedOn = LocalDate.ofInstant(playtime.lastPlayedAt(), zone);
|
||||
changes.add(SyncChange.playtime(game.getAppid(), playtime.played()));
|
||||
playtimeDayRepository.addMinutes(userId, game.getAppid(), playedOn, playtime.played());
|
||||
}
|
||||
touched.put(game.getAppid(), game);
|
||||
});
|
||||
@@ -243,6 +263,9 @@ public class PsnSyncStep {
|
||||
touched.forEach((appid, game) ->
|
||||
game.setPlaytime2Weeks((int) Math.min(twoWeeks.getOrDefault(appid, 0L), Integer.MAX_VALUE)));
|
||||
}
|
||||
if (!prepared.plays().isEmpty()) {
|
||||
psnTitleRepository.savePlays(userId, prepared.plays());
|
||||
}
|
||||
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,6 +8,12 @@ spring:
|
||||
accept-float-as-int: false
|
||||
jpa:
|
||||
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:
|
||||
enabled: true
|
||||
baseline-on-migrate: true
|
||||
@@ -100,6 +106,8 @@ app:
|
||||
# The hand changes of a followed user are one batch after this quiet time.
|
||||
digest-ms: 600000
|
||||
quiet-minutes: 10
|
||||
# Player searches per user and minute; more answers 429.
|
||||
search-per-minute: 30
|
||||
inbox:
|
||||
purge-cron: "0 15 3 * * *"
|
||||
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.json.JsonMapper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
@@ -62,6 +63,7 @@ class FollowControllerTest extends AbstractIntegrationTest {
|
||||
@AfterEach
|
||||
void cleanUp() {
|
||||
jdbc.update("delete from follow");
|
||||
jdbc.update("delete from inbox_note");
|
||||
jdbc.update("delete from follow_digest");
|
||||
jdbc.update("delete from public_user");
|
||||
jdbc.update("delete from account_name");
|
||||
@@ -82,7 +84,15 @@ class FollowControllerTest extends AbstractIntegrationTest {
|
||||
}
|
||||
|
||||
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();
|
||||
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));
|
||||
Set<String> keys = new HashSet<>();
|
||||
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 follow where user_id in (?, ?)", READER, OTHER);
|
||||
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 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);
|
||||
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 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 "
|
||||
+ "(?, 'NEW_GAME', 501, 'Alpha', null, 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].newGames").value(2))
|
||||
.andExpect(jsonPath("$.results[0].read").value(false))
|
||||
.andExpect(jsonPath("$.results[0].profileName").doesNotExist())
|
||||
.andExpect(jsonPath("$.results[1].displayName").value("Older"))
|
||||
.andExpect(jsonPath("$.unread").value(2))
|
||||
.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())
|
||||
.andExpect(jsonPath("$.results.length()").value(3))
|
||||
.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[2].fromStatus").value("BACKLOG"))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
assertThat(body).doesNotContain("appid", "subject", "userId");
|
||||
mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(jsonPath("$.results[0].details").value(true));
|
||||
assertThat(body).doesNotContain("subject", "userId");
|
||||
mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(jsonPath("$.results[0].details").value(true))
|
||||
.andExpect(jsonPath("$.results[0].profileName").value("SubjectNow"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package core_service.modules.inbox.repo;
|
||||
|
||||
import core_service.modules.inbox.domain.NoteDetail;
|
||||
import core_service.shared.test.AbstractPersistenceTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -71,4 +72,36 @@ class InboxRepositoryTest extends AbstractPersistenceTest {
|
||||
jdbc.update("delete from inbox_note_detail");
|
||||
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());
|
||||
}
|
||||
|
||||
@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
|
||||
void activity_RecentGamesAreOneEntryPerSetWithTheSumOfItsEditions() throws Exception {
|
||||
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.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -247,13 +249,16 @@ class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
|
||||
awaitSync();
|
||||
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);
|
||||
|
||||
assertThat(jdbc.queryForMap("select kind, appid, amount from sync_change where user_id = 1"))
|
||||
.containsEntry("kind", "PLAYTIME").containsEntry("appid", -300L).containsEntry("amount", 60);
|
||||
assertThat(jdbc.queryForObject("select minutes from user_game_playtime_day where user_id = 1 and appid = -300",
|
||||
Integer.class)).isEqualTo(60);
|
||||
assertThat(jdbc.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"))
|
||||
.containsEntry("playtime_minutes", 185).containsEntry("playtime_2weeks", 60);
|
||||
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())
|
||||
.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");
|
||||
}
|
||||
|
||||
@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
|
||||
void rekeyUserGame_KindHeldOnTarget_StaysOnOldRef() {
|
||||
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_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_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)) " +
|
||||
@@ -55,7 +56,9 @@ class OwnedGamesQueryGuardTest extends AbstractPersistenceTest {
|
||||
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, "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
|
||||
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)));
|
||||
}
|
||||
|
||||
@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
|
||||
void findAll_ReturnsOnlyTheUsersTitles() {
|
||||
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")));
|
||||
}
|
||||
|
||||
@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) {
|
||||
UserGame game = userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow();
|
||||
game.setRating(rating);
|
||||
|
||||
@@ -262,6 +262,18 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
|
||||
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
|
||||
void longestDay_SumsAllGamesOfTheDay() {
|
||||
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);
|
||||
}
|
||||
|
||||
@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
|
||||
void games_LongestDayListsASetOnceWithTheSumOfItsEditions() {
|
||||
seedSet();
|
||||
|
||||
@@ -833,9 +833,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
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.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)));
|
||||
owned(100);
|
||||
steamReturns(game(160, 60), STEAM_ID);
|
||||
@@ -845,7 +845,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
InOrder order = inOrder(steamApiService, psnSyncStep, userGameRepository, syncChangeRepository);
|
||||
order.verify(psnSyncStep).run(USER_ID);
|
||||
order.verify(userGameRepository).findByUserId(USER_ID);
|
||||
order.verify(psnSyncStep).apply(eq(USER_ID), eq(prepared), any(), any());
|
||||
order.verify(psnSyncStep).apply(eq(USER_ID), eq(prepared), any(), any(), any());
|
||||
order.verify(syncChangeRepository).replace(eq(USER_ID), any(), eq(SyncTrigger.AUTO), eq(0), anyList());
|
||||
assertThat(replacedChanges(SyncTrigger.AUTO, 0)).containsExactlyInAnyOrder(SyncChange.playtime(220L, 60),
|
||||
SyncChange.added(-9L), SyncChange.playtime(-8L, 30));
|
||||
@@ -853,9 +853,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
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.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)));
|
||||
owned(100);
|
||||
steamReturns(game(100, 0), STEAM_ID);
|
||||
@@ -867,9 +867,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
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.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);
|
||||
|
||||
worker.runSync(USER_ID, null, SyncTrigger.AUTO);
|
||||
|
||||
@@ -26,6 +26,8 @@ import org.springframework.transaction.PlatformTransactionManager;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -34,6 +36,7 @@ import java.util.Set;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
@@ -48,6 +51,8 @@ class PsnSyncStepTest extends AbstractUnitTest {
|
||||
private static final long USER = 1L;
|
||||
private static final Instant LAST_PLAYED = Instant.parse("2026-10-05T18:30:00Z");
|
||||
private static final LocalDate DAY = LocalDate.of(2026, 10, 7);
|
||||
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
|
||||
private static final Instant OLDER = Instant.parse("2026-10-01T10:00:00Z");
|
||||
|
||||
@Mock
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
void run_ExactMatchOnThePlatform_AddsTheGame_LinksIt_SetsLastPlayed_AndIsABaseline() {
|
||||
fetches(ghost());
|
||||
@@ -191,11 +204,42 @@ class PsnSyncStepTest extends AbstractUnitTest {
|
||||
|
||||
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();
|
||||
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
|
||||
void run_SavesThePlayOfALinkedAndASteamOwnedTitle_AndGivesNoPlaytimeToTheSteamRow() {
|
||||
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);
|
||||
|
||||
ArgumentCaptor<List<TitlePlay>> plays = ArgumentCaptor.captor();
|
||||
verify(psnTitleRepository).savePlays(eq(USER), plays.capture());
|
||||
assertThat(plays.getValue()).containsExactlyInAnyOrder(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED),
|
||||
verify(psnTitleRepository, never()).savePlays(eq(USER), anyList());
|
||||
assertThat(prepared.plays()).containsExactlyInAnyOrder(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED),
|
||||
new TitlePlay("c8", null, "PS5", 30, null));
|
||||
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),
|
||||
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();
|
||||
verify(psnTitleRepository).savePlays(eq(USER), plays.capture());
|
||||
assertThat(plays.getValue()).containsExactly(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED));
|
||||
assertThat(prepared.plays()).containsExactly(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED));
|
||||
assertThat(prepared.playtimes()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -297,20 +339,29 @@ class PsnSyncStepTest extends AbstractUnitTest {
|
||||
}
|
||||
|
||||
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
|
||||
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);
|
||||
when(playtimeDayRepository.minutesPerGameSince(eq(USER), eq(Set.of(-1L)), eq(DAY.minusDays(13))))
|
||||
.thenReturn(List.of(new AppidMinutes(-1L, 75)));
|
||||
Instant now = Instant.now();
|
||||
|
||||
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 90, LAST_PLAYED)), now, DAY);
|
||||
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));
|
||||
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.getPlaytime2Weeks()).isEqualTo(75);
|
||||
assertThat(game.getLastPlayedAt()).isEqualTo(LAST_PLAYED);
|
||||
@@ -322,7 +373,7 @@ class PsnSyncStepTest extends AbstractUnitTest {
|
||||
UserGame game = psnRow(-1L, 90);
|
||||
game.setLastPlayedAt(LAST_PLAYED);
|
||||
|
||||
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 60, null)), Instant.now(), DAY);
|
||||
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 60, null, 0)), Instant.now(), DAY, BERLIN);
|
||||
|
||||
assertThat(changes).containsExactly(SyncChange.added(-9L));
|
||||
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
|
||||
@@ -339,9 +390,9 @@ class PsnSyncStepTest extends AbstractUnitTest {
|
||||
removed.setRemovedAt(Instant.EPOCH);
|
||||
when(userGameRepository.findById(new UserGameId(USER, -3L))).thenReturn(Optional.empty());
|
||||
|
||||
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 90, LAST_PLAYED),
|
||||
new PsnSyncStep.Playtime(-2L, 90, LAST_PLAYED), new PsnSyncStep.Playtime(-3L, 90, LAST_PLAYED)),
|
||||
Instant.now(), DAY);
|
||||
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 90, LAST_PLAYED, 60),
|
||||
new PsnSyncStep.Playtime(-2L, 90, LAST_PLAYED, 60), new PsnSyncStep.Playtime(-3L, 90, LAST_PLAYED, 60)),
|
||||
Instant.now(), DAY, BERLIN);
|
||||
|
||||
assertThat(changes).containsExactly(SyncChange.added(-9L));
|
||||
assertThat(steam.getPlaytimeMinutes()).isEqualTo(30);
|
||||
@@ -353,11 +404,51 @@ class PsnSyncStepTest extends AbstractUnitTest {
|
||||
void apply_TwoTitlesOnOneGame_BookTheirSum_SoTheTotalNeverFlips() {
|
||||
UserGame game = psnRow(-1L, 600);
|
||||
|
||||
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 100, null),
|
||||
new PsnSyncStep.Playtime(-1L, 500, null)), Instant.now(), DAY);
|
||||
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 100, LAST_PLAYED, 40),
|
||||
new PsnSyncStep.Playtime(-1L, 500, OLDER, 60)), Instant.now(), DAY, BERLIN);
|
||||
|
||||
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));
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user