Public Access
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2ea1e45d3 | ||
|
|
221b5594c5 | ||
|
|
e806670d26 |
No files matched your search
@@ -0,0 +1,29 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 113112
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7046
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 26226
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14593
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 233
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 133236
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 119388
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 14593,
|
||||||
|
"name": "Hollow Knight",
|
||||||
|
"first_release_date": 1487980800,
|
||||||
|
"cover": {
|
||||||
|
"image_id": "co1r7f"
|
||||||
|
},
|
||||||
|
"platforms": [
|
||||||
|
{
|
||||||
|
"name": "PlayStation 5",
|
||||||
|
"abbreviation": "PS5"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_rating_count": 1100
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -63,5 +63,20 @@
|
|||||||
"uid": "504230"
|
"uid": "504230"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14593,
|
||||||
|
"name": "Hollow Knight",
|
||||||
|
"first_release_date": 1487980800,
|
||||||
|
"cover": {
|
||||||
|
"image_id": "co1r7f"
|
||||||
|
},
|
||||||
|
"platforms": [
|
||||||
|
{
|
||||||
|
"name": "PlayStation 5",
|
||||||
|
"abbreviation": "PS5"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_rating_count": 1100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"urlPath": "/igdb/games",
|
||||||
|
"bodyPatterns": [
|
||||||
|
{
|
||||||
|
"contains": "fields version_parent"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "igdb-editions.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"urlPath": "/igdb/games",
|
||||||
|
"bodyPatterns": [
|
||||||
|
{
|
||||||
|
"contains": "where id = 14593;"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "igdb-game-14593.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,9 @@ To Be Played is split into three repositories:
|
|||||||
and moods; moon trips (Apollo 11, 195.3 h), longest day, share under one hour; finished games per year (from the
|
and moods; moon trips (Apollo 11, 195.3 h), longest day, share under one hour; finished games per year (from the
|
||||||
status history); games and playtime per platform (`PLATFORMS`: Steam games as "Steam", manual games by platform, trimmed and ignoring case, the most used spelling is the label); the Pile of Shame trend (90 days of `backlog_snapshot`, plus "this month: added / done", where the
|
status history); games and playtime per platform (`PLATFORMS`: Steam games as "Steam", manual games by platform, trimmed and ignoring case, the most used spelling is the label); the Pile of Shame trend (90 days of `backlog_snapshot`, plus "this month: added / done", where the
|
||||||
first sync of a Steam account is no "added", see `user_steam_sync.history_since`). Every number counts only listed games (still owned, not hidden: the view
|
first sync of a Steam account is no "added", see `user_steam_sync.history_since`). Every number counts only listed games (still owned, not hidden: the view
|
||||||
`listed_user_game`, V24).
|
`listed_user_game`, V24). A linked set (see "Editions of one game") counts once: its most advanced edition (status, then
|
||||||
|
playtime, then the older `first_seen_at`; `counted_user_game`, V28), so the set is on the pile only when every owned edition is `BACKLOG`. Playtime
|
||||||
|
sums, most played, genres, moods and platforms still use every listed game.
|
||||||
- **Status history**: a database trigger logs every status change of a library game in `user_game_status_change`
|
- **Status history**: a database trigger logs every status change of a library game in `user_game_status_change`
|
||||||
(old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older
|
(old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older
|
||||||
changes are not known.
|
changes are not known.
|
||||||
@@ -67,6 +69,11 @@ To Be Played is split into three repositories:
|
|||||||
backgrounds (V27 widens `reward_equipped.slot`); a `BUY` row proves ownership,
|
backgrounds (V27 widens `reward_equipped.slot`); a `BUY` row proves ownership,
|
||||||
`reward_equipped` holds what is worn. A platform admin owns every item (the role of the login token, or the `role`
|
`reward_equipped` holds what is worn. A platform admin owns every item (the role of the login token, or the `role`
|
||||||
claim of the profile ticket for visitors).
|
claim of the profile ticket for visitors).
|
||||||
|
- **Editions and links**: editions, remasters and ports of one game stay separate library rows, but an AUDITOR can
|
||||||
|
link their keys as one game for every user (see "Editions of one game"). IGDB suggests pairs, users can ask for
|
||||||
|
one, and one queue holds both. Every link, unlink and dismissal goes to `admin_audit`
|
||||||
|
(`GAME_LINK`, `GAME_UNLINK`, `GAME_LINK_DISMISS`); auditors read that log through `GET /api/library/audit-log`, with
|
||||||
|
the actor's display name from Auth's account state push.
|
||||||
- **Wishlist**: synced from Steam with store price and discount.
|
- **Wishlist**: synced from Steam with store price and discount.
|
||||||
- **Broken image repair**: the frontend reports a Steam cover or achievement icon that does not load. At 01:30
|
- **Broken image repair**: the frontend reports a Steam cover or achievement icon that does not load. At 01:30
|
||||||
(Europe/Berlin) a job checks up to 200 reports. A cover that loads again is left alone; else the store's hashed
|
(Europe/Berlin) a job checks up to 200 reports. A cover that loads again is left alone; else the store's hashed
|
||||||
@@ -76,7 +83,7 @@ To Be Played is split into three repositories:
|
|||||||
- **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the
|
- **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the
|
||||||
feature is off and the rest works.
|
feature is off and the rest works.
|
||||||
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
|
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
|
||||||
and average completion. Readable only
|
and average completion (the numbers count a linked set once; the lists keep every edition). Readable only
|
||||||
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat
|
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat
|
||||||
groups the user shares (the ticket's `profileStats` claim, derived in Auth from the profile tiles); none by
|
groups the user shares (the ticket's `profileStats` claim, derived in Auth from the profile tiles); none by
|
||||||
default. A section the user
|
default. A section the user
|
||||||
@@ -127,10 +134,15 @@ 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) | JWT |
|
| `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name) | JWT |
|
||||||
| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS`) | JWT |
|
| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS`) | JWT |
|
||||||
| `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT |
|
| `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT |
|
||||||
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
|
| `GET` | `/api/library/games` (`hideDuplicates=true` leaves out the lesser editions of a linked set), `/api/library/games/{appid}` | JWT |
|
||||||
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT |
|
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT |
|
||||||
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
|
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
|
||||||
| `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
|
| `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
|
||||||
|
| `POST`, `DELETE` | `/api/library/games/{appid}/link` (POST `{otherAppid}`, `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED`; DELETE takes the game out of its set, `204`, `404 NOT_LINKED`) | JWT, AUDITOR |
|
||||||
|
| `GET` | `/api/library/audit-log?action=&page=&size=` (the newest `admin_audit` rows, `PageDto` of `{id, actorUserId, actorName, action, appid, gameName, targetAppid, targetGameName, detail, createdAt}`; `action` is `GAME_MERGE`, `GAME_LINK`, `GAME_UNLINK` or `GAME_LINK_DISMISS`; size 20, 40 or 80) | JWT, AUDITOR |
|
||||||
|
| `GET` | `/api/library/link-queue?page=&size=` (`PageDto` of `{a, b, igdb, requests, createdAt}`: both games, most requested first; size 20, 40 or 80) | JWT, AUDITOR |
|
||||||
|
| `DELETE` | `/api/library/link-queue/{appid}/{otherAppid}` (dismiss; `204`, `404 PAIR_NOT_OPEN`) | JWT, AUDITOR |
|
||||||
|
| `POST` | `/api/library/games/{appid}/link-requests` (`{otherAppid}`, both in the caller's library; `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED` / `PAIR_DISMISSED`, `429 TOO_MANY_REQUESTS`) | JWT |
|
||||||
| `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT |
|
| `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT |
|
||||||
| `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT |
|
| `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT |
|
||||||
| `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; `409 MANUAL_GAME` for a game added by hand) | JWT |
|
| `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; `409 MANUAL_GAME` for a game added by hand) | JWT |
|
||||||
@@ -149,7 +161,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
|
|||||||
| `GET` | `/api/public/profiles/rewards` (`{equipped}`, the worn looks) | profile ticket |
|
| `GET` | `/api/public/profiles/rewards` (`{equipped}`, the worn looks) | profile ticket |
|
||||||
| `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | public |
|
| `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | public |
|
||||||
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
|
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
|
||||||
| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, blocked}]}`; POST updates, PUT replaces the whole set; `204`) | service token |
|
| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked}]}`, `displayName` optional, at most 100 characters, kept in `account_name` for the audit log; POST updates, PUT replaces the whole set; `204`) | service token |
|
||||||
| `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token |
|
| `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token |
|
||||||
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
|
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
|
||||||
|
|
||||||
@@ -196,9 +208,9 @@ flowchart LR
|
|||||||
Igdb -->|"OAuth token"| Twitch["Twitch"]
|
Igdb -->|"OAuth token"| Twitch["Twitch"]
|
||||||
Speedrun --> SpeedrunApi["speedrun.com API"]
|
Speedrun --> SpeedrunApi["speedrun.com API"]
|
||||||
LibRepo --> Db[("PostgreSQL tbp_core")]
|
LibRepo --> Db[("PostgreSQL tbp_core")]
|
||||||
Stats -->|"listed_user_game, game_mood, user_game_status_change, deleted_user, blocked_user"| Db
|
Stats -->|"listed_user_game, counted_user_game, game_mood, user_game_status_change, deleted_user, blocked_user"| Db
|
||||||
Auth -->|"service token<br/>/internal/accounts"| Account
|
Auth -->|"service token<br/>/internal/accounts"| Account
|
||||||
Account -->|"per-user rows, deleted_user, blocked_user, user_seen_at"| Db
|
Account -->|"per-user rows, deleted_user, blocked_user, account_name, user_seen_at"| Db
|
||||||
```
|
```
|
||||||
|
|
||||||
| Module | Content |
|
| Module | Content |
|
||||||
@@ -206,18 +218,20 @@ flowchart LR
|
|||||||
| `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data |
|
| `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data |
|
||||||
| `stats` | Public numbers for the landing page |
|
| `stats` | Public numbers for the landing page |
|
||||||
| `version` | `GET /api/version`: commit and build time of the running image |
|
| `version` | `GET /api/version`: commit and build time of the running image |
|
||||||
| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, Steam ID changes stop the nightly sync |
|
| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, display names in `account_name`, Steam ID changes stop the nightly sync |
|
||||||
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
|
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
|
||||||
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
|
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
|
||||||
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
|
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
|
||||||
|
|
||||||
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
|
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
|
||||||
SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `steam_game`, `game_mood`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
|
SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
|
||||||
deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
|
deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
|
||||||
into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library`
|
into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library`
|
||||||
reads `deleted_user` and `blocked_user`. A module that moves to its own service takes these reads along as calls.
|
reads `deleted_user` and `blocked_user`. A module that moves to its own service takes these reads along as calls.
|
||||||
The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view
|
The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view
|
||||||
`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view.
|
`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view. The numbers that
|
||||||
|
count games once per linked set read `counted_user_game` (V28), built on `listed_user_game`: a migration that changes
|
||||||
|
a column of `user_game` drops this view first, then `listed_user_game`.
|
||||||
|
|
||||||
Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo`
|
Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo`
|
||||||
(repositories). `src/test/java/core_service/architecture/ArchitectureTest.java` checks these rules on every build:
|
(repositories). `src/test/java/core_service/architecture/ArchitectureTest.java` checks these rules on every build:
|
||||||
@@ -319,13 +333,33 @@ since the sync would bring both back. In one transaction:
|
|||||||
current status the user chose (both: the one chosen last); a gap is filled from the other row. Source, platform and playtime stay, unless the
|
current status the user chose (both: the one chosen last); a gap is filled from the other row. Source, platform and playtime stay, unless the
|
||||||
Steam copy is no longer owned. Both status histories stay; the merge itself logs no status change.
|
Steam copy is no longer owned. Both status histories stay; the merge itself logs no status change.
|
||||||
- The shared rows of the old key (catalog row, moods, speedrun, time to beat, corrections, broken images) are
|
- The shared rows of the old key (catalog row, moods, speedrun, time to beat, corrections, broken images) are
|
||||||
deleted.
|
deleted. So are its queue pairs, its requests and its `game_edition` row; its set row goes to the kept key (see
|
||||||
|
"Editions of one game").
|
||||||
- `game_alias` points the old key to the new one (kept flat), so a later add of the old IGDB game lands on the new
|
- `game_alias` points the old key to the new one (kept flat), so a later add of the old IGDB game lands on the new
|
||||||
key. `admin_audit` gets one `GAME_MERGE` row: who, both keys, both names and how many rows moved or were combined.
|
key. `admin_audit` gets one `GAME_MERGE` row: who, both keys, both names and how many rows moved or were combined.
|
||||||
|
|
||||||
There is no undo. The game search shows games already in ToBePlayed first, with their catalog name and image, and
|
There is no undo. The game search shows games already in ToBePlayed first, with their catalog name and image, and
|
||||||
games other users added by hand that IGDB did not return; `inCatalog` marks both (only those can be merged).
|
games other users added by hand that IGDB did not return; `inCatalog` marks both (only those can be merged).
|
||||||
|
|
||||||
|
### Editions of one game
|
||||||
|
|
||||||
|
IGDB tells which library keys are one game: `game_edition` (V28) holds a "same game" key per library key, refreshed
|
||||||
|
like `game_mood` (stale check, nightly sync, add, detail view; `app.library.edition-ttl-days`). The key is
|
||||||
|
`version_parent` for an edition, else `parent_game` when `game_type` is 9 (remaster) or 11 (port), else the game's own
|
||||||
|
IGDB id; remakes (8) keep their own id. `null` when IGDB does not know the game.
|
||||||
|
|
||||||
|
- `game_link(appid, set_id)`: the keys an auditor linked, for every user. A set is every row with one `set_id`.
|
||||||
|
An unlink takes one key out; a set of one is dropped. A merge puts the kept key in the merged key's set; when both have a set, the sets join.
|
||||||
|
- `game_link_pair` is the auditor queue (`OPEN`, `DISMISSED`, `LINKED`). IGDB pairs are inserted when the queue is
|
||||||
|
read; a row in any state keeps the pair out for good. An unlink dismisses the pairs of that key with the former
|
||||||
|
members. `game_link_request` holds who asked for a pair (the queue shows only the count). A user asks for a pair of
|
||||||
|
two games of their own library; at most 20 open requests per user (a per-user advisory lock makes the cap hold for parallel calls), a dismissed pair answers `409 PAIR_DISMISSED`.
|
||||||
|
- Pile, stats, snapshots and the dashboard count a set once (`counted_user_game`, see "Stats"); the library
|
||||||
|
filter `hideDuplicates=true` hides the lesser editions. Pile Points are unchanged: `STARTED` and `FINISHED` stay per row,
|
||||||
|
and the `MONTH` bonus reads `backlog_snapshot`, which counts a set once.
|
||||||
|
- The game detail has `linked` and `editions` (the user's other listed games of the set); a game row has `editions`
|
||||||
|
(size of the set in the user's library, `null` below 2).
|
||||||
|
|
||||||
### Deleted accounts
|
### Deleted accounts
|
||||||
|
|
||||||
Auth drives it (see Auth's readme). `POST /internal/accounts/deleted` adds a tombstone in `deleted_user`: the user
|
Auth drives it (see Auth's readme). `POST /internal/accounts/deleted` adds a tombstone in `deleted_user`: the user
|
||||||
@@ -333,11 +367,12 @@ gets no more syncs and leaves the landing stats and the pile snapshots at once.
|
|||||||
flagged set (`PUT`), then `purge` for users flagged 30 days ago (only tombstoned ones), then `prune` with all its
|
flagged set (`PUT`), then `purge` for users flagged 30 days ago (only tombstoned ones), then `prune` with all its
|
||||||
user ids, which removes the rows of users Auth does not know (refused for more than
|
user ids, which removes the rows of users Auth does not know (refused for more than
|
||||||
`app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows.
|
`app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows.
|
||||||
|
The purge also removes the user's `account_name` row and link requests (`game_link_request`).
|
||||||
|
|
||||||
### Blocked users and Steam ID changes
|
### Blocked users and Steam ID changes
|
||||||
|
|
||||||
Auth sends a user's state (`POST /internal/accounts/states`) after a Steam link, an unlink, a Steam ID another account
|
Auth sends a user's state (`POST /internal/accounts/states`) after a Steam link, an unlink, a Steam ID another account
|
||||||
took by a verified link, a block and an unblock; its nightly job sends every user (`PUT`). A blocked user is in
|
took by a verified link, a display name change, a block and an unblock; its nightly job sends every user (`PUT`). A blocked user is in
|
||||||
`blocked_user`: no nightly sync, no new sync run, not in the landing stats. The access token still works in Core until
|
`blocked_user`: no nightly sync, no new sync run, not in the landing stats. The access token still works in Core until
|
||||||
it expires (max 15 min). A new Steam ID or no Steam ID takes the user out of the nightly sync; the next visit syncs
|
it expires (max 15 min). A new Steam ID or no Steam ID takes the user out of the nightly sync; the next visit syncs
|
||||||
the ID of the token (Core's copy changes only then, so that sync sees the change and drops the old account's games). A visit with an access token that still has the old ID (max 15 min) can sync it once
|
the ID of the token (Core's copy changes only then, so that sync sees the change and drops the old account's games). A visit with an access token that still has the old ID (max 15 min) can sync it once
|
||||||
@@ -434,6 +469,8 @@ What the `local` profile does:
|
|||||||
- The browser still loads game pictures from the Steam and IGDB image servers; offline they stay gray.
|
- The browser still loads game pictures from the Steam and IGDB image servers; offline they stay gray.
|
||||||
- "Sign in through Steam" still opens the real Steam page (no key needed). The dev user is already linked.
|
- "Sign in through Steam" still opens the real Steam page (no key needed). The dev user is already linked.
|
||||||
- A request to WireMock that has no recording answers 404 and shows up in Core's log.
|
- A request to WireMock that has no recording answers 404 and shows up in Core's log.
|
||||||
|
- To see an IGDB link suggestion: add Hollow Knight by hand (game search, PS5). It shares IGDB's "same game" key with the
|
||||||
|
Steam copy, so the link queue (auditor) lists the pair with the IGDB badge.
|
||||||
|
|
||||||
To change the recorded answers, edit the JSON in `local/wiremock/mappings` (which request) and
|
To change the recorded answers, edit the JSON in `local/wiremock/mappings` (which request) and
|
||||||
`local/wiremock/__files` (the answer), then restart Core (it restarts the container). `LocalMocksParseTest` checks
|
`local/wiremock/__files` (the answer), then restart Core (it restarts the container). `LocalMocksParseTest` checks
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ public class InternalAccountController {
|
|||||||
|
|
||||||
private static List<AccountState> toStates(AccountStatesRequest request) {
|
private static List<AccountState> toStates(AccountStatesRequest request) {
|
||||||
return request.accounts().stream()
|
return request.accounts().stream()
|
||||||
.map(account -> new AccountState(account.userId(), account.steamId(), account.blocked()))
|
.map(account -> new AccountState(account.userId(), account.steamId(), account.displayName(), account.blocked()))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ package core_service.modules.account.api.dto;
|
|||||||
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public record AccountStatesRequest(@NotNull List<@Valid AccountStateDto> accounts) {
|
public record AccountStatesRequest(@NotNull List<@Valid AccountStateDto> accounts) {
|
||||||
|
|
||||||
public record AccountStateDto(@NotNull Long userId, Long steamId, boolean blocked) {
|
/**
|
||||||
|
* @param displayName null from an Auth that does not send it yet
|
||||||
|
*/
|
||||||
|
public record AccountStateDto(@NotNull Long userId, Long steamId, @Size(max = 100) String displayName,
|
||||||
|
boolean blocked) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ package core_service.modules.account.domain;
|
|||||||
* What Core copies from Auth for one user.
|
* What Core copies from Auth for one user.
|
||||||
*
|
*
|
||||||
* @param steamId null when the user has no Steam ID (unlinked, or taken by another account's verified link)
|
* @param steamId null when the user has no Steam ID (unlinked, or taken by another account's verified link)
|
||||||
|
* @param displayName null when Auth did not send one; Core then keeps the stored name
|
||||||
*/
|
*/
|
||||||
public record AccountState(long userId, Long steamId, boolean blocked) {
|
public record AccountState(long userId, Long steamId, String displayName, boolean blocked) {
|
||||||
}
|
}
|
||||||
@@ -22,11 +22,12 @@ public class AccountCleanupRepository {
|
|||||||
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
|
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
|
||||||
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
|
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
|
||||||
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
|
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
|
||||||
"user_steam_sync", "user_game", "game_import", "reward_ledger", "reward_equipped", "blocked_user", "deleted_user");
|
"user_steam_sync", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "reward_equipped",
|
||||||
|
"blocked_user", "deleted_user");
|
||||||
|
|
||||||
// Every user with data has a row in one of these; deleteRows then removes the child rows too.
|
// Every user with data has a row in one of these; deleteRows then removes the child rows too.
|
||||||
private static final List<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game",
|
private static final List<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game",
|
||||||
"game_import", "blocked_user", "deleted_user");
|
"game_import", "blocked_user", "deleted_user", "account_name");
|
||||||
|
|
||||||
private final NamedParameterJdbcTemplate jdbc;
|
private final NamedParameterJdbcTemplate jdbc;
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import java.util.Collection;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blocks in blocked_user, and what a Steam ID change means for user_steam_sync. Plain SQL, because the library
|
* Blocks in blocked_user, the display name in account_name, and what a Steam ID change means for user_steam_sync.
|
||||||
* module owns the entities.
|
* Plain SQL, because the library module owns the entities.
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -22,6 +22,11 @@ public class AccountStateRepository {
|
|||||||
public void apply(AccountState state) {
|
public void apply(AccountState state) {
|
||||||
MapSqlParameterSource params = new MapSqlParameterSource("userId", state.userId())
|
MapSqlParameterSource params = new MapSqlParameterSource("userId", state.userId())
|
||||||
.addValue("steamId", state.steamId() == null ? null : String.valueOf(state.steamId()));
|
.addValue("steamId", state.steamId() == null ? null : String.valueOf(state.steamId()));
|
||||||
|
if (state.displayName() != null) {
|
||||||
|
jdbc.update("insert into account_name (user_id, display_name) values (:userId, :displayName) "
|
||||||
|
+ "on conflict (user_id) do update set display_name = excluded.display_name",
|
||||||
|
params.addValue("displayName", state.displayName()));
|
||||||
|
}
|
||||||
if (state.blocked()) {
|
if (state.blocked()) {
|
||||||
jdbc.update("insert into blocked_user (user_id) values (:userId) on conflict do nothing", params);
|
jdbc.update("insert into blocked_user (user_id) values (:userId) on conflict do nothing", params);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ public final class IgdbResponses {
|
|||||||
@JsonProperty("game_modes") java.util.List<Named> gameModes
|
@JsonProperty("game_modes") java.util.List<Named> gameModes
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Edition(
|
||||||
|
@JsonProperty("id") Long id,
|
||||||
|
@JsonProperty("version_parent") Long versionParent,
|
||||||
|
@JsonProperty("parent_game") Long parentGame,
|
||||||
|
@JsonProperty("game_type") Integer gameType
|
||||||
|
) {}
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public record Named(@JsonProperty("name") String name) {}
|
public record Named(@JsonProperty("name") String name) {}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ public class IgdbApiService {
|
|||||||
*/
|
*/
|
||||||
public record GameTags(List<String> genres, List<String> themes, List<String> modes) {}
|
public record GameTags(List<String> genres, List<String> themes, List<String> modes) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param sameGameId the IGDB game all editions, remasters and ports of one game share, see {@link #sameGameId}
|
||||||
|
*/
|
||||||
|
public record Edition(long igdbId, long sameGameId) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One IGDB game as the library needs it.
|
* One IGDB game as the library needs it.
|
||||||
*
|
*
|
||||||
@@ -65,6 +70,8 @@ public class IgdbApiService {
|
|||||||
private static final String E_READER = "e-Reader / Card-e Reader";
|
private static final String E_READER = "e-Reader / Card-e Reader";
|
||||||
// IGDB game types a user can own and play on their own: main game, standalone expansion, season, remake,
|
// IGDB game types a user can own and play on their own: main game, standalone expansion, season, remake,
|
||||||
// remaster, expanded game, port. Leaves out DLC, bundles, mods, episodes, packs and updates.
|
// remaster, expanded game, port. Leaves out DLC, bundles, mods, episodes, packs and updates.
|
||||||
|
// Remaster and port belong to the game they come from. A remake (8) is another game and is not in this set.
|
||||||
|
private static final Set<Integer> SAME_GAME_TYPES = Set.of(9, 11);
|
||||||
private static final String PLAYABLE_GAME_TYPES = "(0,4,7,8,9,10,11)";
|
private static final String PLAYABLE_GAME_TYPES = "(0,4,7,8,9,10,11)";
|
||||||
private static final String GAME_FIELDS = "fields name,first_release_date,cover.image_id,artworks.image_id,"
|
private static final String GAME_FIELDS = "fields name,first_release_date,cover.image_id,artworks.image_id,"
|
||||||
+ "screenshots.image_id,platforms.name,platforms.abbreviation,external_games.external_game_source,"
|
+ "screenshots.image_id,platforms.name,platforms.abbreviation,external_games.external_game_source,"
|
||||||
@@ -176,6 +183,60 @@ public class IgdbApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the IGDB game and its "same game" key per Steam app; an app linked to several games takes the lowest
|
||||||
|
* game id, and an app IGDB does not link is missing. Empty when the adapter is off or a request failed.
|
||||||
|
*/
|
||||||
|
public Optional<Map<Long, Edition>> getEditions(Collection<Long> steamAppids) {
|
||||||
|
if (!isEnabled() || steamAppids.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<Long> appids = List.copyOf(steamAppids);
|
||||||
|
Map<Long, Edition> result = new HashMap<>();
|
||||||
|
for (int from = 0; from < appids.size(); from += BATCH) {
|
||||||
|
Map<Long, List<Long>> steamByIgdb = steamAppidsByIgdbGame(appids.subList(from, Math.min(from + BATCH, appids.size())));
|
||||||
|
Map<Long, Edition> editions = editionsOf(steamByIgdb.keySet());
|
||||||
|
steamByIgdb.keySet().stream().sorted().filter(editions::containsKey).forEach(igdbId ->
|
||||||
|
steamByIgdb.get(igdbId).forEach(appid -> result.putIfAbsent(appid, editions.get(igdbId))));
|
||||||
|
}
|
||||||
|
return Optional.of(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error communicating with IGDB for editions", e);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like {@link #getEditions}, but for IGDB game ids.
|
||||||
|
*/
|
||||||
|
public Optional<Map<Long, Edition>> getEditionsByGameIds(Collection<Long> igdbIds) {
|
||||||
|
if (!isEnabled() || igdbIds.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<Long> ids = List.copyOf(igdbIds);
|
||||||
|
Map<Long, Edition> result = new HashMap<>();
|
||||||
|
for (int from = 0; from < ids.size(); from += BATCH) {
|
||||||
|
result.putAll(editionsOf(ids.subList(from, Math.min(from + BATCH, ids.size()))));
|
||||||
|
}
|
||||||
|
return Optional.of(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error communicating with IGDB for editions", e);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static long sameGameId(IgdbResponses.Edition game) {
|
||||||
|
if (game.versionParent() != null) {
|
||||||
|
return game.versionParent();
|
||||||
|
}
|
||||||
|
if (game.parentGame() != null && game.gameType() != null && SAME_GAME_TYPES.contains(game.gameType())) {
|
||||||
|
return game.parentGame();
|
||||||
|
}
|
||||||
|
return game.id();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return the first release years of the Steam apps IGDB links to a game; an app linked to several games (say a
|
* @return the first release years of the Steam apps IGDB links to a game; an app linked to several games (say a
|
||||||
* remaster) gets all their years, an app without a known year is missing. Empty when the adapter is off or a
|
* remaster) gets all their years, an app without a known year is missing. Empty when the adapter is off or a
|
||||||
@@ -365,6 +426,23 @@ public class IgdbApiService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Map<Long, Edition> editionsOf(Collection<Long> igdbIds) {
|
||||||
|
if (igdbIds.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(","));
|
||||||
|
IgdbResponses.Edition[] games = query("/games",
|
||||||
|
"fields version_parent,parent_game,game_type; where id = (" + gameIds + "); limit " + LIMIT + ";",
|
||||||
|
IgdbResponses.Edition[].class);
|
||||||
|
Map<Long, Edition> result = new HashMap<>();
|
||||||
|
for (IgdbResponses.Edition game : games) {
|
||||||
|
if (game.id() != null && igdbIds.contains(game.id())) {
|
||||||
|
result.put(game.id(), new Edition(game.id(), sameGameId(game)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private Map<Long, Integer> yearsOf(Collection<Long> igdbIds) {
|
private Map<Long, Integer> yearsOf(Collection<Long> igdbIds) {
|
||||||
if (igdbIds.isEmpty()) {
|
if (igdbIds.isEmpty()) {
|
||||||
return Map.of();
|
return Map.of();
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ import core_service.shared.api.ErrorResponse;
|
|||||||
import core_service.modules.library.api.dto.AchievementSummaryDto;
|
import core_service.modules.library.api.dto.AchievementSummaryDto;
|
||||||
import core_service.modules.library.api.dto.ActivityDto;
|
import core_service.modules.library.api.dto.ActivityDto;
|
||||||
import core_service.modules.library.api.dto.BacklogPatch;
|
import core_service.modules.library.api.dto.BacklogPatch;
|
||||||
|
import core_service.modules.library.api.dto.AuditEntryDto;
|
||||||
import core_service.modules.library.api.dto.BrokenImageReport;
|
import core_service.modules.library.api.dto.BrokenImageReport;
|
||||||
|
import core_service.modules.library.api.dto.CatalogGameDto;
|
||||||
import core_service.modules.library.api.dto.CorrectionDto;
|
import core_service.modules.library.api.dto.CorrectionDto;
|
||||||
import core_service.modules.library.api.dto.FinishedHintDismissal;
|
import core_service.modules.library.api.dto.FinishedHintDismissal;
|
||||||
import core_service.modules.library.api.dto.GameDto;
|
import core_service.modules.library.api.dto.GameDto;
|
||||||
import core_service.modules.library.api.dto.HiddenUpdateDto;
|
import core_service.modules.library.api.dto.HiddenUpdateDto;
|
||||||
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
||||||
|
import core_service.modules.library.api.dto.LinkPairDto;
|
||||||
|
import core_service.modules.library.api.dto.LinkRequest;
|
||||||
import core_service.modules.library.api.dto.MergeRequest;
|
import core_service.modules.library.api.dto.MergeRequest;
|
||||||
import core_service.modules.library.api.dto.MergedGameDto;
|
import core_service.modules.library.api.dto.MergedGameDto;
|
||||||
import core_service.modules.library.api.dto.PageDto;
|
import core_service.modules.library.api.dto.PageDto;
|
||||||
@@ -19,12 +23,17 @@ import core_service.modules.library.api.dto.SteamProfileDto;
|
|||||||
import core_service.modules.library.api.dto.SyncStatusDto;
|
import core_service.modules.library.api.dto.SyncStatusDto;
|
||||||
import core_service.modules.library.api.dto.UserStatsDto;
|
import core_service.modules.library.api.dto.UserStatsDto;
|
||||||
import core_service.modules.library.api.dto.WishlistDto;
|
import core_service.modules.library.api.dto.WishlistDto;
|
||||||
|
import core_service.modules.library.domain.AuditEntry;
|
||||||
|
import core_service.modules.library.domain.CatalogGame;
|
||||||
import core_service.modules.library.domain.GameStatus;
|
import core_service.modules.library.domain.GameStatus;
|
||||||
|
import core_service.modules.library.domain.LinkPair;
|
||||||
import core_service.modules.library.domain.Mood;
|
import core_service.modules.library.domain.Mood;
|
||||||
import core_service.modules.library.domain.StatGroup;
|
import core_service.modules.library.domain.StatGroup;
|
||||||
|
import core_service.modules.library.service.AuditLogService;
|
||||||
import core_service.modules.library.service.BacklogService;
|
import core_service.modules.library.service.BacklogService;
|
||||||
import core_service.modules.library.service.GameCorrectionService;
|
import core_service.modules.library.service.GameCorrectionService;
|
||||||
import core_service.modules.library.service.GameDetailService;
|
import core_service.modules.library.service.GameDetailService;
|
||||||
|
import core_service.modules.library.service.GameLinkService;
|
||||||
import core_service.modules.library.service.GameMergeService;
|
import core_service.modules.library.service.GameMergeService;
|
||||||
import core_service.modules.library.service.ImageRepairService;
|
import core_service.modules.library.service.ImageRepairService;
|
||||||
import core_service.modules.library.service.InvalidCorrectionException;
|
import core_service.modules.library.service.InvalidCorrectionException;
|
||||||
@@ -83,6 +92,8 @@ public class LibraryController {
|
|||||||
private final UserStatsService userStatsService;
|
private final UserStatsService userStatsService;
|
||||||
private final GameCorrectionService gameCorrectionService;
|
private final GameCorrectionService gameCorrectionService;
|
||||||
private final GameMergeService gameMergeService;
|
private final GameMergeService gameMergeService;
|
||||||
|
private final GameLinkService gameLinkService;
|
||||||
|
private final AuditLogService auditLogService;
|
||||||
|
|
||||||
@GetMapping("/games-count")
|
@GetMapping("/games-count")
|
||||||
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
|
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
@@ -102,7 +113,8 @@ public class LibraryController {
|
|||||||
@RequestParam(required = false) GameStatus status,
|
@RequestParam(required = false) GameStatus status,
|
||||||
@RequestParam(required = false) String q,
|
@RequestParam(required = false) String q,
|
||||||
@RequestParam(required = false) Integer maxMinutes,
|
@RequestParam(required = false) Integer maxMinutes,
|
||||||
@RequestParam(defaultValue = "false") boolean hidden
|
@RequestParam(defaultValue = "false") boolean hidden,
|
||||||
|
@RequestParam(defaultValue = "false") boolean hideDuplicates
|
||||||
) {
|
) {
|
||||||
Long userId = principal.getIdAsLong();
|
Long userId = principal.getIdAsLong();
|
||||||
principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId));
|
principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId));
|
||||||
@@ -110,7 +122,7 @@ public class LibraryController {
|
|||||||
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
|
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
|
||||||
int safePage = Math.max(0, page);
|
int safePage = Math.max(0, page);
|
||||||
Sort safeSort = parseSort(sort);
|
Sort safeSort = parseSort(sort);
|
||||||
var dtoPage = queryService.listOwnedGames(userId, hidden, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, PageRequest.of(safePage, safeSize, safeSort));
|
var dtoPage = queryService.listOwnedGames(userId, hidden, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, hideDuplicates, PageRequest.of(safePage, safeSize, safeSort));
|
||||||
return ResponseEntity.ok(PageDto.of(dtoPage));
|
return ResponseEntity.ok(PageDto.of(dtoPage));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +218,89 @@ public class LibraryController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An auditor links two keys as one game, for every user.
|
||||||
|
*/
|
||||||
|
@PostMapping("/games/{appid}/link")
|
||||||
|
public ResponseEntity<?> linkGames(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@PathVariable("appid") Long appid, @Valid @RequestBody LinkRequest request) {
|
||||||
|
return switch (gameLinkService.link(appid, request.otherAppid(), principal.getIdAsLong())) {
|
||||||
|
case LINKED -> ResponseEntity.noContent().build();
|
||||||
|
case NOT_FOUND -> unknownGame();
|
||||||
|
case SAME_GAME -> sameGame();
|
||||||
|
case ALREADY_LINKED -> alreadyLinked();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/games/{appid}/link")
|
||||||
|
public ResponseEntity<?> unlinkGame(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@PathVariable("appid") Long appid) {
|
||||||
|
return gameLinkService.unlink(appid, principal.getIdAsLong()) ? ResponseEntity.noContent().build()
|
||||||
|
: ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("NOT_LINKED", "The game is not linked"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/link-queue")
|
||||||
|
public ResponseEntity<PageDto<LinkPairDto>> getLinkQueue(@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
|
||||||
|
return ResponseEntity.ok(PageDto.of(gameLinkService.queue(PageRequest.of(Math.max(0, page), safeSize)).map(LibraryController::toDto)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/link-queue/{appid}/{otherAppid}")
|
||||||
|
public ResponseEntity<?> dismissLink(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@PathVariable("appid") Long appid, @PathVariable("otherAppid") Long otherAppid) {
|
||||||
|
return gameLinkService.dismiss(appid, otherAppid, principal.getIdAsLong()) ? ResponseEntity.noContent().build()
|
||||||
|
: ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("PAIR_NOT_OPEN", "The pair is not in the queue"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A user asks an auditor to link two games of their own library.
|
||||||
|
*/
|
||||||
|
@PostMapping("/games/{appid}/link-requests")
|
||||||
|
public ResponseEntity<?> requestLink(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@PathVariable("appid") Long appid, @Valid @RequestBody LinkRequest request) {
|
||||||
|
return switch (gameLinkService.request(principal.getIdAsLong(), appid, request.otherAppid())) {
|
||||||
|
case DONE -> ResponseEntity.noContent().build();
|
||||||
|
case NOT_FOUND -> unknownGame();
|
||||||
|
case SAME_GAME -> sameGame();
|
||||||
|
case ALREADY_LINKED -> alreadyLinked();
|
||||||
|
case DISMISSED -> ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
|
.body(new ErrorResponse("PAIR_DISMISSED", "An auditor decided these are different games"));
|
||||||
|
case TOO_MANY -> ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||||
|
.body(new ErrorResponse("TOO_MANY_REQUESTS", "At most 20 open link requests"));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/audit-log")
|
||||||
|
public ResponseEntity<PageDto<AuditEntryDto>> getAuditLog(@RequestParam(required = false) String action,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
|
||||||
|
return ResponseEntity.ok(PageDto.of(auditLogService.newest(action, PageRequest.of(Math.max(0, page), safeSize)).map(LibraryController::toDto)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LinkPairDto toDto(LinkPair pair) {
|
||||||
|
return new LinkPairDto(toDto(pair.a()), toDto(pair.b()), pair.igdb(), pair.requests(), pair.createdAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CatalogGameDto toDto(CatalogGame game) {
|
||||||
|
return new CatalogGameDto(game.appid(), game.name(), game.imageUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuditEntryDto toDto(AuditEntry entry) {
|
||||||
|
return new AuditEntryDto(entry.id(), entry.actorUserId(), entry.actorName(), entry.action(), entry.appid(),
|
||||||
|
entry.gameName(), entry.targetAppid(), entry.targetGameName(), entry.detail(), entry.createdAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResponseEntity<?> sameGame() {
|
||||||
|
return ResponseEntity.badRequest().body(new ErrorResponse("SAME_GAME", "A game cannot be linked with itself"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResponseEntity<?> alreadyLinked() {
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
|
.body(new ErrorResponse("ALREADY_LINKED", "The games are already linked"));
|
||||||
|
}
|
||||||
|
|
||||||
private static ResponseEntity<?> correctionAnswer(GameCorrectionService.Result result) {
|
private static ResponseEntity<?> correctionAnswer(GameCorrectionService.Result result) {
|
||||||
return result == GameCorrectionService.Result.DONE ? ResponseEntity.noContent().build() : unknownGame();
|
return result == GameCorrectionService.Result.DONE ? ResponseEntity.noContent().build() : unknownGame();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param actorName null when Auth never sent the actor's display name
|
||||||
|
* @param gameName null when the key has no catalog row any more (a merged key)
|
||||||
|
*/
|
||||||
|
public record AuditEntryDto(long id, long actorUserId, String actorName, String action, Long appid, String gameName,
|
||||||
|
Long targetAppid, String targetGameName, String detail, Instant createdAt) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
|
public record CatalogGameDto(long appid, String name, String imageUrl) {}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GameSource;
|
||||||
|
import core_service.modules.library.domain.GameStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Another edition of a game that the user owns, from the game's linked set.
|
||||||
|
*/
|
||||||
|
public record EditionDto(long appid, String name, String platform, GameSource source, GameStatus status,
|
||||||
|
String imageUrl) {}
|
||||||
@@ -43,7 +43,11 @@ public record GameDetailDto(
|
|||||||
// IGDB genre names
|
// IGDB genre names
|
||||||
List<String> genres,
|
List<String> genres,
|
||||||
// the fields an auditor corrected
|
// the fields an auditor corrected
|
||||||
List<CorrectionMarkDto> corrections
|
List<CorrectionMarkDto> corrections,
|
||||||
|
// an auditor linked the game with others; for every user
|
||||||
|
boolean linked,
|
||||||
|
// the user's other games of that set, empty when none
|
||||||
|
List<EditionDto> editions
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import core_service.modules.library.domain.GameStatus;
|
|||||||
* @param platform where the user owns a game added by hand, e.g. "PS5"; null for Steam games
|
* @param platform where the user owns a game added by hand, e.g. "PS5"; null for Steam games
|
||||||
* @param imageUrl a game added by hand from IGDB, or a repaired Steam header; null means the fixed Steam CDN header
|
* @param imageUrl a game added by hand from IGDB, or a repaired Steam header; null means the fixed Steam CDN header
|
||||||
* @param hidden the user hid the game; it shows only under the library's hidden filter
|
* @param hidden the user hid the game; it shows only under the library's hidden filter
|
||||||
|
* @param editions how many games of the user are in this game's linked set, null below 2
|
||||||
*/
|
*/
|
||||||
public record GameDto(
|
public record GameDto(
|
||||||
Long appid,
|
Long appid,
|
||||||
@@ -26,5 +27,6 @@ public record GameDto(
|
|||||||
GameSource source,
|
GameSource source,
|
||||||
String platform,
|
String platform,
|
||||||
String imageUrl,
|
String imageUrl,
|
||||||
boolean hidden
|
boolean hidden,
|
||||||
|
Integer editions
|
||||||
) {}
|
) {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param igdb IGDB suggests the pair
|
||||||
|
* @param requests how many users asked for it
|
||||||
|
*/
|
||||||
|
public record LinkPairDto(CatalogGameDto a, CatalogGameDto b, boolean igdb, long requests, Instant createdAt) {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param otherAppid the key of the other edition of the same game
|
||||||
|
*/
|
||||||
|
public record LinkRequest(@NotNull Long otherAppid) {}
|
||||||
@@ -30,7 +30,8 @@ public record UserStatsDto(
|
|||||||
Map<GameStatus, Long> byStatus) {}
|
Map<GameStatus, Long> byStatus) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The pile is every game with status BACKLOG. Times to beat only count games IGDB knows.
|
* The pile is every game with status BACKLOG; a linked set counts once and is on the pile only when every owned
|
||||||
|
* edition is BACKLOG. Times to beat only count games IGDB knows.
|
||||||
*
|
*
|
||||||
* @param minutesPerWeek average playtime per week of the last 90 days
|
* @param minutesPerWeek average playtime per week of the last 90 days
|
||||||
* @param paceWeeks weeks to play the pile's normal time at that pace; null without playtime in the 90 days
|
* @param paceWeeks weeks to play the pile's normal time at that pace; null without playtime in the 90 days
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of Core's audit log, with the names found today.
|
||||||
|
*
|
||||||
|
* @param actorName null when Auth never sent the actor's display name
|
||||||
|
* @param gameName null when the key has no catalog row any more (a merged key)
|
||||||
|
*/
|
||||||
|
public record AuditEntry(long id, long actorUserId, String actorName, String action, Long appid, String gameName,
|
||||||
|
Long targetAppid, String targetGameName, String detail, Instant createdAt) {}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key of the shared catalog, as the link queue shows it.
|
||||||
|
*/
|
||||||
|
public record CatalogGame(long appid, String name, String imageUrl) {}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.IdClass;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.hibernate.annotations.Immutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The games a user's numbers count: read-only view, see V28.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Immutable
|
||||||
|
@Table(name = "counted_user_game")
|
||||||
|
@IdClass(UserGameId.class)
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
public class CountedUserGame {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "user_id")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "appid")
|
||||||
|
private Long appid;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "status", nullable = false, length = 16)
|
||||||
|
private GameStatus status;
|
||||||
|
|
||||||
|
@Column(name = "playtime_minutes", nullable = false)
|
||||||
|
private int playtimeMinutes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A listed row of the user's own library that is another edition of a game.
|
||||||
|
*/
|
||||||
|
public record Edition(long appid, String name, String platform, GameSource source, GameStatus status,
|
||||||
|
String imageUrl) {}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IGDB's "same game" key of one library key (V28): editions, remasters and ports of a game share it.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "game_edition")
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
public class GameEdition {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "appid")
|
||||||
|
private Long appid;
|
||||||
|
|
||||||
|
@Column(name = "igdb_id")
|
||||||
|
private Long igdbId;
|
||||||
|
|
||||||
|
/** Null when IGDB does not know the game. */
|
||||||
|
@Column(name = "same_game_id")
|
||||||
|
private Long sameGameId;
|
||||||
|
|
||||||
|
@Column(name = "checked_at", nullable = false)
|
||||||
|
private Instant checkedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two keys waiting in the auditor queue.
|
||||||
|
*
|
||||||
|
* @param igdb IGDB suggests the pair
|
||||||
|
* @param requests how many users asked for it
|
||||||
|
*/
|
||||||
|
public record LinkPair(CatalogGame a, CatalogGame b, boolean igdb, long requests, Instant createdAt) {}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.AuditEntry;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The audit log of what auditors changed for every user (V21).
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminAuditRepository {
|
||||||
|
|
||||||
|
public static final List<String> ACTIONS = List.of("GAME_MERGE", "GAME_LINK", "GAME_UNLINK", "GAME_LINK_DISMISS");
|
||||||
|
|
||||||
|
private final NamedParameterJdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public void write(long actorUserId, String action, Long appid, Long targetAppid, String detail) {
|
||||||
|
jdbc.update("insert into admin_audit (actor_user_id, action, appid, target_appid, detail) "
|
||||||
|
+ "values (:actor, :action, :appid, :target, :detail)",
|
||||||
|
new MapSqlParameterSource("actor", actorUserId).addValue("action", action)
|
||||||
|
.addValue("appid", appid).addValue("target", targetAppid).addValue("detail", detail));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param action null for every action
|
||||||
|
*/
|
||||||
|
public List<AuditEntry> findNewest(String action, int offset, int limit) {
|
||||||
|
return jdbc.query("""
|
||||||
|
select a.id, a.actor_user_id, n.display_name as actor_name, a.action, a.appid, g.name as game_name,
|
||||||
|
a.target_appid, t.name as target_name, a.detail, a.created_at
|
||||||
|
from admin_audit a
|
||||||
|
left join account_name n on n.user_id = a.actor_user_id
|
||||||
|
left join steam_game g on g.appid = a.appid
|
||||||
|
left join steam_game t on t.appid = a.target_appid
|
||||||
|
where (cast(:action as varchar) is null or a.action = :action)
|
||||||
|
order by a.id desc
|
||||||
|
limit :limit offset :offset
|
||||||
|
""", new MapSqlParameterSource("action", action).addValue("limit", limit).addValue("offset", offset),
|
||||||
|
(rs, row) -> new AuditEntry(rs.getLong("id"), rs.getLong("actor_user_id"), rs.getString("actor_name"),
|
||||||
|
rs.getString("action"), (Long) rs.getObject("appid"), rs.getString("game_name"),
|
||||||
|
(Long) rs.getObject("target_appid"), rs.getString("target_name"), rs.getString("detail"),
|
||||||
|
rs.getTimestamp("created_at").toInstant()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param action null for every action
|
||||||
|
*/
|
||||||
|
public long count(String action) {
|
||||||
|
Long count = jdbc.queryForObject("select count(*) from admin_audit where (cast(:action as varchar) is null "
|
||||||
|
+ "or action = :action)", new MapSqlParameterSource("action", action), Long.class);
|
||||||
|
return count == null ? 0 : count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import java.time.LocalDate;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The daily pile size per user, table backlog_snapshot. Counts listed games only, like {@link UserGameRepository#LISTED}.
|
* The daily pile size per user, table backlog_snapshot. Counts listed games only, like {@link UserGameRepository#LISTED}, and a linked set once (V28).
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -22,7 +22,7 @@ public class BacklogSnapshotRepository {
|
|||||||
count(*) filter (where ug.playtime_minutes = 0) as never_played,
|
count(*) filter (where ug.playtime_minutes = 0) as never_played,
|
||||||
coalesce(sum(t.normally_minutes) filter (where ug.status = 'BACKLOG'), 0) as backlog_minutes,
|
coalesce(sum(t.normally_minutes) filter (where ug.status = 'BACKLOG'), 0) as backlog_minutes,
|
||||||
count(*) as listed_games
|
count(*) as listed_games
|
||||||
from listed_user_game ug left join game_time_to_beat t on t.appid = ug.appid
|
from counted_user_game ug left join game_time_to_beat t on t.appid = ug.appid
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private final NamedParameterJdbcTemplate jdbc;
|
private final NamedParameterJdbcTemplate jdbc;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GameEdition;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public interface GameEditionRepository extends JpaRepository<GameEdition, Long> {
|
||||||
|
|
||||||
|
@Query("select e.appid from GameEdition e where e.appid in :appids and e.checkedAt > :since")
|
||||||
|
Set<Long> findFreshAppids(@Param("appids") Collection<Long> appids, @Param("since") Instant since);
|
||||||
|
|
||||||
|
// An upsert, not save(): two users' syncs can store the same new game at the same time.
|
||||||
|
@Modifying
|
||||||
|
@Transactional
|
||||||
|
@Query(value = "insert into game_edition (appid, igdb_id, same_game_id, checked_at) "
|
||||||
|
+ "values (:appid, :igdbId, :sameGameId, :checkedAt) "
|
||||||
|
+ "on conflict (appid) do update set igdb_id = excluded.igdb_id, "
|
||||||
|
+ "same_game_id = excluded.same_game_id, checked_at = excluded.checked_at",
|
||||||
|
nativeQuery = true)
|
||||||
|
void upsert(@Param("appid") Long appid, @Param("igdbId") Long igdbId, @Param("sameGameId") Long sameGameId,
|
||||||
|
@Param("checkedAt") Instant checkedAt);
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.CatalogGame;
|
||||||
|
import core_service.modules.library.domain.Edition;
|
||||||
|
import core_service.modules.library.domain.GameSource;
|
||||||
|
import core_service.modules.library.domain.GameStatus;
|
||||||
|
import core_service.modules.library.domain.LinkPair;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.RowCallbackHandler;
|
||||||
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The global game sets and the auditor queue (V28). Plain SQL: sets span keys and users. Every pair is stored as
|
||||||
|
* (least, greatest).
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GameLinkRepository {
|
||||||
|
|
||||||
|
private static final String UPSERT_PAIR = "insert into game_link_pair (appid_a, appid_b, state) "
|
||||||
|
+ "values (least(:a, :b), greatest(:a, :b), :state) on conflict (appid_a, appid_b) do update set state = :state";
|
||||||
|
|
||||||
|
private final NamedParameterJdbcTemplate jdbc;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locks the catalog rows of both keys until the transaction ends, so two link changes of one game do not cross.
|
||||||
|
*
|
||||||
|
* @return the name per key found
|
||||||
|
*/
|
||||||
|
public Map<Long, String> lockGames(long appid, long otherAppid) {
|
||||||
|
Map<Long, String> names = new HashMap<>();
|
||||||
|
jdbc.query("select appid, name from steam_game where appid in (:a, :b) order by appid for update",
|
||||||
|
keys(appid, otherAppid), rs -> { names.put(rs.getLong("appid"), rs.getString("name")); });
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<Long, String> names(Collection<Long> appids) {
|
||||||
|
Map<Long, String> names = new LinkedHashMap<>();
|
||||||
|
jdbc.query("select appid, name from steam_game where appid in (:appids) order by name",
|
||||||
|
new MapSqlParameterSource("appids", appids), rs -> { names.put(rs.getLong("appid"), rs.getString("name")); });
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Long> setOf(long appid) {
|
||||||
|
return jdbc.query("select set_id from game_link where appid = :a", new MapSqlParameterSource("a", appid),
|
||||||
|
(rs, row) -> rs.getLong("set_id")).stream().findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts both keys in one set, joining two sets when each key has one, and marks the pair LINKED.
|
||||||
|
*
|
||||||
|
* @return the set id
|
||||||
|
*/
|
||||||
|
public long link(long appid, long otherAppid) {
|
||||||
|
Optional<Long> first = setOf(appid);
|
||||||
|
Optional<Long> second = setOf(otherAppid);
|
||||||
|
long setId;
|
||||||
|
if (first.isPresent() && second.isPresent()) {
|
||||||
|
setId = first.get();
|
||||||
|
jdbc.update("update game_link set set_id = :keep where set_id = :other",
|
||||||
|
new MapSqlParameterSource("keep", setId).addValue("other", second.get()));
|
||||||
|
} else if (first.isPresent() || second.isPresent()) {
|
||||||
|
setId = first.orElseGet(second::get);
|
||||||
|
jdbc.update("insert into game_link (appid, set_id) values (:a, :set)",
|
||||||
|
new MapSqlParameterSource("a", first.isPresent() ? otherAppid : appid).addValue("set", setId));
|
||||||
|
} else {
|
||||||
|
Long next = jdbc.queryForObject("select nextval('game_link_set_seq')", Map.of(), Long.class);
|
||||||
|
setId = next == null ? 0 : next;
|
||||||
|
jdbc.update("insert into game_link (appid, set_id) values (:a, :set), (:b, :set)",
|
||||||
|
keys(appid, otherAppid).addValue("set", setId));
|
||||||
|
}
|
||||||
|
jdbc.update(UPSERT_PAIR, keys(appid, otherAppid).addValue("state", "LINKED"));
|
||||||
|
// Other open pairs inside the set are decided too.
|
||||||
|
jdbc.update("""
|
||||||
|
update game_link_pair set state = 'LINKED' where state = 'OPEN'
|
||||||
|
and appid_a in (select appid from game_link where set_id = :set)
|
||||||
|
and appid_b in (select appid from game_link where set_id = :set)
|
||||||
|
""", new MapSqlParameterSource("set", setId));
|
||||||
|
return setId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes the key out of its set (a set of one is dropped) and dismisses its pairs with the former members, so a
|
||||||
|
* wrong IGDB suggestion does not come back.
|
||||||
|
*
|
||||||
|
* @return the former members without the key; empty when it was in no set
|
||||||
|
*/
|
||||||
|
public List<Long> unlink(long appid) {
|
||||||
|
Optional<Long> setId = setOf(appid);
|
||||||
|
if (setId.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<Long> members = jdbc.queryForList("select appid from game_link where set_id = :set and appid <> :a "
|
||||||
|
+ "order by appid", new MapSqlParameterSource("set", setId.get()).addValue("a", appid), Long.class);
|
||||||
|
jdbc.update("delete from game_link where appid = :a", new MapSqlParameterSource("a", appid));
|
||||||
|
if (members.size() == 1) {
|
||||||
|
jdbc.update("delete from game_link where appid = :a", new MapSqlParameterSource("a", members.get(0)));
|
||||||
|
}
|
||||||
|
for (Long member : members) {
|
||||||
|
jdbc.update(UPSERT_PAIR, keys(appid, member).addValue("state", "DISMISSED"));
|
||||||
|
}
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queues every pair of keys that share an IGDB "same game" key and are not in one set. A row that exists, in
|
||||||
|
* any state, blocks the pair; an open pair a user asked for first is marked as IGDB's too.
|
||||||
|
*/
|
||||||
|
public int addIgdbPairs() {
|
||||||
|
return jdbc.update("""
|
||||||
|
insert into game_link_pair (appid_a, appid_b, state, igdb)
|
||||||
|
select e1.appid, e2.appid, 'OPEN', true
|
||||||
|
from game_edition e1
|
||||||
|
join game_edition e2 on e2.same_game_id = e1.same_game_id and e1.appid < e2.appid
|
||||||
|
where not exists (select 1 from game_link l1 join game_link l2 on l2.set_id = l1.set_id
|
||||||
|
where l1.appid = e1.appid and l2.appid = e2.appid)
|
||||||
|
on conflict (appid_a, appid_b) do update set igdb = true
|
||||||
|
where game_link_pair.state = 'OPEN' and not game_link_pair.igdb
|
||||||
|
""", Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds until the end of the transaction, so one user's link requests run one after the other.
|
||||||
|
*/
|
||||||
|
public void lockRequests(long userId) {
|
||||||
|
jdbc.query("select pg_advisory_xact_lock(hashtextextended(:key, 0))",
|
||||||
|
new MapSqlParameterSource("key", "game_link_request:" + userId), (RowCallbackHandler) rs -> { });
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countOpenPairs() {
|
||||||
|
Long count = jdbc.queryForObject("select count(*) from game_link_pair where state = 'OPEN'", Map.of(), Long.class);
|
||||||
|
return count == null ? 0 : count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<LinkPair> openPairs(int offset, int limit) {
|
||||||
|
return jdbc.query("""
|
||||||
|
select p.appid_a, ga.name as name_a, ga.image_url as image_a,
|
||||||
|
p.appid_b, gb.name as name_b, gb.image_url as image_b, p.igdb, p.created_at,
|
||||||
|
(select count(*) from game_link_request r
|
||||||
|
where r.appid_a = p.appid_a and r.appid_b = p.appid_b) as requests
|
||||||
|
from game_link_pair p
|
||||||
|
join steam_game ga on ga.appid = p.appid_a
|
||||||
|
join steam_game gb on gb.appid = p.appid_b
|
||||||
|
where p.state = 'OPEN'
|
||||||
|
order by requests desc, p.created_at, p.appid_a, p.appid_b
|
||||||
|
limit :limit offset :offset
|
||||||
|
""", new MapSqlParameterSource("limit", limit).addValue("offset", offset), (rs, row) -> new LinkPair(
|
||||||
|
new CatalogGame(rs.getLong("appid_a"), rs.getString("name_a"), rs.getString("image_a")),
|
||||||
|
new CatalogGame(rs.getLong("appid_b"), rs.getString("name_b"), rs.getString("image_b")),
|
||||||
|
rs.getBoolean("igdb"), rs.getLong("requests"), rs.getTimestamp("created_at").toInstant()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return OPEN, DISMISSED or LINKED; null when the pair has no row
|
||||||
|
*/
|
||||||
|
public String pairState(long appid, long otherAppid) {
|
||||||
|
return jdbc.query("select state from game_link_pair where appid_a = least(:a, :b) and appid_b = greatest(:a, :b)",
|
||||||
|
keys(appid, otherAppid), (rs, row) -> rs.getString("state")).stream().findFirst().orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queues the pair when it has no row and records the request.
|
||||||
|
*
|
||||||
|
* @return false when the user asked for this pair before
|
||||||
|
*/
|
||||||
|
public boolean addRequest(long userId, long appid, long otherAppid) {
|
||||||
|
MapSqlParameterSource params = keys(appid, otherAppid).addValue("user", userId);
|
||||||
|
jdbc.update("insert into game_link_pair (appid_a, appid_b, state) values (least(:a, :b), greatest(:a, :b), 'OPEN') "
|
||||||
|
+ "on conflict do nothing", params);
|
||||||
|
return jdbc.update("insert into game_link_request (user_id, appid_a, appid_b) "
|
||||||
|
+ "values (:user, least(:a, :b), greatest(:a, :b)) on conflict do nothing", params) == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int countOpenRequests(long userId) {
|
||||||
|
Integer count = jdbc.queryForObject("""
|
||||||
|
select count(*) from game_link_request r
|
||||||
|
join game_link_pair p on p.appid_a = r.appid_a and p.appid_b = r.appid_b
|
||||||
|
where r.user_id = :user and p.state = 'OPEN'
|
||||||
|
""", new MapSqlParameterSource("user", userId), Integer.class);
|
||||||
|
return count == null ? 0 : count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return false when the pair is not OPEN
|
||||||
|
*/
|
||||||
|
public boolean dismiss(long appid, long otherAppid) {
|
||||||
|
return jdbc.update("update game_link_pair set state = 'DISMISSED' where appid_a = least(:a, :b) "
|
||||||
|
+ "and appid_b = greatest(:a, :b) and state = 'OPEN'", keys(appid, otherAppid)) == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The other listed games of the user in the set of {@code appid}.
|
||||||
|
*/
|
||||||
|
public List<Edition> linkedEditions(long userId, long appid) {
|
||||||
|
return jdbc.query("""
|
||||||
|
select ug.appid, g.name, ug.platform, ug.source, ug.status, g.image_url
|
||||||
|
from game_link me
|
||||||
|
join game_link o on o.set_id = me.set_id and o.appid <> me.appid
|
||||||
|
join listed_user_game ug on ug.user_id = :user and ug.appid = o.appid
|
||||||
|
join steam_game g on g.appid = ug.appid
|
||||||
|
where me.appid = :appid
|
||||||
|
order by g.name, ug.appid
|
||||||
|
""", new MapSqlParameterSource("user", userId).addValue("appid", appid),
|
||||||
|
(rs, row) -> new Edition(rs.getLong("appid"), rs.getString("name"), rs.getString("platform"),
|
||||||
|
GameSource.valueOf(rs.getString("source")), GameStatus.valueOf(rs.getString("status")),
|
||||||
|
rs.getString("image_url")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return per given key in a set, how many listed games of the user are in that set (the key itself included);
|
||||||
|
* only counts of 2 and more
|
||||||
|
*/
|
||||||
|
public Map<Long, Integer> editionCounts(long userId, Collection<Long> appids) {
|
||||||
|
Map<Long, Integer> counts = new HashMap<>();
|
||||||
|
if (appids.isEmpty()) {
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
jdbc.query("""
|
||||||
|
select l.appid, count(*) as owned
|
||||||
|
from game_link l
|
||||||
|
join game_link o on o.set_id = l.set_id
|
||||||
|
join listed_user_game ug on ug.user_id = :user and ug.appid = o.appid
|
||||||
|
where l.appid in (:appids)
|
||||||
|
group by l.appid
|
||||||
|
having count(*) >= 2
|
||||||
|
""", new MapSqlParameterSource("user", userId).addValue("appids", appids),
|
||||||
|
rs -> { counts.put(rs.getLong("appid"), rs.getInt("owned")); });
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MapSqlParameterSource keys(long appid, long otherAppid) {
|
||||||
|
return new MapSqlParameterSource("a", appid).addValue("b", otherAppid);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import java.util.Collection;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges one library key into another (V21). Plain SQL: a merge rewrites the key of rows in many tables at once.
|
* Merges one library key into another (V21). Plain SQL: a merge rewrites the key of rows in many tables at once.
|
||||||
@@ -20,7 +21,7 @@ public class GameMergeRepository {
|
|||||||
|
|
||||||
// The shared rows of a game; steam_game last, since user_game refers to it.
|
// The shared rows of a game; steam_game last, since user_game refers to it.
|
||||||
private static final List<String> CATALOG_TABLES = List.of("game_mood", "game_speedrun", "game_time_to_beat",
|
private static final List<String> CATALOG_TABLES = List.of("game_mood", "game_speedrun", "game_time_to_beat",
|
||||||
"game_correction", "broken_image", "steam_game");
|
"game_correction", "broken_image", "game_edition", "steam_game");
|
||||||
|
|
||||||
// For a user with rows on both keys, the fields set by hand come from the winner: the row whose current status
|
// For a user with rows on both keys, the fields set by hand come from the winner: the row whose current status
|
||||||
// the user chose, and when both are, the one they chose last (a tie keeps the target row; rows from before V14
|
// the user chose, and when both are, the one they chose last (a tie keeps the target row; rows from before V14
|
||||||
@@ -94,6 +95,9 @@ public class GameMergeRepository {
|
|||||||
and not exists (select 1 from reward_ledger o where o.user_id = l.user_id and o.kind = l.kind
|
and not exists (select 1 from reward_ledger o where o.user_id = l.user_id and o.kind = l.kind
|
||||||
and o.ref = cast(:to as text))
|
and o.ref = cast(:to as text))
|
||||||
""", keys);
|
""", keys);
|
||||||
|
mergeSets(from, to, keys);
|
||||||
|
jdbc.update("delete from game_link_pair where :from in (appid_a, appid_b)", keys);
|
||||||
|
jdbc.update("delete from game_link_request where :from in (appid_a, appid_b)", keys);
|
||||||
for (String table : CATALOG_TABLES) {
|
for (String table : CATALOG_TABLES) {
|
||||||
jdbc.update("delete from " + table + " where appid = :from", keys);
|
jdbc.update("delete from " + table + " where appid = :from", keys);
|
||||||
}
|
}
|
||||||
@@ -103,11 +107,27 @@ public class GameMergeRepository {
|
|||||||
return new MergeCounts(moved, combined);
|
return new MergeCounts(moved, combined);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void auditMerge(long actorUserId, long from, long to, String detail) {
|
// The kept key takes the set of the merged one; when both have a set, the sets join. A set left with one key goes.
|
||||||
jdbc.update("insert into admin_audit (actor_user_id, action, appid, target_appid, detail) "
|
private void mergeSets(long from, long to, MapSqlParameterSource keys) {
|
||||||
+ "values (:actor, 'GAME_MERGE', :from, :to, :detail)",
|
Optional<Long> fromSet = setOf(from);
|
||||||
new MapSqlParameterSource("actor", actorUserId).addValue("from", from).addValue("to", to)
|
if (fromSet.isEmpty()) {
|
||||||
.addValue("detail", detail));
|
return;
|
||||||
|
}
|
||||||
|
Optional<Long> toSet = setOf(to);
|
||||||
|
long keep = toSet.orElse(fromSet.get());
|
||||||
|
keys.addValue("keep", keep).addValue("fromSet", fromSet.get());
|
||||||
|
jdbc.update("update game_link set set_id = :keep where set_id = :fromSet", keys);
|
||||||
|
jdbc.update("delete from game_link where appid = :from", keys);
|
||||||
|
if (toSet.isEmpty()) {
|
||||||
|
jdbc.update("insert into game_link (appid, set_id) values (:to, :keep)", keys);
|
||||||
|
}
|
||||||
|
jdbc.update("delete from game_link where set_id = :keep and (select count(*) from game_link where set_id = :keep) = 1",
|
||||||
|
keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<Long> setOf(long appid) {
|
||||||
|
return jdbc.query("select set_id from game_link where appid = :a", new MapSqlParameterSource("a", appid),
|
||||||
|
(rs, row) -> rs.getLong("set_id")).stream().findFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -74,11 +74,11 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
@Query("select ug from UserGame ug where " + LISTED + "and ug.playtime2Weeks > 0 order by ug.playtime2Weeks desc")
|
@Query("select ug from UserGame ug where " + LISTED + "and ug.playtime2Weeks > 0 order by ug.playtime2Weeks desc")
|
||||||
List<UserGame> findListedPlayedInTwoWeeks(@Param("userId") Long userId, Pageable pageable);
|
List<UserGame> findListedPlayedInTwoWeeks(@Param("userId") Long userId, Pageable pageable);
|
||||||
|
|
||||||
@Query("select count(ug) from UserGame ug where " + LISTED + "and ug.playtimeMinutes = 0")
|
@Query("select count(c) from CountedUserGame c where c.userId = :userId and c.playtimeMinutes = 0")
|
||||||
long countListedNeverPlayed(@Param("userId") Long userId);
|
long countListedNeverPlayed(@Param("userId") Long userId);
|
||||||
|
|
||||||
@Query("select new core_service.modules.library.domain.StatusCount(ug.status, count(ug)) from UserGame ug " +
|
@Query("select new core_service.modules.library.domain.StatusCount(c.status, count(c)) from CountedUserGame c " +
|
||||||
"where " + LISTED + "group by ug.status")
|
"where c.userId = :userId group by c.status")
|
||||||
List<StatusCount> countByStatus(@Param("userId") Long userId);
|
List<StatusCount> countByStatus(@Param("userId") Long userId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,7 +104,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
*
|
*
|
||||||
* @param excludeAppid never picked; {@link #NO_GAME} excludes nothing
|
* @param excludeAppid never picked; {@link #NO_GAME} excludes nothing
|
||||||
*/
|
*/
|
||||||
@Query(value = "select * from listed_user_game where user_id = :userId and status = 'BACKLOG' " +
|
@Query(value = "select * from counted_user_game where user_id = :userId and status = 'BACKLOG' " +
|
||||||
"and appid <> :excludeAppid " +
|
"and appid <> :excludeAppid " +
|
||||||
// Weighted random pick: the smallest -ln(u) / weight wins.
|
// Weighted random pick: the smallest -ln(u) / weight wins.
|
||||||
"order by -ln(1 - random()) / case priority when 'HIGH' then 4 when 'MEDIUM' then 2 else 1 end " +
|
"order by -ln(1 - random()) / case priority when 'HIGH' then 4 when 'MEDIUM' then 2 else 1 end " +
|
||||||
@@ -136,7 +136,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
return queryPickerGames(userId, maxMinutes, storedMood, speedrun, excluded, count);
|
return queryPickerGames(userId, maxMinutes, storedMood, speedrun, excluded, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Query(value = "select ug.* from listed_user_game ug where ug.user_id = :userId " +
|
@Query(value = "select ug.* from counted_user_game ug where ug.user_id = :userId " +
|
||||||
"and ug.status = 'BACKLOG' and ug.appid not in (:excludeAppids) " +
|
"and ug.status = 'BACKLOG' and ug.appid not in (:excludeAppids) " +
|
||||||
"and (cast(:maxMinutes as integer) is null or exists (select 1 from game_time_to_beat t " +
|
"and (cast(:maxMinutes as integer) is null or exists (select 1 from game_time_to_beat t " +
|
||||||
" where t.appid = ug.appid and t.normally_minutes <= :maxMinutes)) " +
|
" where t.appid = ug.appid and t.normally_minutes <= :maxMinutes)) " +
|
||||||
@@ -158,6 +158,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
* @param status null for every status
|
* @param status null for every status
|
||||||
* @param namePattern a {@link #containsPattern} result, null for every name
|
* @param namePattern a {@link #containsPattern} result, null for every name
|
||||||
* @param maxMinutes only games whose normal time to beat is known and at most this; null for every game
|
* @param maxMinutes only games whose normal time to beat is known and at most this; null for every game
|
||||||
|
* @param hideDuplicates only the games a linked set counts (see V28), not its lesser editions
|
||||||
*/
|
*/
|
||||||
@Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
|
@Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
|
||||||
"where ug.userId = :userId and ug.removedAt is null " +
|
"where ug.userId = :userId and ug.removedAt is null " +
|
||||||
@@ -165,20 +166,22 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
"and (:status is null or ug.status = :status) " +
|
"and (:status is null or ug.status = :status) " +
|
||||||
"and (:namePattern is null or lower(sg.name) like :namePattern escape '\\') " +
|
"and (:namePattern is null or lower(sg.name) like :namePattern escape '\\') " +
|
||||||
"and (:maxMinutes is null or exists (select 1 from GameTimeToBeat t " +
|
"and (:maxMinutes is null or exists (select 1 from GameTimeToBeat t " +
|
||||||
" where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes))")
|
" where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes)) " +
|
||||||
|
"and (:hideDuplicates = false or exists (select 1 from CountedUserGame c " +
|
||||||
|
" where c.userId = ug.userId and c.appid = ug.appid))")
|
||||||
Page<UserGame> findOwnedGames(@Param("userId") Long userId,
|
Page<UserGame> findOwnedGames(@Param("userId") Long userId,
|
||||||
@Param("hidden") boolean hidden,
|
@Param("hidden") boolean hidden,
|
||||||
@Param("status") GameStatus status,
|
@Param("status") GameStatus status,
|
||||||
@Param("namePattern") String namePattern,
|
@Param("namePattern") String namePattern,
|
||||||
@Param("maxMinutes") Integer maxMinutes,
|
@Param("maxMinutes") Integer maxMinutes,
|
||||||
|
@Param("hideDuplicates") boolean hideDuplicates,
|
||||||
Pageable pageable);
|
Pageable pageable);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normal time to beat of the user's backlog, only games IGDB has data for.
|
* Normal time to beat of the user's backlog, only games IGDB has data for.
|
||||||
*/
|
*/
|
||||||
@Query("select coalesce(sum(t.normallyMinutes), 0) from UserGame ug join GameTimeToBeat t on t.appid = ug.appid " +
|
@Query("select coalesce(sum(t.normallyMinutes), 0) from CountedUserGame c join GameTimeToBeat t on t.appid = c.appid " +
|
||||||
"where " + LISTED +
|
"where c.userId = :userId and c.status = core_service.modules.library.domain.GameStatus.BACKLOG")
|
||||||
"and ug.status = core_service.modules.library.domain.GameStatus.BACKLOG")
|
|
||||||
long sumBacklogMinutes(@Param("userId") Long userId);
|
long sumBacklogMinutes(@Param("userId") Long userId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ public class UserStatsRepository {
|
|||||||
return jdbc.queryForObject("""
|
return jdbc.queryForObject("""
|
||||||
select coalesce(sum(t.normally_minutes), 0) as normal, coalesce(sum(t.completely_minutes), 0) as complete,
|
select coalesce(sum(t.normally_minutes), 0) as normal, coalesce(sum(t.completely_minutes), 0) as complete,
|
||||||
count(t.normally_minutes) as known
|
count(t.normally_minutes) as known
|
||||||
from listed_user_game ug join game_time_to_beat t on t.appid = ug.appid
|
from counted_user_game ug join game_time_to_beat t on t.appid = ug.appid
|
||||||
where %s and ug.status = 'BACKLOG'
|
where %s and ug.status = 'BACKLOG'
|
||||||
""".formatted(OF_USER), user(userId),
|
""".formatted(OF_USER), user(userId),
|
||||||
(rs, i) -> new UserStats.PileTimes(rs.getLong("normal"), rs.getLong("complete"), rs.getLong("known")));
|
(rs, i) -> new UserStats.PileTimes(rs.getLong("normal"), rs.getLong("complete"), rs.getLong("known")));
|
||||||
@@ -66,7 +66,7 @@ public class UserStatsRepository {
|
|||||||
public Optional<UserStats.OldestGame> oldestUnplayed(long userId) {
|
public Optional<UserStats.OldestGame> oldestUnplayed(long userId) {
|
||||||
return jdbc.query("""
|
return jdbc.query("""
|
||||||
select ug.appid, sg.name, sg.image_url, ug.first_seen_at
|
select ug.appid, sg.name, sg.image_url, ug.first_seen_at
|
||||||
from listed_user_game ug join steam_game sg on sg.appid = ug.appid
|
from counted_user_game ug join steam_game sg on sg.appid = ug.appid
|
||||||
where %s and ug.status = 'BACKLOG' and ug.playtime_minutes = 0
|
where %s and ug.status = 'BACKLOG' and ug.playtime_minutes = 0
|
||||||
order by ug.first_seen_at, ug.appid limit 1
|
order by ug.first_seen_at, ug.appid limit 1
|
||||||
""".formatted(OF_USER), user(userId),
|
""".formatted(OF_USER), user(userId),
|
||||||
@@ -148,7 +148,7 @@ public class UserStatsRepository {
|
|||||||
return jdbc.queryForObject("""
|
return jdbc.queryForObject("""
|
||||||
select count(*) filter (where ug.playtime_minutes > 0) as started,
|
select count(*) filter (where ug.playtime_minutes > 0) as started,
|
||||||
count(*) filter (where ug.playtime_minutes between 1 and 59) as under_one_hour
|
count(*) filter (where ug.playtime_minutes between 1 and 59) as under_one_hour
|
||||||
from listed_user_game ug where %s
|
from counted_user_game ug where %s
|
||||||
""".formatted(OF_USER), user(userId),
|
""".formatted(OF_USER), user(userId),
|
||||||
(rs, i) -> new UserStats.PlayedCounts(rs.getLong("started"), rs.getLong("under_one_hour")));
|
(rs, i) -> new UserStats.PlayedCounts(rs.getLong("started"), rs.getLong("under_one_hour")));
|
||||||
}
|
}
|
||||||
@@ -161,7 +161,7 @@ public class UserStatsRepository {
|
|||||||
return jdbc.query("""
|
return jdbc.query("""
|
||||||
select cast(extract(year from coalesce(ug.finished_at_estimate, f.finished_at) at time zone :zone)
|
select cast(extract(year from coalesce(ug.finished_at_estimate, f.finished_at) at time zone :zone)
|
||||||
as integer) as year, count(*) as games
|
as integer) as year, count(*) as games
|
||||||
from listed_user_game ug left join (%s) f on f.appid = ug.appid
|
from counted_user_game ug left join (%s) f on f.appid = ug.appid
|
||||||
where %s and ug.status in %s and coalesce(ug.finished_at_estimate, f.finished_at) is not null
|
where %s and ug.status in %s and coalesce(ug.finished_at_estimate, f.finished_at) is not null
|
||||||
group by year order by year
|
group by year order by year
|
||||||
""".formatted(FIRST_FINISH, OF_USER, FINISHED), user(userId).addValue("zone", zone.getId()),
|
""".formatted(FIRST_FINISH, OF_USER, FINISHED), user(userId).addValue("zone", zone.getId()),
|
||||||
@@ -170,7 +170,7 @@ public class UserStatsRepository {
|
|||||||
|
|
||||||
public long finishedBeforeTracking(long userId) {
|
public long finishedBeforeTracking(long userId) {
|
||||||
Long games = jdbc.queryForObject("""
|
Long games = jdbc.queryForObject("""
|
||||||
select count(*) from listed_user_game ug
|
select count(*) from counted_user_game ug
|
||||||
where %s and ug.status in %s and ug.finished_at_estimate is null
|
where %s and ug.status in %s and ug.finished_at_estimate is null
|
||||||
and not exists (select 1 from user_game_status_change sc
|
and not exists (select 1 from user_game_status_change sc
|
||||||
where sc.user_id = ug.user_id and sc.appid = ug.appid and %s)
|
where sc.user_id = ug.user_id and sc.appid = ug.appid and %s)
|
||||||
@@ -190,7 +190,7 @@ public class UserStatsRepository {
|
|||||||
or sc.from_status is not null or sc.changed_at > uss.history_since)) as added,
|
or sc.from_status is not null or sc.changed_at > uss.history_since)) as added,
|
||||||
count(distinct sc.appid) filter (where sc.from_status = 'BACKLOG' and sc.to_status <> 'BACKLOG')
|
count(distinct sc.appid) filter (where sc.from_status = 'BACKLOG' and sc.to_status <> 'BACKLOG')
|
||||||
as done
|
as done
|
||||||
from user_game_status_change sc join listed_user_game ug on ug.user_id = sc.user_id and ug.appid = sc.appid
|
from user_game_status_change sc join counted_user_game ug on ug.user_id = sc.user_id and ug.appid = sc.appid
|
||||||
left join user_steam_sync uss on uss.user_id = sc.user_id
|
left join user_steam_sync uss on uss.user_id = sc.user_id
|
||||||
where %s and sc.changed_at >= :monthStart
|
where %s and sc.changed_at >= :monthStart
|
||||||
""".formatted(OF_USER), user(userId).addValue("monthStart", Timestamp.from(monthStart)),
|
""".formatted(OF_USER), user(userId).addValue("monthStart", Timestamp.from(monthStart)),
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.AuditEntry;
|
||||||
|
import core_service.modules.library.repo.AdminAuditRepository;
|
||||||
|
import core_service.shared.security.RequiresRole;
|
||||||
|
import core_service.shared.security.Role;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What auditors changed, newest first.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiresRole(Role.AUDITOR)
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuditLogService {
|
||||||
|
|
||||||
|
private final AdminAuditRepository adminAuditRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param action null for every action; an unknown one gives an empty page
|
||||||
|
*/
|
||||||
|
public Page<AuditEntry> newest(String action, Pageable pageable) {
|
||||||
|
if (action != null && !AdminAuditRepository.ACTIONS.contains(action)) {
|
||||||
|
return new PageImpl<>(List.of(), pageable, 0);
|
||||||
|
}
|
||||||
|
return new PageImpl<>(adminAuditRepository.findNewest(action, (int) pageable.getOffset(), pageable.getPageSize()),
|
||||||
|
pageable, adminAuditRepository.count(action));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package core_service.modules.library.service;
|
|||||||
|
|
||||||
import core_service.modules.library.api.dto.AchievementDto;
|
import core_service.modules.library.api.dto.AchievementDto;
|
||||||
import core_service.modules.library.api.dto.CorrectionMarkDto;
|
import core_service.modules.library.api.dto.CorrectionMarkDto;
|
||||||
|
import core_service.modules.library.api.dto.EditionDto;
|
||||||
import core_service.modules.library.api.dto.FinishedHintDto;
|
import core_service.modules.library.api.dto.FinishedHintDto;
|
||||||
import core_service.modules.library.api.dto.GameDetailDto;
|
import core_service.modules.library.api.dto.GameDetailDto;
|
||||||
import core_service.modules.library.domain.AchievementProgress;
|
import core_service.modules.library.domain.AchievementProgress;
|
||||||
@@ -19,6 +20,7 @@ import core_service.modules.library.domain.UserGameAchievementMeta;
|
|||||||
import core_service.modules.library.domain.UserGameId;
|
import core_service.modules.library.domain.UserGameId;
|
||||||
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
|
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
|
||||||
import core_service.modules.library.repo.GameCorrectionRepository;
|
import core_service.modules.library.repo.GameCorrectionRepository;
|
||||||
|
import core_service.modules.library.repo.GameLinkRepository;
|
||||||
import core_service.modules.library.repo.GameMoodsRepository;
|
import core_service.modules.library.repo.GameMoodsRepository;
|
||||||
import core_service.modules.library.repo.GameTimeToBeatRepository;
|
import core_service.modules.library.repo.GameTimeToBeatRepository;
|
||||||
import core_service.modules.library.repo.GameAchievementSchemaRepository;
|
import core_service.modules.library.repo.GameAchievementSchemaRepository;
|
||||||
@@ -58,10 +60,12 @@ public class GameDetailService {
|
|||||||
private final GameTimeToBeatRepository gameTimeToBeatRepository;
|
private final GameTimeToBeatRepository gameTimeToBeatRepository;
|
||||||
private final TimeToBeatService timeToBeatService;
|
private final TimeToBeatService timeToBeatService;
|
||||||
private final MoodService moodService;
|
private final MoodService moodService;
|
||||||
|
private final GameEditionService gameEditionService;
|
||||||
private final GameSpeedrunRepository gameSpeedrunRepository;
|
private final GameSpeedrunRepository gameSpeedrunRepository;
|
||||||
private final SpeedrunService speedrunService;
|
private final SpeedrunService speedrunService;
|
||||||
private final GameMoodsRepository gameMoodsRepository;
|
private final GameMoodsRepository gameMoodsRepository;
|
||||||
private final GameCorrectionRepository gameCorrectionRepository;
|
private final GameCorrectionRepository gameCorrectionRepository;
|
||||||
|
private final GameLinkRepository gameLinkRepository;
|
||||||
|
|
||||||
@Value("${app.library.achievement-ttl-hours:1}")
|
@Value("${app.library.achievement-ttl-hours:1}")
|
||||||
private long achievementTtlHours;
|
private long achievementTtlHours;
|
||||||
@@ -96,6 +100,7 @@ public class GameDetailService {
|
|||||||
if (!hidden) {
|
if (!hidden) {
|
||||||
// Only backlog games are checked in bulk; any other game gets its link on a later visit.
|
// Only backlog games are checked in bulk; any other game gets its link on a later visit.
|
||||||
speedrunService.refreshInBackground(List.of(appid));
|
speedrunService.refreshInBackground(List.of(appid));
|
||||||
|
gameEditionService.refreshInBackground(List.of(appid));
|
||||||
}
|
}
|
||||||
if (userGame.getSource() == GameSource.MANUAL) {
|
if (userGame.getSource() == GameSource.MANUAL) {
|
||||||
// Users without a Steam ID never sync, so the time to beat of their games is asked again here.
|
// Users without a Steam ID never sync, so the time to beat of their games is asked again here.
|
||||||
@@ -217,7 +222,11 @@ public class GameDetailService {
|
|||||||
moods.map(GameMoods::getEndless).orElse(null),
|
moods.map(GameMoods::getEndless).orElse(null),
|
||||||
moods.map(GameMoods::getGenres).orElse(List.of()),
|
moods.map(GameMoods::getGenres).orElse(List.of()),
|
||||||
gameCorrectionRepository.findByAppidOrderByField(userGame.getAppid()).stream()
|
gameCorrectionRepository.findByAppidOrderByField(userGame.getAppid()).stream()
|
||||||
.map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList()
|
.map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList(),
|
||||||
|
gameLinkRepository.setOf(userGame.getAppid()).isPresent(),
|
||||||
|
gameLinkRepository.linkedEditions(userGame.getUserId(), userGame.getAppid()).stream()
|
||||||
|
.map(e -> new EditionDto(e.appid(), e.name(), e.platform(), e.source(), e.status(), e.imageUrl()))
|
||||||
|
.toList()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
|
import core_service.modules.igdb_adapter.service.IgdbApiService;
|
||||||
|
import core_service.modules.library.repo.GameEditionRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.task.TaskExecutor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores IGDB's "same game" key per library key, the same way {@link MoodService} stores moods: only for games
|
||||||
|
* without a fresh row, and nothing when IGDB fails.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class GameEditionService {
|
||||||
|
|
||||||
|
private final IgdbApiService igdbApiService;
|
||||||
|
private final GameEditionRepository gameEditionRepository;
|
||||||
|
private final TaskExecutor applicationTaskExecutor;
|
||||||
|
|
||||||
|
@Value("${app.library.edition-ttl-days:30}")
|
||||||
|
private long ttlDays;
|
||||||
|
|
||||||
|
public void refresh(Collection<Long> appids) {
|
||||||
|
if (!igdbApiService.isEnabled() || appids.isEmpty()) return;
|
||||||
|
Set<Long> stale = stale(appids);
|
||||||
|
if (stale.isEmpty()) return;
|
||||||
|
|
||||||
|
IgdbLookup.Answer<IgdbApiService.Edition> answer = IgdbLookup.byAppid(stale, igdbApiService::getEditions,
|
||||||
|
igdbApiService::getEditionsByGameIds);
|
||||||
|
Map<Long, IgdbApiService.Edition> editions = answer.found();
|
||||||
|
if (answer.answered().size() < stale.size()) {
|
||||||
|
log.warn("Editions of {} games not refreshed, IGDB did not answer", stale.size() - answer.answered().size());
|
||||||
|
}
|
||||||
|
Instant now = Instant.now();
|
||||||
|
for (Long appid : answer.answered()) {
|
||||||
|
IgdbApiService.Edition edition = editions.get(appid);
|
||||||
|
gameEditionRepository.upsert(appid, edition == null ? null : edition.igdbId(),
|
||||||
|
edition == null ? null : edition.sameGameId(), now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link #refresh}, but the request does not wait for IGDB, and an error does not fail it.
|
||||||
|
*/
|
||||||
|
public void refreshInBackground(Collection<Long> appids) {
|
||||||
|
if (!igdbApiService.isEnabled()) return;
|
||||||
|
Refreshes.staleInBackground(applicationTaskExecutor, "Editions of " + appids, () -> appids, this::stale,
|
||||||
|
this::refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<Long> stale(Collection<Long> appids) {
|
||||||
|
return Refreshes.stale(appids, gameEditionRepository::findFreshAppids, ttlDays);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.LinkPair;
|
||||||
|
import core_service.modules.library.repo.AdminAuditRepository;
|
||||||
|
import core_service.modules.library.repo.GameLinkRepository;
|
||||||
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
|
import core_service.shared.security.RequiresRole;
|
||||||
|
import core_service.shared.security.Role;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An auditor links keys of one game for every user, from IGDB's suggestions and the users' requests; a user can
|
||||||
|
* ask for a link. Every change is written to the audit log.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiresRole(Role.AUDITOR)
|
||||||
|
@Slf4j
|
||||||
|
public class GameLinkService {
|
||||||
|
|
||||||
|
static final int MAX_OPEN_REQUESTS = 20;
|
||||||
|
|
||||||
|
public enum LinkResult { LINKED, NOT_FOUND, SAME_GAME, ALREADY_LINKED }
|
||||||
|
|
||||||
|
public enum RequestResult { DONE, NOT_FOUND, SAME_GAME, ALREADY_LINKED, DISMISSED, TOO_MANY }
|
||||||
|
|
||||||
|
private final GameLinkRepository linkRepository;
|
||||||
|
private final AdminAuditRepository adminAuditRepository;
|
||||||
|
private final UserGameRepository userGameRepository;
|
||||||
|
private final TransactionTemplate transaction;
|
||||||
|
|
||||||
|
public GameLinkService(GameLinkRepository linkRepository, AdminAuditRepository adminAuditRepository,
|
||||||
|
UserGameRepository userGameRepository, PlatformTransactionManager transactionManager) {
|
||||||
|
this.linkRepository = linkRepository;
|
||||||
|
this.adminAuditRepository = adminAuditRepository;
|
||||||
|
this.userGameRepository = userGameRepository;
|
||||||
|
this.transaction = new TransactionTemplate(transactionManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LinkResult link(long appid, long otherAppid, long auditorId) {
|
||||||
|
if (appid == otherAppid) {
|
||||||
|
return LinkResult.SAME_GAME;
|
||||||
|
}
|
||||||
|
LinkResult result = transaction.execute(tx -> {
|
||||||
|
Map<Long, String> names = linkRepository.lockGames(appid, otherAppid);
|
||||||
|
if (names.size() < 2) {
|
||||||
|
return LinkResult.NOT_FOUND;
|
||||||
|
}
|
||||||
|
if (inOneSet(appid, otherAppid)) {
|
||||||
|
return LinkResult.ALREADY_LINKED;
|
||||||
|
}
|
||||||
|
linkRepository.link(appid, otherAppid);
|
||||||
|
adminAuditRepository.write(auditorId, "GAME_LINK", appid, otherAppid,
|
||||||
|
"\"" + names.get(appid) + "\" with \"" + names.get(otherAppid) + "\"");
|
||||||
|
return LinkResult.LINKED;
|
||||||
|
});
|
||||||
|
if (result == LinkResult.LINKED) {
|
||||||
|
log.info("Auditor {} linked appid={} with appid={}", auditorId, appid, otherAppid);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return false when the game is in no set
|
||||||
|
*/
|
||||||
|
public boolean unlink(long appid, long auditorId) {
|
||||||
|
boolean unlinked = Boolean.TRUE.equals(transaction.execute(tx -> {
|
||||||
|
List<Long> former = linkRepository.unlink(appid);
|
||||||
|
if (former.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
adminAuditRepository.write(auditorId, "GAME_UNLINK", appid, null, "Was linked with "
|
||||||
|
+ linkRepository.names(former).values().stream().map(name -> "\"" + name + "\"")
|
||||||
|
.collect(Collectors.joining(", ")));
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
if (unlinked) {
|
||||||
|
log.info("Auditor {} unlinked appid={}", auditorId, appid);
|
||||||
|
}
|
||||||
|
return unlinked;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page<LinkPair> queue(Pageable pageable) {
|
||||||
|
linkRepository.addIgdbPairs();
|
||||||
|
return new PageImpl<>(linkRepository.openPairs((int) pageable.getOffset(), pageable.getPageSize()), pageable,
|
||||||
|
linkRepository.countOpenPairs());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return false when the pair is not open
|
||||||
|
*/
|
||||||
|
public boolean dismiss(long appid, long otherAppid, long auditorId) {
|
||||||
|
Boolean done = transaction.execute(tx -> {
|
||||||
|
if (!linkRepository.dismiss(appid, otherAppid)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
adminAuditRepository.write(auditorId, "GAME_LINK_DISMISS", appid, otherAppid, null);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return Boolean.TRUE.equals(done);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The method marker wins over the class marker.
|
||||||
|
@RequiresRole(Role.USER)
|
||||||
|
public RequestResult request(long userId, long appid, long otherAppid) {
|
||||||
|
if (appid == otherAppid) {
|
||||||
|
return RequestResult.SAME_GAME;
|
||||||
|
}
|
||||||
|
// The lock makes the cap hold for parallel calls of one user.
|
||||||
|
return transaction.execute(tx -> {
|
||||||
|
linkRepository.lockRequests(userId);
|
||||||
|
if (userGameRepository.findActiveAppids(userId, List.of(appid, otherAppid)).size() < 2) {
|
||||||
|
return RequestResult.NOT_FOUND;
|
||||||
|
}
|
||||||
|
String state = linkRepository.pairState(appid, otherAppid);
|
||||||
|
if ("LINKED".equals(state) || inOneSet(appid, otherAppid)) {
|
||||||
|
return RequestResult.ALREADY_LINKED;
|
||||||
|
}
|
||||||
|
if ("DISMISSED".equals(state)) {
|
||||||
|
return RequestResult.DISMISSED;
|
||||||
|
}
|
||||||
|
if (linkRepository.countOpenRequests(userId) >= MAX_OPEN_REQUESTS) {
|
||||||
|
return RequestResult.TOO_MANY;
|
||||||
|
}
|
||||||
|
linkRepository.addRequest(userId, appid, otherAppid);
|
||||||
|
return RequestResult.DONE;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean inOneSet(long appid, long otherAppid) {
|
||||||
|
Optional<Long> set = linkRepository.setOf(appid);
|
||||||
|
return set.isPresent() && set.equals(linkRepository.setOf(otherAppid));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package core_service.modules.library.service;
|
|||||||
|
|
||||||
import core_service.modules.library.domain.GameKeys;
|
import core_service.modules.library.domain.GameKeys;
|
||||||
import core_service.modules.library.domain.MergeCounts;
|
import core_service.modules.library.domain.MergeCounts;
|
||||||
|
import core_service.modules.library.repo.AdminAuditRepository;
|
||||||
import core_service.modules.library.repo.GameMergeRepository;
|
import core_service.modules.library.repo.GameMergeRepository;
|
||||||
import core_service.shared.security.RequiresRole;
|
import core_service.shared.security.RequiresRole;
|
||||||
import core_service.shared.security.Role;
|
import core_service.shared.security.Role;
|
||||||
@@ -33,13 +34,16 @@ public class GameMergeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private final GameMergeRepository mergeRepository;
|
private final GameMergeRepository mergeRepository;
|
||||||
|
private final AdminAuditRepository adminAuditRepository;
|
||||||
private final TimeToBeatService timeToBeatService;
|
private final TimeToBeatService timeToBeatService;
|
||||||
private final MoodService moodService;
|
private final MoodService moodService;
|
||||||
private final TransactionTemplate transaction;
|
private final TransactionTemplate transaction;
|
||||||
|
|
||||||
public GameMergeService(GameMergeRepository mergeRepository, TimeToBeatService timeToBeatService,
|
public GameMergeService(GameMergeRepository mergeRepository, AdminAuditRepository adminAuditRepository,
|
||||||
|
TimeToBeatService timeToBeatService,
|
||||||
MoodService moodService, PlatformTransactionManager transactionManager) {
|
MoodService moodService, PlatformTransactionManager transactionManager) {
|
||||||
this.mergeRepository = mergeRepository;
|
this.mergeRepository = mergeRepository;
|
||||||
|
this.adminAuditRepository = adminAuditRepository;
|
||||||
this.timeToBeatService = timeToBeatService;
|
this.timeToBeatService = timeToBeatService;
|
||||||
this.moodService = moodService;
|
this.moodService = moodService;
|
||||||
this.transaction = new TransactionTemplate(transactionManager);
|
this.transaction = new TransactionTemplate(transactionManager);
|
||||||
@@ -64,7 +68,7 @@ public class GameMergeService {
|
|||||||
return new Result.NotFound();
|
return new Result.NotFound();
|
||||||
}
|
}
|
||||||
MergeCounts counts = mergeRepository.merge(from, to);
|
MergeCounts counts = mergeRepository.merge(from, to);
|
||||||
mergeRepository.auditMerge(auditorId, from, to,
|
adminAuditRepository.write(auditorId, "GAME_MERGE", from, to,
|
||||||
"\"" + names.get(from) + "\" into \"" + names.get(to) + "\": " + counts.moved() + " moved, "
|
"\"" + names.get(from) + "\" into \"" + names.get(to) + "\": " + counts.moved() + " moved, "
|
||||||
+ counts.combined() + " combined");
|
+ counts.combined() + " combined");
|
||||||
return new Result.Merged(to);
|
return new Result.Merged(to);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import core_service.modules.library.domain.UserGame;
|
|||||||
import core_service.modules.library.domain.UserGameAchievementMeta;
|
import core_service.modules.library.domain.UserGameAchievementMeta;
|
||||||
import core_service.modules.library.domain.UserSteamSync;
|
import core_service.modules.library.domain.UserSteamSync;
|
||||||
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
|
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
|
||||||
|
import core_service.modules.library.repo.GameLinkRepository;
|
||||||
import core_service.modules.library.repo.GameTimeToBeatRepository;
|
import core_service.modules.library.repo.GameTimeToBeatRepository;
|
||||||
import core_service.modules.library.repo.PlaytimeDayRepository;
|
import core_service.modules.library.repo.PlaytimeDayRepository;
|
||||||
import core_service.modules.library.repo.SteamGameRepository;
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
@@ -76,6 +77,7 @@ public class LibraryQueryService {
|
|||||||
private final UserStatsService userStatsService;
|
private final UserStatsService userStatsService;
|
||||||
private final MoodService moodService;
|
private final MoodService moodService;
|
||||||
private final SpeedrunService speedrunService;
|
private final SpeedrunService speedrunService;
|
||||||
|
private final GameLinkRepository gameLinkRepository;
|
||||||
|
|
||||||
@Value("${app.library.zone:Europe/Berlin}")
|
@Value("${app.library.zone:Europe/Berlin}")
|
||||||
private ZoneId zone;
|
private ZoneId zone;
|
||||||
@@ -85,11 +87,11 @@ public class LibraryQueryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Page<GameDto> listOwnedGames(Long userId, boolean hidden, GameStatus status, String nameQuery,
|
public Page<GameDto> listOwnedGames(Long userId, boolean hidden, GameStatus status, String nameQuery,
|
||||||
Integer maxMinutes, Pageable pageable) {
|
Integer maxMinutes, boolean hideDuplicates, Pageable pageable) {
|
||||||
Pageable stable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
|
Pageable stable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
|
||||||
UserGameRepository.querySort(pageable.getSort()));
|
UserGameRepository.querySort(pageable.getSort()));
|
||||||
Page<UserGame> page = userGameRepository.findOwnedGames(
|
Page<UserGame> page = userGameRepository.findOwnedGames(
|
||||||
userId, hidden, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, stable);
|
userId, hidden, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, hideDuplicates, stable);
|
||||||
List<GameDto> games = toDtos(page.getContent());
|
List<GameDto> games = toDtos(page.getContent());
|
||||||
return new PageImpl<>(games, page.getPageable(), page.getTotalElements());
|
return new PageImpl<>(games, page.getPageable(), page.getTotalElements());
|
||||||
}
|
}
|
||||||
@@ -121,9 +123,10 @@ public class LibraryQueryService {
|
|||||||
Map<Long, UserGameAchievementMeta> unlocks = userAchievementMetaRepository
|
Map<Long, UserGameAchievementMeta> unlocks = userAchievementMetaRepository
|
||||||
.findByUserIdAndAppidIn(games.getFirst().getUserId(), appids).stream()
|
.findByUserIdAndAppidIn(games.getFirst().getUserId(), appids).stream()
|
||||||
.collect(Collectors.toMap(UserGameAchievementMeta::getAppid, m -> m));
|
.collect(Collectors.toMap(UserGameAchievementMeta::getAppid, m -> m));
|
||||||
|
Map<Long, Integer> editions = gameLinkRepository.editionCounts(games.getFirst().getUserId(), appids);
|
||||||
return games.stream()
|
return games.stream()
|
||||||
.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()),
|
.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()),
|
||||||
schemas.get(ug.getAppid()), unlocks.get(ug.getAppid())))
|
schemas.get(ug.getAppid()), unlocks.get(ug.getAppid()), editions.get(ug.getAppid())))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +270,8 @@ public class LibraryQueryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time,
|
private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time,
|
||||||
GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks) {
|
GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks,
|
||||||
|
Integer editions) {
|
||||||
Integer achievementsTotal = ug.getSource() == GameSource.STEAM && schema != null && schema.isHasSchema()
|
Integer achievementsTotal = ug.getSource() == GameSource.STEAM && schema != null && schema.isHasSchema()
|
||||||
&& schema.getAchievementsTotal() > 0 ? schema.getAchievementsTotal() : null;
|
&& schema.getAchievementsTotal() > 0 ? schema.getAchievementsTotal() : null;
|
||||||
Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible()
|
Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible()
|
||||||
@@ -286,7 +290,8 @@ public class LibraryQueryService {
|
|||||||
ug.getSource(),
|
ug.getSource(),
|
||||||
ug.getPlatform(),
|
ug.getPlatform(),
|
||||||
meta != null ? meta.getImageUrl() : null,
|
meta != null ? meta.getImageUrl() : null,
|
||||||
ug.getHiddenAt() != null);
|
ug.getHiddenAt() != null,
|
||||||
|
editions);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<UserSteamSync> getSyncStatus(Long userId) {
|
public Optional<UserSteamSync> getSyncStatus(Long userId) {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ public class LibrarySyncWorker {
|
|||||||
private final WishlistGameRepository wishlistGameRepository;
|
private final WishlistGameRepository wishlistGameRepository;
|
||||||
private final TimeToBeatService timeToBeatService;
|
private final TimeToBeatService timeToBeatService;
|
||||||
private final MoodService moodService;
|
private final MoodService moodService;
|
||||||
|
private final GameEditionService gameEditionService;
|
||||||
private final ApplicationEventPublisher eventPublisher;
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
private final TransactionTemplate transaction;
|
private final TransactionTemplate transaction;
|
||||||
|
|
||||||
@@ -139,6 +140,7 @@ public class LibrarySyncWorker {
|
|||||||
igdbAppids.removeAll(new HashSet<>(userGameRepository.findHiddenAppids(userId)));
|
igdbAppids.removeAll(new HashSet<>(userGameRepository.findHiddenAppids(userId)));
|
||||||
timeToBeatService.refresh(igdbAppids);
|
timeToBeatService.refresh(igdbAppids);
|
||||||
moodService.refresh(igdbAppids);
|
moodService.refresh(igdbAppids);
|
||||||
|
gameEditionService.refresh(igdbAppids);
|
||||||
finalStatus = Status.OK;
|
finalStatus = Status.OK;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Sync failed for user {}", userId, e);
|
log.error("Sync failed for user {}", userId, e);
|
||||||
|
|||||||
@@ -66,11 +66,13 @@ public class ManualGameService {
|
|||||||
private final GameMergeRepository mergeRepository;
|
private final GameMergeRepository mergeRepository;
|
||||||
private final TimeToBeatService timeToBeatService;
|
private final TimeToBeatService timeToBeatService;
|
||||||
private final MoodService moodService;
|
private final MoodService moodService;
|
||||||
|
private final GameEditionService gameEditionService;
|
||||||
private final TransactionTemplate transaction;
|
private final TransactionTemplate transaction;
|
||||||
|
|
||||||
public ManualGameService(IgdbApiService igdbApiService, UserGameRepository userGameRepository,
|
public ManualGameService(IgdbApiService igdbApiService, UserGameRepository userGameRepository,
|
||||||
SteamGameRepository steamGameRepository, GameMergeRepository mergeRepository,
|
SteamGameRepository steamGameRepository, GameMergeRepository mergeRepository,
|
||||||
TimeToBeatService timeToBeatService, MoodService moodService,
|
TimeToBeatService timeToBeatService, MoodService moodService,
|
||||||
|
GameEditionService gameEditionService,
|
||||||
PlatformTransactionManager transactionManager) {
|
PlatformTransactionManager transactionManager) {
|
||||||
this.igdbApiService = igdbApiService;
|
this.igdbApiService = igdbApiService;
|
||||||
this.userGameRepository = userGameRepository;
|
this.userGameRepository = userGameRepository;
|
||||||
@@ -78,6 +80,7 @@ public class ManualGameService {
|
|||||||
this.mergeRepository = mergeRepository;
|
this.mergeRepository = mergeRepository;
|
||||||
this.timeToBeatService = timeToBeatService;
|
this.timeToBeatService = timeToBeatService;
|
||||||
this.moodService = moodService;
|
this.moodService = moodService;
|
||||||
|
this.gameEditionService = gameEditionService;
|
||||||
this.transaction = new TransactionTemplate(transactionManager);
|
this.transaction = new TransactionTemplate(transactionManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +172,7 @@ public class ManualGameService {
|
|||||||
if (result instanceof AddResult.Added added) {
|
if (result instanceof AddResult.Added added) {
|
||||||
timeToBeatService.refreshInBackground(List.of(added.appid()));
|
timeToBeatService.refreshInBackground(List.of(added.appid()));
|
||||||
moodService.refreshInBackground(List.of(added.appid()));
|
moodService.refreshInBackground(List.of(added.appid()));
|
||||||
|
gameEditionService.refreshInBackground(List.of(added.appid()));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ public class StatsRepository {
|
|||||||
|
|
||||||
public long countLibraryEntries() {
|
public long countLibraryEntries() {
|
||||||
Long v = jdbc.queryForObject(
|
Long v = jdbc.queryForObject(
|
||||||
"SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
|
"SELECT COUNT(*) FROM counted_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
|
||||||
return v != null ? v : 0L;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long countGamesPlayed() {
|
public long countGamesPlayed() {
|
||||||
Long v = jdbc.queryForObject(
|
Long v = jdbc.queryForObject(
|
||||||
"SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED + " AND playtime_minutes > 0", Long.class);
|
"SELECT COUNT(*) FROM counted_user_game WHERE " + NOT_DELETED_OR_BLOCKED + " AND playtime_minutes > 0", Long.class);
|
||||||
return v != null ? v : 0L;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ public class StatsRepository {
|
|||||||
public long biggestLibrary() {
|
public long biggestLibrary() {
|
||||||
Long v = jdbc.queryForObject("""
|
Long v = jdbc.queryForObject("""
|
||||||
SELECT COALESCE(MAX(c), 0) FROM (
|
SELECT COALESCE(MAX(c), 0) FROM (
|
||||||
SELECT COUNT(*) AS c FROM listed_user_game
|
SELECT COUNT(*) AS c FROM counted_user_game
|
||||||
WHERE %s GROUP BY user_id
|
WHERE %s GROUP BY user_id
|
||||||
) t
|
) t
|
||||||
""".formatted(NOT_DELETED_OR_BLOCKED), Long.class);
|
""".formatted(NOT_DELETED_OR_BLOCKED), Long.class);
|
||||||
@@ -63,7 +63,7 @@ public class StatsRepository {
|
|||||||
public long biggestBacklog() {
|
public long biggestBacklog() {
|
||||||
Long v = jdbc.queryForObject("""
|
Long v = jdbc.queryForObject("""
|
||||||
SELECT COALESCE(MAX(c), 0) FROM (
|
SELECT COALESCE(MAX(c), 0) FROM (
|
||||||
SELECT COUNT(*) AS c FROM listed_user_game
|
SELECT COUNT(*) AS c FROM counted_user_game
|
||||||
WHERE %s AND playtime_minutes = 0 GROUP BY user_id
|
WHERE %s AND playtime_minutes = 0 GROUP BY user_id
|
||||||
) t
|
) t
|
||||||
""".formatted(NOT_DELETED_OR_BLOCKED), Long.class);
|
""".formatted(NOT_DELETED_OR_BLOCKED), Long.class);
|
||||||
@@ -131,7 +131,7 @@ public class StatsRepository {
|
|||||||
return jdbc.queryForList("""
|
return jdbc.queryForList("""
|
||||||
SELECT CAST(EXTRACT(YEAR FROM COALESCE(ug.finished_at_estimate, f.finished_at) AT TIME ZONE ?)
|
SELECT CAST(EXTRACT(YEAR FROM COALESCE(ug.finished_at_estimate, f.finished_at) AT TIME ZONE ?)
|
||||||
AS INTEGER) AS year, COUNT(*) AS games
|
AS INTEGER) AS year, COUNT(*) AS games
|
||||||
FROM listed_user_game ug
|
FROM counted_user_game ug
|
||||||
LEFT JOIN (SELECT sc.user_id AS uid, sc.appid, MIN(sc.changed_at) AS finished_at
|
LEFT JOIN (SELECT sc.user_id AS uid, sc.appid, MIN(sc.changed_at) AS finished_at
|
||||||
FROM user_game_status_change sc
|
FROM user_game_status_change sc
|
||||||
WHERE sc.to_status IN ('FINISHED', 'COMPLETED')
|
WHERE sc.to_status IN ('FINISHED', 'COMPLETED')
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ app:
|
|||||||
achievement-ttl-hours: 1
|
achievement-ttl-hours: 1
|
||||||
schema-ttl-days: 30
|
schema-ttl-days: 30
|
||||||
speedrun-ttl-days: 30
|
speedrun-ttl-days: 30
|
||||||
|
edition-ttl-days: 30
|
||||||
# Reported broken pictures, repaired after the nightly sync.
|
# Reported broken pictures, repaired after the nightly sync.
|
||||||
image-repair-cron: "0 30 1 * * *"
|
image-repair-cron: "0 30 1 * * *"
|
||||||
image-repair-batch: 200
|
image-repair-batch: 200
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- IGDB's "same game" key per library key (ROADMAP item 13 batch 2), shared by all users and refreshed like game_mood.
|
||||||
|
-- same_game_id: version_parent of an edition, parent_game of a remaster or port, else the game's own IGDB id;
|
||||||
|
-- null when IGDB does not know the game. Remakes keep their own id: they are other games.
|
||||||
|
CREATE TABLE game_edition (
|
||||||
|
appid BIGINT PRIMARY KEY,
|
||||||
|
igdb_id BIGINT,
|
||||||
|
same_game_id BIGINT,
|
||||||
|
checked_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX game_edition_same_game_idx ON game_edition (same_game_id);
|
||||||
|
-- The keys an auditor linked as one game, for every user; a set is every row with one set_id. Who and when is in
|
||||||
|
-- admin_audit (GAME_LINK, GAME_UNLINK, GAME_LINK_DISMISS). A merge (V21) moves the merged key's row to the kept key.
|
||||||
|
CREATE SEQUENCE game_link_set_seq;
|
||||||
|
CREATE TABLE game_link (
|
||||||
|
appid BIGINT PRIMARY KEY,
|
||||||
|
set_id BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX game_link_set_idx ON game_link (set_id);
|
||||||
|
-- The auditor queue: a pair IGDB suggests or a user asked for. DISMISSED and LINKED rows keep the pair out of the
|
||||||
|
-- queue for good. appid_a < appid_b.
|
||||||
|
CREATE TABLE game_link_pair (
|
||||||
|
appid_a BIGINT NOT NULL,
|
||||||
|
appid_b BIGINT NOT NULL,
|
||||||
|
state VARCHAR(16) NOT NULL,
|
||||||
|
igdb BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (appid_a, appid_b),
|
||||||
|
CHECK (appid_a < appid_b)
|
||||||
|
);
|
||||||
|
-- Who asked for a pair; the queue shows only the count. Goes with the account (item 16).
|
||||||
|
CREATE TABLE game_link_request (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
appid_a BIGINT NOT NULL,
|
||||||
|
appid_b BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, appid_a, appid_b)
|
||||||
|
);
|
||||||
|
-- The display name Auth pushes with the account state, for the audit log page. Goes with the account (item 16).
|
||||||
|
CREATE TABLE account_name (
|
||||||
|
user_id BIGINT PRIMARY KEY,
|
||||||
|
display_name VARCHAR(100) NOT NULL
|
||||||
|
);
|
||||||
|
-- The games a user's numbers count: listed games, and of a linked set the user owns more than once only its most
|
||||||
|
-- advanced edition (COMPLETED > FINISHED > PLAYING > DROPPED > BACKLOG, then more playtime, then the older row by
|
||||||
|
-- first_seen_at, so a key added today does not beat the Steam row, then the lower appid).
|
||||||
|
-- Same column rule as listed_user_game: a migration that changes a column of user_game drops both views first,
|
||||||
|
-- this one before listed_user_game.
|
||||||
|
CREATE VIEW counted_user_game AS
|
||||||
|
SELECT ug.* FROM listed_user_game ug
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM game_link l
|
||||||
|
JOIN game_link o ON o.set_id = l.set_id AND o.appid <> l.appid
|
||||||
|
JOIN listed_user_game og ON og.user_id = ug.user_id AND og.appid = o.appid
|
||||||
|
WHERE l.appid = ug.appid
|
||||||
|
AND (CASE og.status WHEN 'COMPLETED' THEN 4 WHEN 'FINISHED' THEN 3 WHEN 'PLAYING' THEN 2 WHEN 'DROPPED' THEN 1 ELSE 0 END,
|
||||||
|
og.playtime_minutes, -extract(epoch from og.first_seen_at), -og.appid)
|
||||||
|
> (CASE ug.status WHEN 'COMPLETED' THEN 4 WHEN 'FINISHED' THEN 3 WHEN 'PLAYING' THEN 2 WHEN 'DROPPED' THEN 1 ELSE 0 END,
|
||||||
|
ug.playtime_minutes, -extract(epoch from ug.first_seen_at), -ug.appid));
|
||||||
@@ -45,6 +45,7 @@ class LocalMocksParseTest extends AbstractUnitTest {
|
|||||||
Map.entry("igdb-external-games", IgdbResponses.ExternalGame[].class),
|
Map.entry("igdb-external-games", IgdbResponses.ExternalGame[].class),
|
||||||
Map.entry("igdb-time-to-beats", IgdbResponses.TimeToBeat[].class),
|
Map.entry("igdb-time-to-beats", IgdbResponses.TimeToBeat[].class),
|
||||||
Map.entry("igdb-tags", IgdbResponses.GameTags[].class),
|
Map.entry("igdb-tags", IgdbResponses.GameTags[].class),
|
||||||
|
Map.entry("igdb-editions", IgdbResponses.Edition[].class),
|
||||||
Map.entry("igdb-game-", IgdbResponses.Game[].class),
|
Map.entry("igdb-game-", IgdbResponses.Game[].class),
|
||||||
Map.entry("speedrun-games-", SpeedrunResponses.Games.class),
|
Map.entry("speedrun-games-", SpeedrunResponses.Games.class),
|
||||||
Map.entry("speedrun-runs-", SpeedrunResponses.Runs.class));
|
Map.entry("speedrun-runs-", SpeedrunResponses.Runs.class));
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
|
|||||||
void cleanUp() {
|
void cleanUp() {
|
||||||
jdbc.update("delete from deleted_user where user_id in (?, 42)", USER_ID);
|
jdbc.update("delete from deleted_user where user_id in (?, 42)", USER_ID);
|
||||||
jdbc.update("delete from blocked_user where user_id in (?, 42)", USER_ID);
|
jdbc.update("delete from blocked_user where user_id in (?, 42)", USER_ID);
|
||||||
|
jdbc.update("delete from account_name where user_id in (?, 42)", USER_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String serviceToken() {
|
private static String serviceToken() {
|
||||||
@@ -115,6 +116,21 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
|
|||||||
return request(HttpMethod.valueOf(call.path("method").asString()), call.path("path").asString());
|
return request(HttpMethod.valueOf(call.path("method").asString()), call.path("path").asString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stateCallWithADisplayName_StoresItAndAStateWithoutOneKeepsIt() throws Exception {
|
||||||
|
String token = ContractTokens.serviceToken();
|
||||||
|
JsonNode call = ContractTokens.internalCall("applyStates");
|
||||||
|
|
||||||
|
send(contractRequest(call), call.path("request").toString(), token).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
assertThat(jdbc.queryForObject("select display_name from account_name where user_id = 42", String.class)).isEqualTo("lobo");
|
||||||
|
send(post("/internal/accounts/states"), "{\"accounts\":[{\"userId\":42,\"blocked\":false}]}", token)
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
assertThat(jdbc.queryForObject("select display_name from account_name where user_id = 42", String.class)).isEqualTo("lobo");
|
||||||
|
send(post("/internal/accounts/states"), "{\"accounts\":[{\"userId\":42,\"displayName\":\"" + "x".repeat(101) + "\"}]}", token)
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
// The calls as Auth's CoreAccountService sends them.
|
// The calls as Auth's CoreAccountService sends them.
|
||||||
@Test
|
@Test
|
||||||
void contractCalls_AreAnswered() throws Exception {
|
void contractCalls_AreAnswered() throws Exception {
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
|
|||||||
|
|
||||||
private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement",
|
private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement",
|
||||||
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
|
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
|
||||||
"user_steam_sync", "user_game", "game_import", "reward_ledger", "reward_equipped");
|
"user_steam_sync", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
|
||||||
|
"reward_equipped");
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private AccountCleanupRepository repository;
|
private AccountCleanupRepository repository;
|
||||||
@@ -42,6 +43,8 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
|
|||||||
jdbc.update("""
|
jdbc.update("""
|
||||||
insert into user_steam_sync (user_id, steam_id, user_seen_at) values (?, '76561198000000001', now())""",
|
insert into user_steam_sync (user_id, steam_id, user_seen_at) values (?, '76561198000000001', now())""",
|
||||||
userId);
|
userId);
|
||||||
|
jdbc.update("insert into game_link_request (user_id, appid_a, appid_b) values (?, 220, 620)", userId);
|
||||||
|
jdbc.update("insert into account_name (user_id, display_name) values (?, 'lobo')", userId);
|
||||||
jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'SAVED', 0)", userId);
|
jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'SAVED', 0)", userId);
|
||||||
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', 20, now())", userId);
|
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', 20, now())", userId);
|
||||||
jdbc.update("insert into reward_equipped (user_id, slot, item_code) values (?, 'BADGE', 'BADGE_PILE_SLAYER')", userId);
|
jdbc.update("insert into reward_equipped (user_id, slot, item_code) values (?, 'BADGE', 'BADGE_PILE_SLAYER')", userId);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void seed() {
|
void seed() {
|
||||||
jdbc.update("delete from blocked_user");
|
jdbc.update("delete from blocked_user");
|
||||||
|
jdbc.update("delete from account_name");
|
||||||
for (long userId : List.of(1L, 2L)) {
|
for (long userId : List.of(1L, 2L)) {
|
||||||
jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, user_seen_at) "
|
jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, user_seen_at) "
|
||||||
+ "values (?, ?, now(), now())", userId, String.valueOf(OLD_STEAM_ID));
|
+ "values (?, ?, now(), now())", userId, String.valueOf(OLD_STEAM_ID));
|
||||||
@@ -43,15 +44,26 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
|
|||||||
return jdbc.queryForList("select user_id from blocked_user order by user_id", Long.class);
|
return jdbc.queryForList("select user_id from blocked_user order by user_id", Long.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void displayName_IsStoredAndReplacedAndAStateWithoutOneKeepsIt() {
|
||||||
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, "lobo", false));
|
||||||
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false));
|
||||||
|
assertThat(jdbc.queryForObject("select display_name from account_name where user_id = 1", String.class)).isEqualTo("lobo");
|
||||||
|
|
||||||
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, "wolf", false));
|
||||||
|
|
||||||
|
assertThat(jdbc.queryForList("select display_name from account_name", String.class)).containsExactly("wolf");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void block_StopsTheNightlySyncAndUnblockLiftsTheMarker() {
|
void block_StopsTheNightlySyncAndUnblockLiftsTheMarker() {
|
||||||
repository.apply(new AccountState(1L, OLD_STEAM_ID, true));
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true));
|
||||||
|
|
||||||
assertThat(blocked()).containsExactly(1L);
|
assertThat(blocked()).containsExactly(1L);
|
||||||
assertThat(sync(1L).get("user_seen_at")).isNull();
|
assertThat(sync(1L).get("user_seen_at")).isNull();
|
||||||
assertThat(sync(2L).get("user_seen_at")).isNotNull();
|
assertThat(sync(2L).get("user_seen_at")).isNotNull();
|
||||||
|
|
||||||
repository.apply(new AccountState(1L, OLD_STEAM_ID, false));
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false));
|
||||||
|
|
||||||
assertThat(blocked()).isEmpty();
|
assertThat(blocked()).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -59,7 +71,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
|
|||||||
// steam_id stays: the next sync with the new ID compares with it and drops the old account's games.
|
// steam_id stays: the next sync with the new ID compares with it and drops the old account's games.
|
||||||
@Test
|
@Test
|
||||||
void newSteamId_StopsTheNightlySyncOfTheOldIdAndSyncsOnTheNextVisit() {
|
void newSteamId_StopsTheNightlySyncOfTheOldIdAndSyncsOnTheNextVisit() {
|
||||||
repository.apply(new AccountState(1L, NEW_STEAM_ID, false));
|
repository.apply(new AccountState(1L, NEW_STEAM_ID, null, false));
|
||||||
|
|
||||||
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
|
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
|
||||||
assertThat(sync(1L).get("last_synced_at")).isNull();
|
assertThat(sync(1L).get("last_synced_at")).isNull();
|
||||||
@@ -68,14 +80,14 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void sameSteamId_KeepsTheLastSync() {
|
void sameSteamId_KeepsTheLastSync() {
|
||||||
repository.apply(new AccountState(1L, OLD_STEAM_ID, false));
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false));
|
||||||
|
|
||||||
assertThat(sync(1L).get("last_synced_at")).isNotNull();
|
assertThat(sync(1L).get("last_synced_at")).isNotNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void noSteamId_StopsTheNightlySync() {
|
void noSteamId_StopsTheNightlySync() {
|
||||||
repository.apply(new AccountState(1L, null, false));
|
repository.apply(new AccountState(1L, null, null, false));
|
||||||
|
|
||||||
assertThat(sync(1L).get("user_seen_at")).isNull();
|
assertThat(sync(1L).get("user_seen_at")).isNull();
|
||||||
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
|
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
|
||||||
@@ -83,7 +95,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void userWithoutASyncRow_GetsOnlyTheBlock() {
|
void userWithoutASyncRow_GetsOnlyTheBlock() {
|
||||||
repository.apply(new AccountState(3L, NEW_STEAM_ID, true));
|
repository.apply(new AccountState(3L, NEW_STEAM_ID, null, true));
|
||||||
|
|
||||||
assertThat(blocked()).containsExactly(3L);
|
assertThat(blocked()).containsExactly(3L);
|
||||||
assertThat(jdbc.queryForList("select user_id from user_steam_sync where user_id = 3", Long.class)).isEmpty();
|
assertThat(jdbc.queryForList("select user_id from user_steam_sync where user_id = 3", Long.class)).isEmpty();
|
||||||
@@ -91,10 +103,10 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void replaceAll_LiftsTheBlocksAuthNoLongerSends() {
|
void replaceAll_LiftsTheBlocksAuthNoLongerSends() {
|
||||||
repository.apply(new AccountState(1L, OLD_STEAM_ID, true));
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true));
|
||||||
repository.apply(new AccountState(2L, OLD_STEAM_ID, true));
|
repository.apply(new AccountState(2L, OLD_STEAM_ID, null, true));
|
||||||
|
|
||||||
repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, true)));
|
repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, true)));
|
||||||
assertThat(blocked()).containsExactly(2L);
|
assertThat(blocked()).containsExactly(2L);
|
||||||
|
|
||||||
repository.replaceAll(List.of());
|
repository.replaceAll(List.of());
|
||||||
|
|||||||
@@ -109,9 +109,6 @@ class IgdbApiServiceTest extends AbstractUnitTest {
|
|||||||
Map.entry(220L, new IgdbApiService.GameTags(List.of(), List.of(), List.of())));
|
Map.entry(220L, new IgdbApiService.GameTags(List.of(), List.of(), List.of())));
|
||||||
}
|
}
|
||||||
|
|
||||||
// REVIEW-FINDING: one Steam app linked to two IGDB games (main entry + edition) keeps the tags of whichever game
|
|
||||||
// the HashMap yields first and drops the other's. Here game 72 has no tags and game 233 is Horror; 72 sits in the
|
|
||||||
// earlier bucket, so 620 is stored with no mood. Time to beat picks the most-voted entry; tags should be the union.
|
|
||||||
@Test
|
@Test
|
||||||
void getGameTags_SteamAppLinkedToTwoIgdbGames_KeepsTheTagsOfBoth() {
|
void getGameTags_SteamAppLinkedToTwoIgdbGames_KeepsTheTagsOfBoth() {
|
||||||
expectToken();
|
expectToken();
|
||||||
@@ -201,6 +198,56 @@ class IgdbApiServiceTest extends AbstractUnitTest {
|
|||||||
igdb.verify();
|
igdb.verify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getEditions_ResolvesEditionsRemastersPortsAndSameGame() {
|
||||||
|
expectToken();
|
||||||
|
igdb.expect(requestTo(containsString("/external_games")))
|
||||||
|
.andRespond(withSuccess("[{\"game\": 11, \"uid\": \"1\"}, {\"game\": 12, \"uid\": \"2\"},"
|
||||||
|
+ " {\"game\": 13, \"uid\": \"3\"}, {\"game\": 14, \"uid\": \"4\"},"
|
||||||
|
+ " {\"game\": 15, \"uid\": \"5\"}, {\"game\": 99, \"uid\": \"6\"},"
|
||||||
|
+ " {\"game\": 22, \"uid\": \"7\"}, {\"game\": 21, \"uid\": \"7\"}]",
|
||||||
|
MediaType.APPLICATION_JSON));
|
||||||
|
igdb.expect(requestTo(containsString("/games")))
|
||||||
|
.andExpect(content().string(containsString("fields version_parent,parent_game,game_type;")))
|
||||||
|
.andRespond(withSuccess("[{\"id\": 11, \"version_parent\": 10, \"game_type\": 0},"
|
||||||
|
+ " {\"id\": 12, \"parent_game\": 10, \"game_type\": 11},"
|
||||||
|
+ " {\"id\": 13, \"parent_game\": 10, \"game_type\": 9},"
|
||||||
|
+ " {\"id\": 14, \"parent_game\": 10, \"game_type\": 8},"
|
||||||
|
+ " {\"id\": 15, \"game_type\": 0},"
|
||||||
|
+ " {\"id\": 22, \"game_type\": 0}, {\"id\": 21, \"game_type\": 0}]",
|
||||||
|
MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
Map<Long, IgdbApiService.Edition> editions = service.getEditions(List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L)).orElseThrow();
|
||||||
|
|
||||||
|
assertThat(editions).containsOnlyKeys(1L, 2L, 3L, 4L, 5L, 7L);
|
||||||
|
assertThat(editions.get(1L)).isEqualTo(new IgdbApiService.Edition(11, 10));
|
||||||
|
assertThat(editions.get(2L).sameGameId()).isEqualTo(10);
|
||||||
|
assertThat(editions.get(3L).sameGameId()).isEqualTo(10);
|
||||||
|
assertThat(editions.get(4L).sameGameId()).isEqualTo(14);
|
||||||
|
assertThat(editions.get(5L).sameGameId()).isEqualTo(15);
|
||||||
|
assertThat(editions.get(7L).igdbId()).isEqualTo(21);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getEditionsByGameIds_KeepsOnlyTheAskedGames() {
|
||||||
|
expectToken();
|
||||||
|
igdb.expect(requestTo(containsString("/games")))
|
||||||
|
.andExpect(content().string(containsString("id = (1025)")))
|
||||||
|
.andRespond(withSuccess("[{\"id\": 1025, \"game_type\": 0}, {\"id\": 999, \"version_parent\": 1}]",
|
||||||
|
MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
assertThat(service.getEditionsByGameIds(List.of(1025L)).orElseThrow())
|
||||||
|
.containsOnly(Map.entry(1025L, new IgdbApiService.Edition(1025, 1025)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getEditions_IgdbError_IsEmpty() {
|
||||||
|
expectToken();
|
||||||
|
igdb.expect(requestTo(containsString("/external_games"))).andRespond(withServerError());
|
||||||
|
|
||||||
|
assertThat(service.getEditions(List.of(1L))).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getGameTagsByGameIds_AsksTheGamesDirectly() {
|
void getGameTagsByGameIds_AsksTheGamesDirectly() {
|
||||||
expectToken();
|
expectToken();
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
import core_service.configuration.security.ClientHeaderFilter;
|
||||||
|
import core_service.modules.library.repo.AdminAuditRepository;
|
||||||
|
import core_service.shared.security.Role;
|
||||||
|
import core_service.shared.test.AbstractIntegrationTest;
|
||||||
|
import core_service.shared.test.ContractTokens;
|
||||||
|
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.hasSize;
|
||||||
|
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 audit log through the real (non-dev) filter chain, so the role in the token decides.
|
||||||
|
*/
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ActiveProfiles(value = "prod", inheritProfiles = false)
|
||||||
|
class AuditLogControllerTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private static final long AUDITOR_ID = 6001L;
|
||||||
|
private static final long GAME = -1_930_007L;
|
||||||
|
private static final long GAME_2 = -1_930_008L;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AdminAuditRepository adminAuditRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
jdbc.update("delete from admin_audit");
|
||||||
|
jdbc.update("delete from account_name where user_id = ?", AUDITOR_ID);
|
||||||
|
jdbc.update("insert into account_name (user_id, display_name) values (?, 'lobo')", AUDITOR_ID);
|
||||||
|
jdbc.update("delete from steam_game where appid in (?, ?)", GAME, GAME_2);
|
||||||
|
jdbc.update("insert into steam_game (appid, name) values (?, 'Hades (Switch)'), (?, 'Hades (PS5)')", GAME, GAME_2);
|
||||||
|
adminAuditRepository.write(AUDITOR_ID, "GAME_LINK", GAME, GAME_2, "\"Hades (Switch)\" with \"Hades (PS5)\"");
|
||||||
|
adminAuditRepository.write(AUDITOR_ID, "GAME_UNLINK", GAME, null, "Was linked with \"Hades (PS5)\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResultActions log(Role role, String query) throws Exception {
|
||||||
|
return mockMvc.perform(get("/api/library/audit-log" + query)
|
||||||
|
.header("Authorization", "Bearer " + ContractTokens.loginToken(AUDITOR_ID, role))
|
||||||
|
.header(ClientHeaderFilter.CLIENT_HEADER, "test"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void auditor_GetsTheNewestRowsWithNames() throws Exception {
|
||||||
|
log(Role.AUDITOR, "").andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.totalElements").value(2))
|
||||||
|
.andExpect(jsonPath("$.content[0].action").value("GAME_UNLINK"))
|
||||||
|
.andExpect(jsonPath("$.content[0].actorName").value("lobo"))
|
||||||
|
.andExpect(jsonPath("$.content[0].gameName").value("Hades (Switch)"))
|
||||||
|
.andExpect(jsonPath("$.content[0].createdAt").exists())
|
||||||
|
.andExpect(jsonPath("$.content[1].targetGameName").value("Hades (PS5)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void actionFilter_KeepsOneActionAndAnUnknownOneIsEmpty() throws Exception {
|
||||||
|
log(Role.PLATFORM_ADMIN, "?action=GAME_LINK").andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.content", hasSize(1)))
|
||||||
|
.andExpect(jsonPath("$.content[0].action").value("GAME_LINK"));
|
||||||
|
log(Role.AUDITOR, "?action=NOPE").andExpect(status().isOk()).andExpect(jsonPath("$.content", hasSize(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void user_IsForbidden() throws Exception {
|
||||||
|
log(Role.USER, "").andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
import core_service.configuration.security.ClientHeaderFilter;
|
||||||
|
import core_service.modules.library.domain.GameStatus;
|
||||||
|
import core_service.modules.library.domain.SteamGame;
|
||||||
|
import core_service.modules.library.domain.UserGame;
|
||||||
|
import core_service.modules.library.repo.GameLinkRepository;
|
||||||
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
|
import core_service.shared.security.Role;
|
||||||
|
import core_service.shared.test.AbstractIntegrationTest;
|
||||||
|
import core_service.shared.test.ContractTokens;
|
||||||
|
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.http.MediaType;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.ResultActions;
|
||||||
|
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.BrokenBarrierException;
|
||||||
|
import java.util.concurrent.CyclicBarrier;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.hamcrest.Matchers.hasItem;
|
||||||
|
import static org.hamcrest.Matchers.hasSize;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Links and requests through the real (non-dev) filter chain, so the role in the token decides.
|
||||||
|
*/
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ActiveProfiles(value = "prod", inheritProfiles = false)
|
||||||
|
class GameLinkFlowTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private static final long AUDITOR_ID = 5003L;
|
||||||
|
private static final long USER_A = 5001L;
|
||||||
|
private static final long USER_B = 5002L;
|
||||||
|
private static final long GAME = -1_910_007L;
|
||||||
|
private static final long GAME_2 = -1_910_008L;
|
||||||
|
private static final long FILLER = -1_920_000L;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SteamGameRepository steamGameRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserGameRepository userGameRepository;
|
||||||
|
|
||||||
|
@MockitoSpyBean
|
||||||
|
private GameLinkRepository linkRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
for (String table : List.of("game_link_request", "game_link_pair", "game_link", "game_edition", "admin_audit")) {
|
||||||
|
jdbc.update("delete from " + table);
|
||||||
|
}
|
||||||
|
userGameRepository.deleteAll();
|
||||||
|
jdbc.update("delete from steam_game where appid in (?, ?) or appid <= ?", GAME, GAME_2, FILLER);
|
||||||
|
steamGameRepository.save(new SteamGame(GAME, "Hades (Switch)", null, Instant.now()));
|
||||||
|
steamGameRepository.save(new SteamGame(GAME_2, "Hades (PS5)", null, Instant.now()));
|
||||||
|
for (long user : List.of(USER_A, USER_B)) {
|
||||||
|
own(user, GAME);
|
||||||
|
own(user, GAME_2);
|
||||||
|
}
|
||||||
|
jdbc.update("insert into game_edition (appid, igdb_id, same_game_id, checked_at) values (?, 7, 7, now()), (?, 8, 7, now())",
|
||||||
|
GAME, GAME_2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void own(long userId, long appid) {
|
||||||
|
userGameRepository.save(UserGame.manual(userId, appid, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockHttpServletRequestBuilder as(MockHttpServletRequestBuilder request, long userId, Role role) {
|
||||||
|
return request.header("Authorization", "Bearer " + ContractTokens.loginToken(userId, role))
|
||||||
|
.header(ClientHeaderFilter.CLIENT_HEADER, "test");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResultActions body(MockHttpServletRequestBuilder request, long userId, Role role, String json) throws Exception {
|
||||||
|
return mockMvc.perform(as(request, userId, role).contentType(MediaType.APPLICATION_JSON).content(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResultActions linkAs(Role role, long appid, long otherAppid) throws Exception {
|
||||||
|
return body(post("/api/library/games/" + appid + "/link"), AUDITOR_ID, role, "{\"otherAppid\": " + otherAppid + "}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResultActions requestAs(long userId, long appid, long otherAppid) throws Exception {
|
||||||
|
return body(post("/api/library/games/" + appid + "/link-requests"), userId, Role.USER,
|
||||||
|
"{\"otherAppid\": " + otherAppid + "}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResultActions queue() throws Exception {
|
||||||
|
return mockMvc.perform(as(get("/api/library/link-queue"), AUDITOR_ID, Role.AUDITOR));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> audit() {
|
||||||
|
return jdbc.query("select * from admin_audit order by id", (rs, i) -> rs.getString("action") + " "
|
||||||
|
+ rs.getString("detail"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void queue_ShowsTheIgdbPair() throws Exception {
|
||||||
|
queue().andExpect(status().isOk()).andExpect(jsonPath("$.content", hasSize(1)))
|
||||||
|
.andExpect(jsonPath("$.content[0].igdb").value(true))
|
||||||
|
.andExpect(jsonPath("$.content[0].requests").value(0))
|
||||||
|
.andExpect(jsonPath("$.content[0].a.name").value("Hades (PS5)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void roles_AUserCannotUseTheAuditorCalls() throws Exception {
|
||||||
|
mockMvc.perform(as(get("/api/library/link-queue"), USER_A, Role.USER)).andExpect(status().isForbidden());
|
||||||
|
linkAs(Role.USER, GAME, GAME_2).andExpect(status().isForbidden());
|
||||||
|
mockMvc.perform(as(delete("/api/library/games/" + GAME + "/link"), USER_A, Role.USER)).andExpect(status().isForbidden());
|
||||||
|
mockMvc.perform(as(delete("/api/library/link-queue/" + GAME + "/" + GAME_2), USER_A, Role.USER))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
assertThat(audit()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requests_AreCountedPerUserAndPair() throws Exception {
|
||||||
|
requestAs(USER_A, GAME, GAME_2).andExpect(status().isNoContent());
|
||||||
|
requestAs(USER_B, GAME_2, GAME).andExpect(status().isNoContent());
|
||||||
|
requestAs(USER_A, GAME, GAME_2).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
queue().andExpect(jsonPath("$.content", hasSize(1))).andExpect(jsonPath("$.content[0].requests").value(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requests_BadInput_IsRefused() throws Exception {
|
||||||
|
requestAs(USER_A, GAME, GAME).andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("SAME_GAME"));
|
||||||
|
requestAs(USER_A, GAME, -1_910_999L).andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
|
||||||
|
requestAs(AUDITOR_ID, GAME, GAME_2).andExpect(status().isNotFound());
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from game_link_request", Integer.class)).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requests_The21stOpenOneIsTooMany() throws Exception {
|
||||||
|
for (int i = 0; i < 21; i++) {
|
||||||
|
steamGameRepository.save(new SteamGame(FILLER - i, "Filler " + i, null, Instant.now()));
|
||||||
|
own(USER_A, FILLER - i);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 20; i++) {
|
||||||
|
requestAs(USER_A, GAME, FILLER - i).andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAs(USER_A, GAME, FILLER - 20).andExpect(status().isTooManyRequests())
|
||||||
|
.andExpect(jsonPath("$.code").value("TOO_MANY_REQUESTS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void link_ChangesEveryUsersNumbersAndShowsTheEditions() throws Exception {
|
||||||
|
requestAs(USER_A, GAME, GAME_2).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
mockMvc.perform(as(get("/api/library/summary"), USER_A, Role.USER)).andExpect(jsonPath("$.total").value(2));
|
||||||
|
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
queue().andExpect(jsonPath("$.content", hasSize(0)));
|
||||||
|
assertThat(audit()).containsExactly("GAME_LINK \"Hades (Switch)\" with \"Hades (PS5)\"");
|
||||||
|
for (long user : List.of(USER_A, USER_B)) {
|
||||||
|
mockMvc.perform(as(get("/api/library/games/" + GAME), user, Role.USER)).andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.linked").value(true))
|
||||||
|
.andExpect(jsonPath("$.editions", hasSize(1)))
|
||||||
|
.andExpect(jsonPath("$.editions[0].appid").value(GAME_2));
|
||||||
|
mockMvc.perform(as(get("/api/library/games"), user, Role.USER)).andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.content[*].editions", hasItem(2)));
|
||||||
|
mockMvc.perform(as(get("/api/library/summary"), user, Role.USER)).andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.total").value(1))
|
||||||
|
.andExpect(jsonPath("$.byStatus.BACKLOG").value(1));
|
||||||
|
}
|
||||||
|
requestAs(USER_A, GAME, GAME_2).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("ALREADY_LINKED"));
|
||||||
|
linkAs(Role.AUDITOR, GAME_2, GAME).andExpect(status().isConflict());
|
||||||
|
linkAs(Role.AUDITOR, GAME, GAME).andExpect(status().isBadRequest());
|
||||||
|
linkAs(Role.AUDITOR, GAME, -1_910_999L).andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unlink_DismissesThePairForGood() throws Exception {
|
||||||
|
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
mockMvc.perform(as(delete("/api/library/games/" + GAME + "/link"), AUDITOR_ID, Role.AUDITOR))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
assertThat(audit()).hasSize(2).last().asString().startsWith("GAME_UNLINK").contains("Hades (PS5)");
|
||||||
|
queue().andExpect(jsonPath("$.content", hasSize(0)));
|
||||||
|
queue().andExpect(jsonPath("$.content", hasSize(0)));
|
||||||
|
requestAs(USER_A, GAME, GAME_2).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("PAIR_DISMISSED"));
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from game_link_request", Integer.class)).isZero();
|
||||||
|
mockMvc.perform(as(delete("/api/library/games/" + GAME + "/link"), AUDITOR_ID, Role.AUDITOR))
|
||||||
|
.andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("NOT_LINKED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dismiss_WritesTheAuditRowAndAnswers404TheSecondTime() throws Exception {
|
||||||
|
queue().andExpect(jsonPath("$.content", hasSize(1)));
|
||||||
|
|
||||||
|
mockMvc.perform(as(delete("/api/library/link-queue/" + GAME_2 + "/" + GAME), AUDITOR_ID, Role.AUDITOR))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
mockMvc.perform(as(delete("/api/library/link-queue/" + GAME_2 + "/" + GAME), AUDITOR_ID, Role.AUDITOR))
|
||||||
|
.andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("PAIR_NOT_OPEN"));
|
||||||
|
|
||||||
|
assertThat(audit()).containsExactly("GAME_LINK_DISMISS null");
|
||||||
|
queue().andExpect(jsonPath("$.content", hasSize(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without the per-user lock every call counts 19 before any call inserts (the barrier holds the count until all
|
||||||
|
// calls have counted); with it the first call waits alone, the barrier times out, and the calls run one by one.
|
||||||
|
@Test
|
||||||
|
void requests_ParallelCallsCannotPassTheCap() throws Exception {
|
||||||
|
int parallel = 12;
|
||||||
|
for (int i = 0; i < 19 + parallel; i++) {
|
||||||
|
steamGameRepository.save(new SteamGame(FILLER - i, "Filler " + i, null, Instant.now()));
|
||||||
|
own(USER_A, FILLER - i);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 19; i++) {
|
||||||
|
requestAs(USER_A, GAME, FILLER - i).andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
CyclicBarrier counted = new CyclicBarrier(parallel);
|
||||||
|
doAnswer(call -> {
|
||||||
|
Object count = call.callRealMethod();
|
||||||
|
try {
|
||||||
|
counted.await(500, TimeUnit.MILLISECONDS);
|
||||||
|
} catch (TimeoutException | BrokenBarrierException e) {
|
||||||
|
// The other calls wait for the lock: the barrier is broken, the calls go on one by one.
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}).when(linkRepository).countOpenRequests(USER_A);
|
||||||
|
ExecutorService pool = Executors.newFixedThreadPool(parallel);
|
||||||
|
try {
|
||||||
|
List<Future<ResultActions>> calls = new ArrayList<>();
|
||||||
|
for (int i = 0; i < parallel; i++) {
|
||||||
|
long other = FILLER - 19 - i;
|
||||||
|
calls.add(pool.submit(() -> requestAs(USER_A, GAME, other)));
|
||||||
|
}
|
||||||
|
for (Future<ResultActions> call : calls) {
|
||||||
|
call.get();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pool.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from game_link_request where user_id = ?", Integer.class, USER_A))
|
||||||
|
.isEqualTo(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void queue_AnotherPageIsEmptyAndKeepsTheTotal() throws Exception {
|
||||||
|
mockMvc.perform(as(get("/api/library/link-queue?page=1"), AUDITOR_ID, Role.AUDITOR)).andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.content", hasSize(0))).andExpect(jsonPath("$.totalElements").value(1))
|
||||||
|
.andExpect(jsonPath("$.page").value(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hideDuplicates_ListsOnlyTheEditionTheSetCounts() throws Exception {
|
||||||
|
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
mockMvc.perform(as(get("/api/library/games?hideDuplicates=true"), USER_A, Role.USER)).andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.totalElements").value(1));
|
||||||
|
mockMvc.perform(as(get("/api/library/games"), USER_A, Role.USER)).andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.totalElements").value(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,6 +161,55 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
|
|||||||
+ " \"Hades (Switch)\" into \"Hades\": 1 moved, 0 combined");
|
+ " \"Hades (Switch)\" into \"Hades\": 1 moved, 0 combined");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void merge_MovesTheSetRowAndDropsThePairsRequestsAndEditionOfTheMergedKey() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
|
||||||
|
jdbc.update("delete from game_link");
|
||||||
|
jdbc.update("delete from game_link_pair");
|
||||||
|
jdbc.update("delete from game_link_request");
|
||||||
|
jdbc.update("delete from game_edition");
|
||||||
|
jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", MANUAL, MANUAL_2);
|
||||||
|
jdbc.update("insert into game_link_pair (appid_a, appid_b, state) values (?, ?, 'OPEN')", MANUAL_2, STEAM_2);
|
||||||
|
jdbc.update("insert into game_link_pair (appid_a, appid_b, state) values (?, ?, 'OPEN')", MANUAL_2, MANUAL);
|
||||||
|
jdbc.update("insert into game_link_request (user_id, appid_a, appid_b) values (?, ?, ?)", OTHER_USER, MANUAL_2, STEAM_2);
|
||||||
|
jdbc.update("insert into game_edition (appid, same_game_id, checked_at) values (?, 1, now())", MANUAL);
|
||||||
|
|
||||||
|
merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
|
||||||
|
assertThat(jdbc.queryForList("select appid from game_link order by appid", Long.class)).containsExactly(MANUAL_2, STEAM);
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from game_link_pair where ? in (appid_a, appid_b)", Integer.class, MANUAL))
|
||||||
|
.isZero();
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from game_link_pair", Integer.class)).isEqualTo(1);
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from game_link_request", Integer.class)).isEqualTo(1);
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from game_edition", Integer.class)).isZero();
|
||||||
|
assertThat(audit()).hasSize(1).first().asString().contains("GAME_MERGE");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void merge_IntoAKeyWithItsOwnSet_JoinsBothSets() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
|
||||||
|
jdbc.update("delete from game_link");
|
||||||
|
jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1), (?, 2), (?, 2)",
|
||||||
|
MANUAL, MANUAL_2, STEAM, STEAM_2);
|
||||||
|
|
||||||
|
merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
|
||||||
|
assertThat(jdbc.queryForList("select appid from game_link order by appid", Long.class))
|
||||||
|
.containsExactly(MANUAL_2, STEAM, STEAM_2);
|
||||||
|
assertThat(jdbc.queryForObject("select count(distinct set_id) from game_link", Integer.class)).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void merge_TwoKeysOfOneSet_DropsTheSetThatHasOneKeyLeft() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
|
||||||
|
jdbc.update("delete from game_link");
|
||||||
|
jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", MANUAL, STEAM);
|
||||||
|
|
||||||
|
merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from game_link", Integer.class)).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void paidGameRows_MoveToTheKeptKey_UnlessTheUserHoldsTheKindThereAlready() throws Exception {
|
void paidGameRows_MoveToTheKeptKey_UnlessTheUserHoldsTheKindThereAlready() throws Exception {
|
||||||
manualRow(AUDITOR_ID, MANUAL, GameStatus.FINISHED);
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.FINISHED);
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.AuditEntry;
|
||||||
|
import core_service.shared.test.AbstractPersistenceTest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@Import(AdminAuditRepository.class)
|
||||||
|
class AdminAuditRepositoryTest extends AbstractPersistenceTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AdminAuditRepository repository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
jdbc.update("delete from admin_audit");
|
||||||
|
jdbc.update("delete from account_name");
|
||||||
|
jdbc.update("insert into steam_game (appid, name) values (220, 'Half-Life 2'), (620, 'Portal 2') on conflict do nothing");
|
||||||
|
jdbc.update("insert into account_name (user_id, display_name) values (1, 'lobo')");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findNewest_NewestFirstWithTheNamesFound() {
|
||||||
|
repository.write(1, "GAME_LINK", 220L, 620L, "\"Half-Life 2\" with \"Portal 2\"");
|
||||||
|
repository.write(2, "GAME_MERGE", -5L, 220L, "\"Old\" into \"Half-Life 2\": 1 moved, 0 combined");
|
||||||
|
|
||||||
|
List<AuditEntry> entries = repository.findNewest(null, 0, 10);
|
||||||
|
|
||||||
|
assertThat(entries).extracting(AuditEntry::action).containsExactly("GAME_MERGE", "GAME_LINK");
|
||||||
|
AuditEntry merge = entries.get(0);
|
||||||
|
assertThat(merge.actorName()).isNull();
|
||||||
|
assertThat(merge.actorUserId()).isEqualTo(2);
|
||||||
|
assertThat(merge.gameName()).isNull();
|
||||||
|
assertThat(merge.targetGameName()).isEqualTo("Half-Life 2");
|
||||||
|
AuditEntry link = entries.get(1);
|
||||||
|
assertThat(link.actorName()).isEqualTo("lobo");
|
||||||
|
assertThat(link.gameName()).isEqualTo("Half-Life 2");
|
||||||
|
assertThat(link.targetGameName()).isEqualTo("Portal 2");
|
||||||
|
assertThat(link.createdAt()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findNewest_FiltersByActionAndPages() {
|
||||||
|
repository.write(1, "GAME_LINK", 220L, 620L, null);
|
||||||
|
repository.write(1, "GAME_UNLINK", 220L, null, "Was linked with \"Portal 2\"");
|
||||||
|
repository.write(1, "GAME_LINK", 620L, 220L, null);
|
||||||
|
|
||||||
|
assertThat(repository.findNewest("GAME_LINK", 0, 10)).extracting(AuditEntry::appid).containsExactly(620L, 220L);
|
||||||
|
assertThat(repository.findNewest(null, 1, 1)).extracting(AuditEntry::action).containsExactly("GAME_UNLINK");
|
||||||
|
assertThat(repository.findNewest("GAME_UNLINK", 0, 10).getFirst().targetAppid()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void count_PerAction() {
|
||||||
|
repository.write(1, "GAME_LINK", 220L, 620L, null);
|
||||||
|
repository.write(1, "GAME_UNLINK", 220L, null, null);
|
||||||
|
repository.write(1, "GAME_LINK", 620L, 220L, null);
|
||||||
|
|
||||||
|
assertThat(repository.count(null)).isEqualTo(3);
|
||||||
|
assertThat(repository.count("GAME_LINK")).isEqualTo(2);
|
||||||
|
assertThat(repository.count("GAME_MERGE")).isZero();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,16 @@ class BacklogSnapshotRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertThat(repository.findSince(2L, DAY)).containsExactly(new UserStats.PileDay(DAY, 0, 1, 0));
|
assertThat(repository.findSince(2L, DAY)).containsExactly(new UserStats.PileDay(DAY, 0, 1, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void writeAll_ALinkedSetCountsOnce() {
|
||||||
|
userGameRepository.saveAllAndFlush(List.of(game(1L, 220L, 0, GameStatus.BACKLOG), game(1L, 620L, 0, GameStatus.BACKLOG)));
|
||||||
|
jdbc.update("insert into game_link (appid, set_id) values (220, 1), (620, 1)");
|
||||||
|
|
||||||
|
repository.writeAll(DAY);
|
||||||
|
|
||||||
|
assertThat(repository.findSince(1L, DAY)).containsExactly(new UserStats.PileDay(DAY, 1, 1, 600));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void writeAll_SecondRunOnTheSameDay_ReplacesTheRow() {
|
void writeAll_SecondRunOnTheSameDay_ReplacesTheRow() {
|
||||||
UserGame game = game(1L, 220L, 0, GameStatus.BACKLOG);
|
UserGame game = game(1L, 220L, 0, GameStatus.BACKLOG);
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
|
import core_service.shared.test.AbstractPersistenceTest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
class CountedUserGameViewTest extends AbstractPersistenceTest {
|
||||||
|
|
||||||
|
private static final String COLUMNS = "select column_name from information_schema.columns "
|
||||||
|
+ "where table_schema = 'public' and table_name = ? order by ordinal_position";
|
||||||
|
private static final long USER = 1L;
|
||||||
|
private static final long OTHER_USER = 2L;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
jdbc.update("delete from game_link");
|
||||||
|
jdbc.update("delete from user_game");
|
||||||
|
for (long appid : List.of(-10L, 10L, 20L, 30L, 40L)) {
|
||||||
|
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void set(long appid, long setId) {
|
||||||
|
jdbc.update("insert into game_link (appid, set_id) values (?, ?)", appid, setId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void own(long userId, long appid, String status, int playtime) {
|
||||||
|
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, ?, ?)",
|
||||||
|
userId, appid, status, playtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Long> counted(long userId) {
|
||||||
|
return jdbc.queryForList("select appid from counted_user_game where user_id = ? order by appid", Long.class, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void view_HasEveryColumnOfUserGame() {
|
||||||
|
assertEquals(jdbc.queryForList(COLUMNS, String.class, "user_game"),
|
||||||
|
jdbc.queryForList(COLUMNS, String.class, "counted_user_game"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void set_CountsTheMostAdvancedEdition() {
|
||||||
|
set(10, 1);
|
||||||
|
set(20, 1);
|
||||||
|
own(USER, 10, "BACKLOG", 0);
|
||||||
|
own(USER, 20, "COMPLETED", 0);
|
||||||
|
|
||||||
|
assertThat(counted(USER)).containsExactly(20L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void set_AllBacklog_CountsTheOneWithMorePlaytimeOnce() {
|
||||||
|
set(10, 1);
|
||||||
|
set(20, 1);
|
||||||
|
own(USER, 10, "BACKLOG", 5);
|
||||||
|
own(USER, 20, "BACKLOG", 50);
|
||||||
|
|
||||||
|
assertThat(counted(USER)).containsExactly(20L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void set_FullTieOnStatusAndPlaytime_CountsTheOlderRow() {
|
||||||
|
set(-10, 1);
|
||||||
|
set(20, 1);
|
||||||
|
own(USER, -10, "BACKLOG", 0);
|
||||||
|
own(USER, 20, "BACKLOG", 0);
|
||||||
|
jdbc.update("update user_game set first_seen_at = now() where appid = -10");
|
||||||
|
jdbc.update("update user_game set first_seen_at = now() - interval '3 years' where appid = 20");
|
||||||
|
|
||||||
|
assertThat(counted(USER)).containsExactly(20L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void set_FullTieAlsoOnTime_CountsTheLowerKey() {
|
||||||
|
set(10, 1);
|
||||||
|
set(20, 1);
|
||||||
|
own(USER, 10, "BACKLOG", 0);
|
||||||
|
own(USER, 20, "BACKLOG", 0);
|
||||||
|
jdbc.update("update user_game set first_seen_at = '2025-01-01T00:00:00Z'");
|
||||||
|
|
||||||
|
assertThat(counted(USER)).containsExactly(10L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void set_DroppedBeatsBacklog() {
|
||||||
|
set(10, 1);
|
||||||
|
set(20, 1);
|
||||||
|
own(USER, 10, "BACKLOG", 0);
|
||||||
|
own(USER, 20, "DROPPED", 0);
|
||||||
|
|
||||||
|
assertThat(counted(USER)).containsExactly(20L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void set_HiddenSiblingDoesNotWin() {
|
||||||
|
set(10, 1);
|
||||||
|
set(20, 1);
|
||||||
|
own(USER, 10, "BACKLOG", 0);
|
||||||
|
own(USER, 20, "COMPLETED", 0);
|
||||||
|
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = 20", USER);
|
||||||
|
|
||||||
|
assertThat(counted(USER)).containsExactly(10L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keyInASetTheUserOwnsOnce_IsCounted_AndOtherUsersRowsHaveNoEffect() {
|
||||||
|
set(10, 1);
|
||||||
|
set(20, 1);
|
||||||
|
own(USER, 10, "BACKLOG", 0);
|
||||||
|
own(OTHER_USER, 20, "COMPLETED", 0);
|
||||||
|
own(USER, 30, "BACKLOG", 0);
|
||||||
|
|
||||||
|
assertThat(counted(USER)).containsExactly(10L, 30L);
|
||||||
|
assertThat(counted(OTHER_USER)).containsExactly(20L);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.Edition;
|
||||||
|
import core_service.modules.library.domain.GameStatus;
|
||||||
|
import core_service.modules.library.domain.LinkPair;
|
||||||
|
import core_service.shared.test.AbstractPersistenceTest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@Import(GameLinkRepository.class)
|
||||||
|
class GameLinkRepositoryTest extends AbstractPersistenceTest {
|
||||||
|
|
||||||
|
private static final long USER = 1L;
|
||||||
|
private static final long OTHER_USER = 2L;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GameLinkRepository repository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
for (String table : List.of("game_link_request", "game_link_pair", "game_link", "game_edition", "user_game")) {
|
||||||
|
jdbc.update("delete from " + table);
|
||||||
|
}
|
||||||
|
for (long appid : List.of(100L, 200L, -7L, -8L, 300L)) {
|
||||||
|
jdbc.update("insert into steam_game (appid, name, image_url) values (?, ?, ?) on conflict do nothing",
|
||||||
|
appid, "Game " + appid, "img" + appid);
|
||||||
|
}
|
||||||
|
edition(100, 10);
|
||||||
|
edition(-7, 10);
|
||||||
|
edition(200, 10);
|
||||||
|
edition(-8, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void edition(long appid, long sameGame) {
|
||||||
|
jdbc.update("insert into game_edition (appid, igdb_id, same_game_id, checked_at) values (?, ?, ?, now())",
|
||||||
|
appid, Math.abs(appid), sameGame);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void own(long userId, long appid, String status) {
|
||||||
|
jdbc.update("insert into user_game (user_id, appid, status) values (?, ?, ?)", userId, appid, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> pairs(String state) {
|
||||||
|
return jdbc.queryForList("select appid_a || ':' || appid_b from game_link_pair where state = ? order by 1",
|
||||||
|
String.class, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addIgdbPairs_InsertsEachPairOnce() {
|
||||||
|
assertThat(repository.addIgdbPairs()).isEqualTo(3);
|
||||||
|
assertThat(repository.addIgdbPairs()).isZero();
|
||||||
|
|
||||||
|
assertThat(pairs("OPEN")).containsExactlyInAnyOrder("-7:100", "-7:200", "100:200");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addIgdbPairs_SkipsKeysOfOneSet() {
|
||||||
|
repository.link(100, 200);
|
||||||
|
|
||||||
|
repository.addIgdbPairs();
|
||||||
|
|
||||||
|
assertThat(pairs("OPEN")).containsExactlyInAnyOrder("-7:100", "-7:200");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addIgdbPairs_DoesNotBringBackADismissedPair() {
|
||||||
|
repository.addIgdbPairs();
|
||||||
|
repository.dismiss(100, 200);
|
||||||
|
|
||||||
|
repository.addIgdbPairs();
|
||||||
|
|
||||||
|
assertThat(pairs("DISMISSED")).containsExactly("100:200");
|
||||||
|
assertThat(pairs("OPEN")).doesNotContain("100:200");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void link_MakesExtendsAndMergesSets() {
|
||||||
|
long first = repository.link(100, 200);
|
||||||
|
assertThat(repository.setOf(100)).contains(first);
|
||||||
|
assertThat(repository.setOf(200)).contains(first);
|
||||||
|
|
||||||
|
assertThat(repository.link(200, -7)).isEqualTo(first);
|
||||||
|
assertThat(repository.setOf(-7)).contains(first);
|
||||||
|
|
||||||
|
long second = repository.link(-8, 300);
|
||||||
|
assertThat(second).isNotEqualTo(first);
|
||||||
|
long merged = repository.link(-8, 100);
|
||||||
|
|
||||||
|
assertThat(jdbc.queryForList("select distinct set_id from game_link", Long.class)).containsExactly(merged);
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from game_link", Integer.class)).isEqualTo(5);
|
||||||
|
assertThat(pairs("LINKED")).contains("100:200", "-8:300", "-8:100");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void link_DecidesTheOtherOpenPairsInsideTheSet() {
|
||||||
|
repository.addIgdbPairs();
|
||||||
|
|
||||||
|
repository.link(-7, 100);
|
||||||
|
repository.link(100, 200);
|
||||||
|
|
||||||
|
assertThat(pairs("OPEN")).isEmpty();
|
||||||
|
assertThat(pairs("LINKED")).containsExactlyInAnyOrder("-7:100", "100:200", "-7:200");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unlink_RemovesTheKeyDismissesItsPairsAndDropsASetOfOne() {
|
||||||
|
repository.link(100, 200);
|
||||||
|
repository.link(100, -7);
|
||||||
|
|
||||||
|
assertThat(repository.unlink(-7)).containsExactly(100L, 200L);
|
||||||
|
|
||||||
|
assertThat(repository.setOf(-7)).isEmpty();
|
||||||
|
assertThat(repository.setOf(100)).isPresent();
|
||||||
|
assertThat(pairs("DISMISSED")).containsExactlyInAnyOrder("-7:100", "-7:200");
|
||||||
|
|
||||||
|
assertThat(repository.unlink(100)).containsExactly(200L);
|
||||||
|
assertThat(repository.setOf(200)).isEmpty();
|
||||||
|
assertThat(repository.unlink(100)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dismiss_OnlyAnOpenPair() {
|
||||||
|
repository.addIgdbPairs();
|
||||||
|
|
||||||
|
assertThat(repository.dismiss(200, 100)).isTrue();
|
||||||
|
assertThat(repository.dismiss(100, 200)).isFalse();
|
||||||
|
assertThat(repository.dismiss(100, 300)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRequest_CreatesThePairOnceAndOneRequestPerUser() {
|
||||||
|
assertThat(repository.addRequest(USER, 300, 100)).isTrue();
|
||||||
|
assertThat(repository.addRequest(USER, 100, 300)).isFalse();
|
||||||
|
assertThat(repository.addRequest(OTHER_USER, 100, 300)).isTrue();
|
||||||
|
|
||||||
|
assertThat(pairs("OPEN")).containsExactly("100:300");
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from game_link_request", Integer.class)).isEqualTo(2);
|
||||||
|
assertThat(repository.pairState(300, 100)).isEqualTo("OPEN");
|
||||||
|
assertThat(repository.pairState(100, 200)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addIgdbPairs_MarksAnOpenRequestedPairAsIgdb() {
|
||||||
|
repository.addRequest(USER, 100, 200);
|
||||||
|
|
||||||
|
repository.addIgdbPairs();
|
||||||
|
|
||||||
|
assertThat(repository.openPairs(0, 10)).filteredOn(pair -> pair.a().appid() == 100 && pair.b().appid() == 200)
|
||||||
|
.singleElement().extracting(LinkPair::igdb).isEqualTo(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countOpenRequests_IgnoresPairsThatAreNotOpen() {
|
||||||
|
repository.addRequest(USER, 100, 300);
|
||||||
|
repository.addRequest(USER, 100, 200);
|
||||||
|
repository.addRequest(USER, -7, 300);
|
||||||
|
repository.link(100, 300);
|
||||||
|
repository.dismiss(100, 200);
|
||||||
|
|
||||||
|
assertThat(repository.countOpenRequests(USER)).isEqualTo(1);
|
||||||
|
assertThat(repository.countOpenRequests(OTHER_USER)).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openPairs_CarryNamesImagesRequestsAndTheOrder() {
|
||||||
|
repository.addIgdbPairs();
|
||||||
|
repository.addRequest(USER, 100, 300);
|
||||||
|
repository.addRequest(USER, 200, 300);
|
||||||
|
repository.addRequest(OTHER_USER, 200, 300);
|
||||||
|
repository.link(-7, 100);
|
||||||
|
|
||||||
|
List<LinkPair> pairs = repository.openPairs(0, 10);
|
||||||
|
|
||||||
|
assertThat(pairs).hasSize(4);
|
||||||
|
assertThat(pairs.getFirst().requests()).isEqualTo(2);
|
||||||
|
assertThat(pairs.getFirst().igdb()).isFalse();
|
||||||
|
assertThat(pairs.getFirst().a().appid()).isEqualTo(200);
|
||||||
|
assertThat(pairs.getFirst().b().name()).isEqualTo("Game 300");
|
||||||
|
assertThat(pairs.getFirst().b().imageUrl()).isEqualTo("img300");
|
||||||
|
assertThat(pairs.get(1).requests()).isEqualTo(1);
|
||||||
|
assertThat(pairs.get(2).igdb()).isTrue();
|
||||||
|
assertThat(pairs.get(2).requests()).isZero();
|
||||||
|
assertThat(pairs.get(3).igdb()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openPairs_PageWithoutGapsAndCountOnlyOpenPairs() {
|
||||||
|
repository.addIgdbPairs();
|
||||||
|
|
||||||
|
assertThat(repository.countOpenPairs()).isEqualTo(3);
|
||||||
|
List<LinkPair> first = repository.openPairs(0, 2);
|
||||||
|
List<LinkPair> second = repository.openPairs(2, 2);
|
||||||
|
assertThat(first).hasSize(2);
|
||||||
|
assertThat(second).hasSize(1);
|
||||||
|
assertThat(Stream.concat(first.stream(), second.stream())
|
||||||
|
.map(pair -> pair.a().appid() + ":" + pair.b().appid()))
|
||||||
|
.containsExactlyInAnyOrder("-7:100", "-7:200", "100:200");
|
||||||
|
|
||||||
|
repository.dismiss(100, 200);
|
||||||
|
|
||||||
|
assertThat(repository.countOpenPairs()).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addIgdbPairs_KeepsADismissedPairDismissed() {
|
||||||
|
repository.addRequest(USER, 100, 200);
|
||||||
|
repository.dismiss(100, 200);
|
||||||
|
|
||||||
|
repository.addIgdbPairs();
|
||||||
|
|
||||||
|
assertThat(pairs("DISMISSED")).containsExactly("100:200");
|
||||||
|
assertThat(jdbc.queryForObject("select igdb from game_link_pair where appid_a = 100 and appid_b = 200",
|
||||||
|
Boolean.class)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void linkedEditions_ListsTheOtherListedRowsOfTheUserOnly() {
|
||||||
|
repository.link(100, 200);
|
||||||
|
repository.link(100, -7);
|
||||||
|
repository.link(100, -8);
|
||||||
|
own(USER, 100, "PLAYING");
|
||||||
|
own(USER, 200, "BACKLOG");
|
||||||
|
own(USER, -7, "FINISHED");
|
||||||
|
own(USER, -8, "BACKLOG");
|
||||||
|
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = -8", USER);
|
||||||
|
own(OTHER_USER, 200, "COMPLETED");
|
||||||
|
|
||||||
|
List<Edition> editions = repository.linkedEditions(USER, 100);
|
||||||
|
|
||||||
|
assertThat(editions).extracting(Edition::appid).containsExactly(-7L, 200L);
|
||||||
|
assertThat(editions.getFirst().status()).isEqualTo(GameStatus.FINISHED);
|
||||||
|
assertThat(editions.getFirst().name()).isEqualTo("Game -7");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void linkedEditions_RemovedRowIsLeftOut() {
|
||||||
|
repository.link(100, 200);
|
||||||
|
own(USER, 100, "PLAYING");
|
||||||
|
own(USER, 200, "BACKLOG");
|
||||||
|
jdbc.update("update user_game set removed_at = now() where user_id = ? and appid = 200", USER);
|
||||||
|
|
||||||
|
assertThat(repository.linkedEditions(USER, 100)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void editionCounts_GivesTwoForBothKeysAndNothingForASingleOwnedKey() {
|
||||||
|
repository.link(100, 200);
|
||||||
|
repository.link(100, -7);
|
||||||
|
own(USER, 100, "PLAYING");
|
||||||
|
own(USER, 200, "BACKLOG");
|
||||||
|
own(USER, -7, "BACKLOG");
|
||||||
|
own(OTHER_USER, 100, "BACKLOG");
|
||||||
|
|
||||||
|
assertThat(repository.editionCounts(USER, List.of(100L, 200L, 300L))).isEqualTo(Map.of(100L, 3, 200L, 3));
|
||||||
|
assertThat(repository.editionCounts(OTHER_USER, List.of(100L))).isEmpty();
|
||||||
|
assertThat(repository.editionCounts(USER, List.of())).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.transaction.PlatformTransactionManager;
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
import org.springframework.transaction.TransactionDefinition;
|
import org.springframework.transaction.TransactionDefinition;
|
||||||
import org.springframework.transaction.annotation.Propagation;
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
@@ -37,6 +38,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private UserGameRepository userGameRepository;
|
private UserGameRepository userGameRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private SteamGameRepository steamGameRepository;
|
private SteamGameRepository steamGameRepository;
|
||||||
|
|
||||||
@@ -65,7 +69,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<Long> appids(GameStatus status, String query) {
|
private List<Long> appids(GameStatus status, String query) {
|
||||||
return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, PAGE)
|
return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, false, PAGE)
|
||||||
.map(UserGame::getAppid).getContent();
|
.map(UserGame::getAppid).getContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +198,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findOwnedGames_CountsAllMatchesAcrossPages() {
|
void findOwnedGames_CountsAllMatchesAcrossPages() {
|
||||||
var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null,
|
var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null, false,
|
||||||
PageRequest.of(0, 1, Sort.by("appid")));
|
PageRequest.of(0, 1, Sort.by("appid")));
|
||||||
|
|
||||||
assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent());
|
assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent());
|
||||||
@@ -202,7 +206,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<Long> sortedAppids(Sort sort) {
|
private List<Long> sortedAppids(Sort sort) {
|
||||||
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
|
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, false, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
|
||||||
.map(UserGame::getAppid).getContent();
|
.map(UserGame::getAppid).getContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +257,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
|||||||
new GameTimeToBeat(620L, 300, 480, 900, Instant.now()),
|
new GameTimeToBeat(620L, 300, 480, 900, Instant.now()),
|
||||||
new GameTimeToBeat(380L, null, null, null, Instant.now())));
|
new GameTimeToBeat(380L, null, null, null, Instant.now())));
|
||||||
|
|
||||||
List<Long> shortGames = userGameRepository.findOwnedGames(USER_ID, false, null, null, 600, PAGE).map(UserGame::getAppid).getContent();
|
List<Long> shortGames = userGameRepository.findOwnedGames(USER_ID, false, null, null, 600, false, PAGE).map(UserGame::getAppid).getContent();
|
||||||
|
|
||||||
assertEquals(List.of(620L), shortGames);
|
assertEquals(List.of(620L), shortGames);
|
||||||
}
|
}
|
||||||
@@ -280,7 +284,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
|||||||
hide(620L, 700L);
|
hide(620L, 700L);
|
||||||
|
|
||||||
assertEquals(List.of(220L, 380L), appids(null, null));
|
assertEquals(List.of(220L, 380L), appids(null, null));
|
||||||
assertEquals(List.of(620L, 700L), userGameRepository.findOwnedGames(USER_ID, true, null, null, null,
|
assertEquals(List.of(620L, 700L), userGameRepository.findOwnedGames(USER_ID, true, null, null, null, false,
|
||||||
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
|
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
|
||||||
assertEquals(Set.of(620L, 700L), Set.copyOf(userGameRepository.findHiddenAppids(USER_ID)));
|
assertEquals(Set.of(620L, 700L), Set.copyOf(userGameRepository.findHiddenAppids(USER_ID)));
|
||||||
}
|
}
|
||||||
@@ -345,6 +349,41 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
|||||||
Set.copyOf(userGameRepository.countByStatus(USER_ID)));
|
Set.copyOf(userGameRepository.countByStatus(USER_ID)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void link(long... appids) {
|
||||||
|
jdbc.update("delete from game_link");
|
||||||
|
for (long appid : appids) {
|
||||||
|
jdbc.update("insert into game_link (appid, set_id) values (?, 1)", appid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void linkedBacklogEditions_CountOnceAndTheOnceFilterKeepsOne() {
|
||||||
|
link(380L, 700L);
|
||||||
|
userGameRepository.flush();
|
||||||
|
|
||||||
|
assertEquals(Set.of(new StatusCount(GameStatus.PLAYING, 2), new StatusCount(GameStatus.BACKLOG, 1)),
|
||||||
|
Set.copyOf(userGameRepository.countByStatus(USER_ID)));
|
||||||
|
assertEquals(1, userGameRepository.countListedNeverPlayed(USER_ID));
|
||||||
|
assertEquals(List.of(220L, 380L, 620L), userGameRepository.findOwnedGames(USER_ID, false, null, null, null, true,
|
||||||
|
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
|
||||||
|
assertEquals(List.of(220L, 380L, 620L, 700L), userGameRepository.findOwnedGames(USER_ID, false, null, null, null, false,
|
||||||
|
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backlogEditionOfAPlayingSet_IsNeverPicked() {
|
||||||
|
link(220L, 380L);
|
||||||
|
userGameRepository.flush();
|
||||||
|
|
||||||
|
for (int i = 0; i < 20; i++) {
|
||||||
|
assertEquals(700L, userGameRepository.findRandomBacklogGame(USER_ID, UserGameRepository.NO_GAME).orElseThrow().getAppid());
|
||||||
|
}
|
||||||
|
assertEquals(List.of(700L), userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 10)
|
||||||
|
.stream().map(UserGame::getAppid).toList());
|
||||||
|
assertEquals(Set.of(new StatusCount(GameStatus.PLAYING, 2), new StatusCount(GameStatus.BACKLOG, 1)),
|
||||||
|
Set.copyOf(userGameRepository.countByStatus(USER_ID)));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void countNeverPlayed_CountsOnlyOwnActiveGamesWithoutPlaytime() {
|
void countNeverPlayed_CountsOnlyOwnActiveGamesWithoutPlaytime() {
|
||||||
UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow();
|
UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow();
|
||||||
|
|||||||
@@ -94,6 +94,48 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertThat(repository.pileTimes(USER_ID)).isEqualTo(new UserStats.PileTimes(600, 1200, 1));
|
assertThat(repository.pileTimes(USER_ID)).isEqualTo(new UserStats.PileTimes(600, 1200, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void link(long... appids) {
|
||||||
|
for (long appid : appids) {
|
||||||
|
jdbc.update("insert into game_link (appid, set_id) values (?, 1)", appid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pileTimes_ALinkedSetCountsOnce() {
|
||||||
|
userGameRepository.saveAllAndFlush(List.of(
|
||||||
|
game(220L, 0, GameStatus.BACKLOG, Instant.now()),
|
||||||
|
game(620L, 0, GameStatus.BACKLOG, Instant.now())));
|
||||||
|
gameTimeToBeatRepository.saveAllAndFlush(List.of(new GameTimeToBeat(220L, 300, 600, 1200, Instant.now()),
|
||||||
|
new GameTimeToBeat(620L, 300, 500, 800, Instant.now())));
|
||||||
|
link(220L, 620L);
|
||||||
|
|
||||||
|
assertThat(repository.pileTimes(USER_ID)).isEqualTo(new UserStats.PileTimes(600, 1200, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oldestUnplayed_LeavesOutABacklogEditionOfAPlayedSet() {
|
||||||
|
userGameRepository.saveAllAndFlush(List.of(
|
||||||
|
game(620L, 0, GameStatus.BACKLOG, Instant.EPOCH),
|
||||||
|
game(700L, 60, GameStatus.PLAYING, Instant.now()),
|
||||||
|
game(220L, 0, GameStatus.BACKLOG, Instant.parse("2026-01-01T00:00:00Z"))));
|
||||||
|
link(620L, 700L);
|
||||||
|
|
||||||
|
assertThat(repository.oldestUnplayed(USER_ID).orElseThrow().appid()).isEqualTo(220L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finishedPerYear_ALinkedSetCountsOnce() {
|
||||||
|
userGameRepository.saveAllAndFlush(List.of(
|
||||||
|
game(220L, 300, GameStatus.BACKLOG, Instant.now()),
|
||||||
|
game(620L, 300, GameStatus.BACKLOG, Instant.now())));
|
||||||
|
setStatus(220L, "FINISHED");
|
||||||
|
setStatus(620L, "FINISHED");
|
||||||
|
link(220L, 620L);
|
||||||
|
|
||||||
|
assertThat(repository.finishedPerYear(USER_ID, BERLIN))
|
||||||
|
.containsExactly(new UserStats.YearCount(LocalDate.now(BERLIN).getYear(), 1));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void minutesPlayedSince_LeavesOutOlderDaysAndHiddenGames() {
|
void minutesPlayedSince_LeavesOutOlderDaysAndHiddenGames() {
|
||||||
userGameRepository.saveAllAndFlush(List.of(game(220L, 300, GameStatus.PLAYING, Instant.now())));
|
userGameRepository.saveAllAndFlush(List.of(game(220L, 300, GameStatus.PLAYING, Instant.now())));
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
|
import core_service.modules.igdb_adapter.service.IgdbApiService;
|
||||||
|
import core_service.modules.library.repo.GameEditionRepository;
|
||||||
|
import core_service.shared.test.AbstractUnitTest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.springframework.core.task.SyncTaskExecutor;
|
||||||
|
import org.springframework.core.task.TaskExecutor;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class GameEditionServiceTest extends AbstractUnitTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private IgdbApiService igdbApiService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private GameEditionRepository gameEditionRepository;
|
||||||
|
|
||||||
|
@Spy
|
||||||
|
private TaskExecutor applicationTaskExecutor = new SyncTaskExecutor();
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private GameEditionService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
ReflectionTestUtils.setField(service, "ttlDays", 30L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refresh_StoresStaleGamesOnly_AndNullsForAnAnsweredUnknownGame() {
|
||||||
|
when(igdbApiService.isEnabled()).thenReturn(true);
|
||||||
|
when(gameEditionRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of(220L));
|
||||||
|
when(igdbApiService.getEditions(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of(
|
||||||
|
620L, new IgdbApiService.Edition(72, 10))));
|
||||||
|
|
||||||
|
service.refresh(List.of(220L, 620L, 700L));
|
||||||
|
|
||||||
|
verify(gameEditionRepository).upsert(eq(620L), eq(72L), eq(10L), any(Instant.class));
|
||||||
|
verify(gameEditionRepository).upsert(eq(700L), isNull(), isNull(), any(Instant.class));
|
||||||
|
verify(gameEditionRepository, never()).upsert(eq(220L), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refresh_GamesOffSteam_AreAskedByIgdbIdAndStoredUnderTheirKey() {
|
||||||
|
when(igdbApiService.isEnabled()).thenReturn(true);
|
||||||
|
when(gameEditionRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
|
||||||
|
when(igdbApiService.getEditionsByGameIds(Set.of(119388L))).thenReturn(Optional.of(Map.of(
|
||||||
|
119388L, new IgdbApiService.Edition(119388, 5))));
|
||||||
|
|
||||||
|
service.refresh(List.of(-119388L));
|
||||||
|
|
||||||
|
verify(gameEditionRepository).upsert(eq(-119388L), eq(119388L), eq(5L), any(Instant.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refresh_IgdbFailed_StoresNothingSoTheGamesAreAskedAgain() {
|
||||||
|
when(igdbApiService.isEnabled()).thenReturn(true);
|
||||||
|
when(gameEditionRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
|
||||||
|
when(igdbApiService.getEditions(any())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
service.refresh(List.of(620L));
|
||||||
|
|
||||||
|
verify(gameEditionRepository, never()).upsert(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refresh_IgdbOff_DoesNothing() {
|
||||||
|
when(igdbApiService.isEnabled()).thenReturn(false);
|
||||||
|
|
||||||
|
service.refresh(List.of(620L));
|
||||||
|
|
||||||
|
verifyNoInteractions(gameEditionRepository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,6 +120,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private MoodService moodService;
|
private MoodService moodService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private GameEditionService gameEditionService;
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private ApplicationEventPublisher eventPublisher;
|
private ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
@@ -526,6 +529,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
verify(timeToBeatService).refresh(List.of(220L, -1025L));
|
verify(timeToBeatService).refresh(List.of(220L, -1025L));
|
||||||
verify(moodService).refresh(List.of(220L, -1025L));
|
verify(moodService).refresh(List.of(220L, -1025L));
|
||||||
|
verify(gameEditionService).refresh(List.of(220L, -1025L));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ class ManualGameServiceTest extends AbstractUnitTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private MoodService moodService;
|
private MoodService moodService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private GameEditionService gameEditionService;
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private PlatformTransactionManager transactionManager;
|
private PlatformTransactionManager transactionManager;
|
||||||
|
|
||||||
@@ -58,7 +61,7 @@ class ManualGameServiceTest extends AbstractUnitTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, mergeRepository, timeToBeatService,
|
service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, mergeRepository, timeToBeatService,
|
||||||
moodService, transactionManager);
|
moodService, gameEditionService, transactionManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -66,6 +66,22 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertEquals(0, statsRepository.biggestBacklog());
|
assertEquals(0, statsRepository.biggestBacklog());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aLinkedSet_CountsOnceInLibraryBacklogAndFinishedPerYear() {
|
||||||
|
jdbc.update("insert into game_link (appid, set_id) values (220, 1), (1000, 1)");
|
||||||
|
userGameRepository.saveAllAndFlush(List.of(new UserGame(5L, 220L, 0, Instant.now(), Instant.now()),
|
||||||
|
new UserGame(5L, 1000L, 0, Instant.now(), Instant.now())));
|
||||||
|
|
||||||
|
assertEquals(1, statsRepository.biggestLibrary());
|
||||||
|
assertEquals(1, statsRepository.biggestBacklog());
|
||||||
|
assertEquals(2, statsRepository.countDistinctGames());
|
||||||
|
|
||||||
|
jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 5");
|
||||||
|
|
||||||
|
assertEquals(1L, statsRepository.finishedPerYear("Europe/Berlin").stream()
|
||||||
|
.mapToLong(row -> ((Number) row.get("games")).longValue()).sum());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void hiddenGames_LeaveTheTopLists() {
|
void hiddenGames_LeaveTheTopLists() {
|
||||||
assertEquals(List.of(220L), appids(statsRepository.topOwnedGames(5)));
|
assertEquals(List.of(220L), appids(statsRepository.topOwnedGames(5)));
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import org.testcontainers.containers.PostgreSQLContainer;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Base class for persistence tests.
|
* Base class for persistence tests.
|
||||||
* Slices only the persistence layer with Testcontainers.
|
* Slices only the persistence layer with Testcontainers. Every distinct @Import set caches its own context and pool;
|
||||||
|
* with the default pool of 10 the 100 connections of the container run out at ten contexts.
|
||||||
*/
|
*/
|
||||||
@Tag("PersistenceTest")
|
@Tag("PersistenceTest")
|
||||||
@DataJpaTest
|
@DataJpaTest(properties = "spring.datasource.hikari.maximum-pool-size=4")
|
||||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||||
public abstract class AbstractPersistenceTest {
|
public abstract class AbstractPersistenceTest {
|
||||||
|
|
||||||
|
|||||||
@@ -37,12 +37,12 @@
|
|||||||
"applyStates": {
|
"applyStates": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/internal/accounts/states",
|
"path": "/internal/accounts/states",
|
||||||
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "blocked": true}]}
|
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true}]}
|
||||||
},
|
},
|
||||||
"replaceStates": {
|
"replaceStates": {
|
||||||
"method": "PUT",
|
"method": "PUT",
|
||||||
"path": "/internal/accounts/states",
|
"path": "/internal/accounts/states",
|
||||||
"request": {"accounts": [{"userId": 42, "steamId": null, "blocked": false}]}
|
"request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false}]}
|
||||||
},
|
},
|
||||||
"purge": {
|
"purge": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
|
|||||||
Reference in new issue
Block a user