Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb8341dfef | ||
|
|
44af19d32e |
No files matched your search
@@ -43,7 +43,7 @@ To Be Played is split into three repositories:
|
|||||||
weaker hint compares the playtime with IGDB's normal time to beat (at least 90 %), only for games IGDB does not
|
weaker hint compares the playtime with IGDB's normal time to beat (at least 90 %), only for games IGDB does not
|
||||||
mark as endless (sandbox, survival, 4X, simulator, MMO). "Not yet" hides a hint per game; a dismissed playtime hint
|
mark as endless (sandbox, survival, 4X, simulator, MMO). "Not yet" hides a hint per game; a dismissed playtime hint
|
||||||
still lets the ending hint through.
|
still lets the ending hint through.
|
||||||
- **Hide games**: a user can hide a Steam game (e.g. a playtest or a tool). It leaves every list and number: library,
|
- **Hide games**: a user can hide a game, Steam or added by hand (e.g. a playtest or a tool). It leaves every list and number: library,
|
||||||
picker, Pile of Shame, activity, achievement summary, public profile and landing page stats. The sync still
|
picker, Pile of Shame, activity, achievement summary, public profile and landing page stats. The sync still
|
||||||
updates its playtime, but no achievements, time to beat, moods or speedrun data are fetched for it. The library
|
updates its playtime, but no achievements, time to beat, moods or speedrun data are fetched for it. The library
|
||||||
lists hidden games with `hidden=true`.
|
lists hidden games with `hidden=true`.
|
||||||
@@ -136,16 +136,19 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
|
|||||||
| `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` (`hideDuplicates=true` leaves out the lesser editions of a linked set), `/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`, `time_to_beat` = `{normallyMinutes, completelyMinutes}`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
|
||||||
|
| `GET` | `/api/library/catalog?q=&page=&size=` (every game some user still has, by name part, `PageDto` of `{appid, name, imageUrl, platforms}`; `q` at most 100 characters; size 20, 40 or 80) | JWT, AUDITOR |
|
||||||
|
| `GET` | `/api/library/catalog/{appid}` (what the correction dialog reads: name, image, speedrun, moods, endless, genres, timeToBeat, corrections; `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
|
||||||
| `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` | `/api/library/merges/{mergeId}/undo` (`mergeId` = the `id` of a `GAME_MERGE` audit row, within 30 days; `204`, `404 MERGE_NOT_FOUND`, `409 UNDO_NOT_AVAILABLE` / `NEWER_MERGE`, `410 UNDO_EXPIRED`) | 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 |
|
| `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/audit-log?action=&page=&size=` (the newest `admin_audit` rows, `PageDto` of `{id, actorUserId, actorName, action, appid, gameName, targetAppid, targetGameName, detail, createdAt, undoable}`; `action` is `GAME_MERGE`, `GAME_MERGE_UNDO`, `GAME_LINK`, `GAME_UNLINK`, `GAME_LINK_DISMISS`, `GAME_CORRECT` or `GAME_CORRECTION_RESET`; 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 |
|
| `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 |
|
| `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 |
|
| `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}`; Steam and manual games) | JWT |
|
||||||
| `POST` | `/api/library/imports` (`{source: TEXT\|CSV\|SEARCH\|GALAXY\|GROUVEE\|HLTB\|PLAYNITE\|BOOKMARKLET, rows: [{title, platform, playtimeMinutes, status, igdbId}]}`, 1–1000 rows; `202 {id}` in `QUEUED`, `409 IMPORT_RUNNING`, `503 IGDB_OFF`) | JWT |
|
| `POST` | `/api/library/imports` (`{source: TEXT\|CSV\|SEARCH\|GALAXY\|GROUVEE\|HLTB\|PLAYNITE\|BOOKMARKLET, rows: [{title, platform, playtimeMinutes, status, igdbId}]}`, 1–1000 rows; `202 {id}` in `QUEUED`, `409 IMPORT_RUNNING`, `503 IGDB_OFF`) | JWT |
|
||||||
| `GET` | `/api/library/imports` (`{imports}`, the last 20), `/api/library/imports/{id}` (status, progress counts, `ahead` while it waits in a queue, `attempts`; the rows with their candidates while `READY`) | JWT |
|
| `GET` | `/api/library/imports` (`{imports}`, the last 20), `/api/library/imports/{id}` (status, progress counts, `ahead` while it waits in a queue, `attempts`; the rows with their candidates while `READY`) | JWT |
|
||||||
| `POST` | `/api/library/imports/{id}/save` (`{rows: [{lineNo, igdbId, platform, status, playtimeMinutes}]}`; `202`, then poll until `SAVED`; `409 IMPORT_NOT_READY`) | JWT |
|
| `POST` | `/api/library/imports/{id}/save` (`{rows: [{lineNo, igdbId, platform, status, playtimeMinutes}]}`; `202`, then poll until `SAVED`; `409 IMPORT_NOT_READY`) | JWT |
|
||||||
@@ -311,13 +314,17 @@ a restart.
|
|||||||
|
|
||||||
### Corrections by an auditor
|
### Corrections by an auditor
|
||||||
|
|
||||||
An AUDITOR (active role) corrects a game's name, header image, speedrun link, moods, endless flag and genres for
|
An AUDITOR (active role) corrects a game's name, header image, speedrun link, moods, endless flag, genres and time to beat for
|
||||||
every user (`game_correction`, V20). The value is also written into the cached row (`steam_game`, `game_speedrun`,
|
every user (`game_correction`, V20). The value is also written into the cached row (`steam_game`, `game_speedrun`,
|
||||||
`game_mood`), so no read joins the corrections. The refresh writers skip a corrected field: the Steam sync (name),
|
`game_mood`), so no read joins the corrections. The refresh writers skip a corrected field: the Steam sync (name),
|
||||||
the image repair (image), the IGDB add (name, image), the mood and speedrun upserts (SQL). `SteamGame` has no
|
the image repair (image), the IGDB add (name, image), the mood, speedrun and time to beat upserts (SQL). `SteamGame` has no
|
||||||
setter for name and image: `applyCorrection` is the only Java writer, a refresh uses the guarded updates in
|
setter for name and image: `applyCorrection` is the only Java writer, a refresh uses the guarded updates in
|
||||||
`SteamGameRepository`. "Back to source" (`DELETE`) writes back the source value from the first correction and
|
`SteamGameRepository`. "Back to source" (`DELETE`) writes back the source value from the first correction and
|
||||||
lets the next refresh ask IGDB or speedrun.com again. An image must be on a host of the frontend's CSP.
|
lets the next refresh ask IGDB or speedrun.com again. An image must be on a host of the frontend's CSP.
|
||||||
|
The time to beat is one field for both times (`normallyMinutes`, `completelyMinutes`, each empty or 1 to 60000,
|
||||||
|
completely not below normally) written into `game_time_to_beat`, so pile hours and stats use it; the IGDB `hastily`
|
||||||
|
time is not correctable. Every correction and every reset writes `admin_audit` (`GAME_CORRECT`,
|
||||||
|
`GAME_CORRECTION_RESET`). `GET /api/library/catalog` lets an auditor find and correct a game they do not own.
|
||||||
Known limit: a mood or speedrun refresh that waits on the row lock while an auditor saves can still write the
|
Known limit: a mood or speedrun refresh that waits on the row lock while an auditor saves can still write the
|
||||||
source value once (its guard sees the state from before the save); the auditor saves again. `save(new SteamGame(...))` on a
|
source value once (its guard sees the state from before the save); the auditor saves again. `save(new SteamGame(...))` on a
|
||||||
stored key also overwrites a corrected name; the sync and the IGDB add do it only after `findById` found no row.
|
stored key also overwrites a corrected name; the sync and the IGDB add do it only after `findById` found no row.
|
||||||
@@ -338,7 +345,38 @@ since the sync would bring both back. In one transaction:
|
|||||||
- `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
|
Merges made since V29 can be undone for 30 days (`POST /api/library/merges/{mergeId}/undo`, AUDITOR). The merge saves
|
||||||
|
a copy in its own transaction: `game_merge_undo` (the deleted shared rows as JSON per table, the link set after the
|
||||||
|
merge, the flattened aliases) and `game_merge_undo_user` (per user: both `user_game` rows as JSON, the history ids and the
|
||||||
|
reward ledger ids the merge moved). The copy holds notes, so it goes with the account. A nightly job
|
||||||
|
(`app.library.merge-undo-purge-cron`) deletes copies older than 30 days; older merges answer `410`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> MERGED: POST /games/{appid}/merge (copy saved)
|
||||||
|
MERGED --> UNDONE: POST /merges/{id}/undo (30 days, newest first)
|
||||||
|
MERGED --> FINAL: nightly purge after 30 days
|
||||||
|
```
|
||||||
|
|
||||||
|
The undo, in one transaction (the newest merge of a kept key goes first, else `409 NEWER_MERGE`):
|
||||||
|
|
||||||
|
- Shared rows (catalog row, moods, speedrun, time to beat, corrections, broken images, `game_edition`) come back;
|
||||||
|
`game_alias` of the old key is removed and the aliases the merge flattened point to the old key again.
|
||||||
|
- A user with only the old row: the row moves back if it is still on the new key, still added by hand (no Steam
|
||||||
|
takeover) and the user has no old row. It keeps its current status, rating and notes.
|
||||||
|
- A user with both rows: both rows are back if the kept row still exists. If the user did not change status, rating, notes or
|
||||||
|
priority of the kept row since the merge (compared with a copy taken right after the merge), each row gets its own old
|
||||||
|
values. If they did, both rows get the current values. The kept row gets its Steam ownership back when the merge
|
||||||
|
had replaced it and nothing took the row over since. The old status history goes back to the old key; the log
|
||||||
|
entries the undo itself causes are removed.
|
||||||
|
- Ledger rows the merge moved go back to the old key (so does what a moved user earned on the row after the merge), only when the user has the old row
|
||||||
|
again. Nothing is paid twice. A game the user hid after the merge stays hidden on the restored rows.
|
||||||
|
- Links: when the kept key's set is still as the merge left it, the old sets come back with their `set_id`s; if
|
||||||
|
links changed since, they stay and the old key comes back unlinked (the audit detail says "links kept").
|
||||||
|
- Users who deleted their account are not restored. `admin_audit` gets a `GAME_MERGE_UNDO` row; the `GAME_MERGE` row stays.
|
||||||
|
|
||||||
|
`GET /api/library/games/{appid}` follows `game_alias` when the user has no row on the asked key, and answers with the
|
||||||
|
kept `appid` (the frontend then replaces the URL). After an undo the alias is gone. 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
|
### Editions of one game
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ 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_link_request", "account_name", "game_import", "reward_ledger", "reward_equipped",
|
"user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "reward_equipped",
|
||||||
"blocked_user", "deleted_user");
|
"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.
|
||||||
@@ -88,6 +88,14 @@ public class AccountCleanupRepository {
|
|||||||
for (String table : TABLES) {
|
for (String table : TABLES) {
|
||||||
counts.put(table, jdbc.update("delete from " + table + " where user_id in (:ids)", ids(userIds)));
|
counts.put(table, jdbc.update("delete from " + table + " where user_id in (:ids)", ids(userIds)));
|
||||||
}
|
}
|
||||||
|
// The merge copy keeps the users' link requests as JSON, outside game_merge_undo_user.
|
||||||
|
jdbc.update("""
|
||||||
|
update game_merge_undo set rows = jsonb_set(rows, '{game_link_request}',
|
||||||
|
coalesce((select jsonb_agg(r) from jsonb_array_elements(rows->'game_link_request') r
|
||||||
|
where (r->>'user_id')::bigint not in (:ids)), '[]'))
|
||||||
|
where exists (select 1 from jsonb_array_elements(rows->'game_link_request') r
|
||||||
|
where (r->>'user_id')::bigint in (:ids))
|
||||||
|
""", ids(userIds));
|
||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import core_service.modules.library.api.dto.BacklogPatch;
|
|||||||
import core_service.modules.library.api.dto.AuditEntryDto;
|
import core_service.modules.library.api.dto.AuditEntryDto;
|
||||||
import core_service.modules.library.api.dto.BrokenImageReport;
|
import core_service.modules.library.api.dto.BrokenImageReport;
|
||||||
import core_service.modules.library.api.dto.CatalogGameDto;
|
import core_service.modules.library.api.dto.CatalogGameDto;
|
||||||
|
import core_service.modules.library.api.dto.CatalogGameDetailDto;
|
||||||
|
import core_service.modules.library.api.dto.CatalogHitDto;
|
||||||
|
import core_service.modules.library.api.dto.CorrectionMarkDto;
|
||||||
|
import core_service.modules.library.api.dto.GameDetailDto;
|
||||||
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;
|
||||||
@@ -25,6 +29,8 @@ 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.AuditEntry;
|
||||||
import core_service.modules.library.domain.CatalogGame;
|
import core_service.modules.library.domain.CatalogGame;
|
||||||
|
import core_service.modules.library.domain.CatalogGameDetail;
|
||||||
|
import core_service.modules.library.domain.CatalogHit;
|
||||||
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.LinkPair;
|
||||||
import core_service.modules.library.domain.Mood;
|
import core_service.modules.library.domain.Mood;
|
||||||
@@ -44,6 +50,7 @@ import core_service.modules.library.service.SyncCooldownException;
|
|||||||
import core_service.modules.library.service.UserStatsService;
|
import core_service.modules.library.service.UserStatsService;
|
||||||
import core_service.shared.security.UserPrincipal;
|
import core_service.shared.security.UserPrincipal;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
@@ -194,14 +201,33 @@ public class LibraryController {
|
|||||||
* Back to the source's value. 204 also when the field had no correction.
|
* Back to the source's value. 204 also when the field had no correction.
|
||||||
*/
|
*/
|
||||||
@DeleteMapping("/games/{appid}/corrections/{field}")
|
@DeleteMapping("/games/{appid}/corrections/{field}")
|
||||||
public ResponseEntity<?> resetCorrection(@PathVariable("appid") Long appid, @PathVariable("field") String field) {
|
public ResponseEntity<?> resetCorrection(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@PathVariable("appid") Long appid, @PathVariable("field") String field) {
|
||||||
try {
|
try {
|
||||||
return correctionAnswer(gameCorrectionService.reset(appid, field));
|
return correctionAnswer(gameCorrectionService.reset(appid, field, principal.getIdAsLong()));
|
||||||
} catch (InvalidCorrectionException e) {
|
} catch (InvalidCorrectionException e) {
|
||||||
return invalidCorrection(e.getMessage());
|
return invalidCorrection(e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every game some user has, by name part; blank for all.
|
||||||
|
*/
|
||||||
|
@GetMapping("/catalog")
|
||||||
|
public ResponseEntity<PageDto<CatalogHitDto>> searchCatalog(@RequestParam(required = false) @Size(max = 100) String q,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
|
||||||
|
return ResponseEntity.ok(PageDto.of(gameCorrectionService.search(q, PageRequest.of(Math.max(0, page), safeSize))
|
||||||
|
.map(LibraryController::toDto)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/catalog/{appid}")
|
||||||
|
public ResponseEntity<?> catalogGame(@PathVariable("appid") Long appid) {
|
||||||
|
return gameCorrectionService.detail(appid).<ResponseEntity<?>>map(d -> ResponseEntity.ok(toDto(d)))
|
||||||
|
.orElseGet(LibraryController::unknownGame);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An auditor's merge of two keys of the same game, for every user.
|
* An auditor's merge of two keys of the same game, for every user.
|
||||||
*/
|
*/
|
||||||
@@ -218,6 +244,25 @@ public class LibraryController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undoes a merge made in the last {@value GameMergeService#UNDO_DAYS} days.
|
||||||
|
*/
|
||||||
|
@PostMapping("/merges/{mergeId}/undo")
|
||||||
|
public ResponseEntity<?> undoMerge(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@PathVariable("mergeId") Long mergeId) {
|
||||||
|
return switch (gameMergeService.undo(mergeId, principal.getIdAsLong())) {
|
||||||
|
case GameMergeService.UndoResult.Undone _ -> ResponseEntity.noContent().build();
|
||||||
|
case GameMergeService.UndoResult.NotFound _ -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body(new ErrorResponse("MERGE_NOT_FOUND", "No such merge"));
|
||||||
|
case GameMergeService.UndoResult.NotAvailable _ -> ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
|
.body(new ErrorResponse("UNDO_NOT_AVAILABLE", "This merge was undone or made before undo existed"));
|
||||||
|
case GameMergeService.UndoResult.NewerMerge _ -> ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
|
.body(new ErrorResponse("NEWER_MERGE", "Undo the newer merge of this game first"));
|
||||||
|
case GameMergeService.UndoResult.Expired _ -> ResponseEntity.status(HttpStatus.GONE)
|
||||||
|
.body(new ErrorResponse("UNDO_EXPIRED", "The undo of this merge expired"));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An auditor links two keys as one game, for every user.
|
* An auditor links two keys as one game, for every user.
|
||||||
*/
|
*/
|
||||||
@@ -283,13 +328,26 @@ public class LibraryController {
|
|||||||
return new LinkPairDto(toDto(pair.a()), toDto(pair.b()), pair.igdb(), pair.requests(), pair.createdAt());
|
return new LinkPairDto(toDto(pair.a()), toDto(pair.b()), pair.igdb(), pair.requests(), pair.createdAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static CatalogGameDetailDto toDto(CatalogGameDetail d) {
|
||||||
|
var t = d.timeToBeat();
|
||||||
|
return new CatalogGameDetailDto(d.appid(), d.name(), d.imageUrl(), d.speedrunUrl(), d.moods(), d.endless(),
|
||||||
|
d.genres(), t == null ? null : new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(),
|
||||||
|
t.getNormallyMinutes(), t.getCompletelyMinutes()),
|
||||||
|
d.corrections().stream().map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CatalogHitDto toDto(CatalogHit hit) {
|
||||||
|
return new CatalogHitDto(hit.appid(), hit.name(), hit.imageUrl(), hit.platforms());
|
||||||
|
}
|
||||||
|
|
||||||
private static CatalogGameDto toDto(CatalogGame game) {
|
private static CatalogGameDto toDto(CatalogGame game) {
|
||||||
return new CatalogGameDto(game.appid(), game.name(), game.imageUrl());
|
return new CatalogGameDto(game.appid(), game.name(), game.imageUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AuditEntryDto toDto(AuditEntry entry) {
|
private static AuditEntryDto toDto(AuditEntry entry) {
|
||||||
return new AuditEntryDto(entry.id(), entry.actorUserId(), entry.actorName(), entry.action(), entry.appid(),
|
return new AuditEntryDto(entry.id(), entry.actorUserId(), entry.actorName(), entry.action(), entry.appid(),
|
||||||
entry.gameName(), entry.targetAppid(), entry.targetGameName(), entry.detail(), entry.createdAt());
|
entry.gameName(), entry.targetAppid(), entry.targetGameName(), entry.detail(), entry.createdAt(),
|
||||||
|
entry.undoable());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ResponseEntity<?> sameGame() {
|
private static ResponseEntity<?> sameGame() {
|
||||||
@@ -330,8 +388,6 @@ public class LibraryController {
|
|||||||
case DONE -> ResponseEntity.ok(update);
|
case DONE -> ResponseEntity.ok(update);
|
||||||
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
.body(GAME_NOT_FOUND);
|
.body(GAME_NOT_FOUND);
|
||||||
case MANUAL_GAME -> ResponseEntity.status(HttpStatus.CONFLICT)
|
|
||||||
.body(new ErrorResponse("MANUAL_GAME", "A game added by hand can be deleted, not hidden"));
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import java.time.Instant;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param actorName null when Auth never sent the actor's display name
|
* @param actorName null when Auth never sent the actor's display name
|
||||||
|
* @param undoable a GAME_MERGE row whose merge can still be undone
|
||||||
* @param gameName null when the key has no catalog row any more (a merged key)
|
* @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,
|
public record AuditEntryDto(long id, long actorUserId, String actorName, String action, Long appid, String gameName,
|
||||||
Long targetAppid, String targetGameName, String detail, Instant createdAt) {}
|
Long targetAppid, String targetGameName, String detail, Instant createdAt,
|
||||||
|
boolean undoable) {}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the correction dialog reads; the field names are those of {@link GameDetailDto}.
|
||||||
|
*/
|
||||||
|
public record CatalogGameDetailDto(
|
||||||
|
long appid,
|
||||||
|
String name,
|
||||||
|
String imageUrl,
|
||||||
|
String speedrunUrl,
|
||||||
|
List<String> moods,
|
||||||
|
Boolean endless,
|
||||||
|
List<String> genres,
|
||||||
|
GameDetailDto.TimeToBeatDto timeToBeat,
|
||||||
|
List<CorrectionMarkDto> corrections
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record CatalogHitDto(long appid, String name, String imageUrl, List<String> platforms) {}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package core_service.modules.library.api.dto;
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param value a string (name, image, speedrun), a list of names (moods, genres), a boolean (endless), or null
|
* @param value a string (name, image, speedrun), a list of names (moods, genres), a boolean (endless),
|
||||||
|
* an object {normallyMinutes, completelyMinutes} (time_to_beat), or null
|
||||||
*/
|
*/
|
||||||
public record CorrectionDto(Object value) {}
|
public record CorrectionDto(Object value) {}
|
||||||
@@ -9,4 +9,5 @@ import java.time.Instant;
|
|||||||
* @param gameName null when the key has no catalog row any more (a merged key)
|
* @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,
|
public record AuditEntry(long id, long actorUserId, String actorName, String action, Long appid, String gameName,
|
||||||
Long targetAppid, String targetGameName, String detail, Instant createdAt) {}
|
Long targetAppid, String targetGameName, String detail, Instant createdAt,
|
||||||
|
boolean undoable) {}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an auditor sees of a game any user has; timeToBeat is null without a row.
|
||||||
|
*/
|
||||||
|
public record CatalogGameDetail(long appid, String name, String imageUrl, String speedrunUrl, List<String> moods,
|
||||||
|
Boolean endless, List<String> genres, GameTimeToBeat timeToBeat,
|
||||||
|
List<GameCorrection> corrections) {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A game some user has, as the auditor catalog lists it.
|
||||||
|
*/
|
||||||
|
public record CatalogHit(long appid, String name, String imageUrl, List<String> platforms) {}
|
||||||
@@ -4,7 +4,7 @@ import java.util.Arrays;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public enum CorrectionField {
|
public enum CorrectionField {
|
||||||
NAME, IMAGE, SPEEDRUN, MOODS, ENDLESS, GENRES;
|
NAME, IMAGE, SPEEDRUN, MOODS, ENDLESS, GENRES, TIME_TO_BEAT;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param name the lower-case name in the URL, e.g. "speedrun"
|
* @param name the lower-case name in the URL, e.g. "speedrun"
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The saved copy of one merge.
|
||||||
|
*
|
||||||
|
* @param mergeId the admin_audit id of the merge
|
||||||
|
* @param fromName the name the merged key had; null when its catalog row was not saved
|
||||||
|
* @param createdAt when the merge was made
|
||||||
|
*/
|
||||||
|
public record MergeUndo(long mergeId, long from, long to, String fromName, Instant createdAt) {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param back users whose single row moved back to the old key
|
||||||
|
* @param split users whose combined row is two rows again
|
||||||
|
* @param stayed users whose row could not be restored (taken over by Steam, removed, deleted)
|
||||||
|
* @param linksKept the links changed since the merge, so they were left as they are
|
||||||
|
*/
|
||||||
|
public record UndoCounts(int back, int split, int stayed, boolean linksKept) {}
|
||||||
@@ -6,7 +6,10 @@ import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
|||||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The audit log of what auditors changed for every user (V21).
|
* The audit log of what auditors changed for every user (V21).
|
||||||
@@ -15,24 +18,47 @@ import java.util.List;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AdminAuditRepository {
|
public class AdminAuditRepository {
|
||||||
|
|
||||||
public static final List<String> ACTIONS = List.of("GAME_MERGE", "GAME_LINK", "GAME_UNLINK", "GAME_LINK_DISMISS");
|
public static final List<String> ACTIONS = List.of("GAME_MERGE", "GAME_LINK", "GAME_UNLINK", "GAME_LINK_DISMISS",
|
||||||
|
"GAME_CORRECT", "GAME_CORRECTION_RESET", "GAME_MERGE_UNDO");
|
||||||
|
|
||||||
private final NamedParameterJdbcTemplate jdbc;
|
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) "
|
* @return the id of the new row
|
||||||
+ "values (:actor, :action, :appid, :target, :detail)",
|
*/
|
||||||
|
public long write(long actorUserId, String action, Long appid, Long targetAppid, String detail) {
|
||||||
|
Long id = jdbc.queryForObject("insert into admin_audit (actor_user_id, action, appid, target_appid, detail) "
|
||||||
|
+ "values (:actor, :action, :appid, :target, :detail) returning id",
|
||||||
new MapSqlParameterSource("actor", actorUserId).addValue("action", action)
|
new MapSqlParameterSource("actor", actorUserId).addValue("action", action)
|
||||||
.addValue("appid", appid).addValue("target", targetAppid).addValue("detail", detail));
|
.addValue("appid", appid).addValue("target", targetAppid).addValue("detail", detail),
|
||||||
|
Long.class);
|
||||||
|
return id == null ? 0 : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateDetail(long id, String detail) {
|
||||||
|
jdbc.update("update admin_audit set detail = :detail where id = :id",
|
||||||
|
new MapSqlParameterSource("id", id).addValue("detail", detail));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return when the merge was made; empty when the id is no GAME_MERGE row
|
||||||
|
*/
|
||||||
|
public Optional<Instant> findMergeTime(long id) {
|
||||||
|
return jdbc.query("select created_at from admin_audit where id = :id and action = 'GAME_MERGE'",
|
||||||
|
new MapSqlParameterSource("id", id), (rs, row) -> rs.getTimestamp("created_at").toInstant())
|
||||||
|
.stream().findFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param action null for every action
|
* @param action null for every action
|
||||||
|
* @param undoableSince a merge is undoable when its undo copy is newer than this
|
||||||
*/
|
*/
|
||||||
public List<AuditEntry> findNewest(String action, int offset, int limit) {
|
public List<AuditEntry> findNewest(String action, Instant undoableSince, int offset, int limit) {
|
||||||
return jdbc.query("""
|
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,
|
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
|
a.target_appid, t.name as target_name, a.detail, a.created_at,
|
||||||
|
exists (select 1 from game_merge_undo u where u.merge_id = a.id
|
||||||
|
and u.created_at > :since) as undoable
|
||||||
from admin_audit a
|
from admin_audit a
|
||||||
left join account_name n on n.user_id = a.actor_user_id
|
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 g on g.appid = a.appid
|
||||||
@@ -40,11 +66,12 @@ public class AdminAuditRepository {
|
|||||||
where (cast(:action as varchar) is null or a.action = :action)
|
where (cast(:action as varchar) is null or a.action = :action)
|
||||||
order by a.id desc
|
order by a.id desc
|
||||||
limit :limit offset :offset
|
limit :limit offset :offset
|
||||||
""", new MapSqlParameterSource("action", action).addValue("limit", limit).addValue("offset", offset),
|
""", new MapSqlParameterSource("action", action).addValue("since", Timestamp.from(undoableSince))
|
||||||
|
.addValue("limit", limit).addValue("offset", offset),
|
||||||
(rs, row) -> new AuditEntry(rs.getLong("id"), rs.getLong("actor_user_id"), rs.getString("actor_name"),
|
(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"),
|
rs.getString("action"), (Long) rs.getObject("appid"), rs.getString("game_name"),
|
||||||
(Long) rs.getObject("target_appid"), rs.getString("target_name"), rs.getString("detail"),
|
(Long) rs.getObject("target_appid"), rs.getString("target_name"), rs.getString("detail"),
|
||||||
rs.getTimestamp("created_at").toInstant()));
|
rs.getTimestamp("created_at").toInstant(), rs.getBoolean("undoable")));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.CatalogHit;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The games any user has, for the auditor catalog.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CatalogRepository {
|
||||||
|
|
||||||
|
// ponytail: like '%q%' scans steam_game; add a pg_trgm index if the catalog grows past ~100k rows.
|
||||||
|
private static final String NAME_MATCH = "(cast(:pattern as varchar) is null or lower(g.name) like :pattern escape '\\')";
|
||||||
|
|
||||||
|
private final NamedParameterJdbcTemplate jdbc;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param query a name part; blank for every game
|
||||||
|
*/
|
||||||
|
public List<CatalogHit> search(String query, int offset, int limit) {
|
||||||
|
return jdbc.query("""
|
||||||
|
select g.appid, g.name, g.image_url,
|
||||||
|
array_remove(array_agg(distinct case when ug.source = 'STEAM' then 'Steam' else ug.platform end), null) as platforms
|
||||||
|
from steam_game g join user_game ug on ug.appid = g.appid and ug.removed_at is null
|
||||||
|
where """ + NAME_MATCH + """
|
||||||
|
|
||||||
|
group by g.appid, g.name, g.image_url
|
||||||
|
order by lower(g.name), g.appid
|
||||||
|
limit :limit offset :offset
|
||||||
|
""", new MapSqlParameterSource("pattern", UserGameRepository.containsPattern(query))
|
||||||
|
.addValue("limit", limit).addValue("offset", offset),
|
||||||
|
(rs, row) -> new CatalogHit(rs.getLong("appid"), rs.getString("name"), rs.getString("image_url"),
|
||||||
|
platforms(rs.getArray("platforms"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
public long count(String query) {
|
||||||
|
Long count = jdbc.queryForObject("select count(*) from steam_game g where exists (select 1 from user_game ug "
|
||||||
|
+ "where ug.appid = g.appid and ug.removed_at is null) and " + NAME_MATCH,
|
||||||
|
new MapSqlParameterSource("pattern", UserGameRepository.containsPattern(query)), Long.class);
|
||||||
|
return count == null ? 0 : count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> platforms(java.sql.Array array) throws SQLException {
|
||||||
|
return Arrays.stream((String[]) array.getArray()).sorted().toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
package core_service.modules.library.repo;
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
import core_service.modules.library.domain.MergeCounts;
|
import core_service.modules.library.domain.MergeCounts;
|
||||||
|
import core_service.modules.library.domain.MergeUndo;
|
||||||
|
import core_service.modules.library.domain.UndoCounts;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -23,10 +30,31 @@ public class GameMergeRepository {
|
|||||||
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", "game_edition", "steam_game");
|
"game_correction", "broken_image", "game_edition", "steam_game");
|
||||||
|
|
||||||
|
// The paid game kinds; a merge moves their ref, an undo moves it back.
|
||||||
|
private static final String GAME_KINDS = "('STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED')";
|
||||||
|
|
||||||
|
// The ledger rows of a game kind the merge moves: on from, unless the user holds the kind on to.
|
||||||
|
private static final String LEDGER_MOVED = "l.ref = cast(:from as text) and l.kind in " + GAME_KINDS
|
||||||
|
+ " 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))";
|
||||||
|
|
||||||
|
// What the snapshot saves besides the catalog tables: the link sets of both keys and the pairs and requests of from.
|
||||||
|
private static final String LINK_SET_ROWS = "select * from game_link where set_id in "
|
||||||
|
+ "(select set_id from game_link where appid in (:from, :to))";
|
||||||
|
private static final String LINKS_AFTER = "select coalesce(jsonb_agg(to_jsonb(g) order by g.appid), '[]') "
|
||||||
|
+ "from game_link g where g.set_id = (select set_id from game_link where appid = :to)";
|
||||||
|
|
||||||
|
// The saved shared rows as one JSON object: table name -> array of rows.
|
||||||
|
private static final String SHARED_ROWS = sharedRows();
|
||||||
|
|
||||||
|
// The five fields a user sets by hand on a game; undo compares them with the copy taken after the merge.
|
||||||
|
private static final String HAND_SET_NOW = "(t.status, t.status_set_by_user, t.rating, t.notes, t.priority)";
|
||||||
|
private static final String HAND_SET_MERGED = "(m.status, m.status_set_by_user, m.rating, m.notes, m.priority)";
|
||||||
|
|
||||||
// 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
|
||||||
// have no history). A gap is filled from the other row. A Steam copy the user no longer owns gives way to the
|
// have no history). A gap is filled from the other row. A Steam copy the user no longer owns gives way to the
|
||||||
// copy added by hand, which cannot be hidden.
|
// copy added by hand, with its hide.
|
||||||
private static final String COMBINE = """
|
private static final String COMBINE = """
|
||||||
with f as (
|
with f as (
|
||||||
select f.*,
|
select f.*,
|
||||||
@@ -51,13 +79,68 @@ public class GameMergeRepository {
|
|||||||
playtime_minutes = case when t.removed_at is null then t.playtime_minutes else f.playtime_minutes end,
|
playtime_minutes = case when t.removed_at is null then t.playtime_minutes else f.playtime_minutes end,
|
||||||
playtime_2weeks = case when t.removed_at is null then t.playtime_2weeks else f.playtime_2weeks end,
|
playtime_2weeks = case when t.removed_at is null then t.playtime_2weeks else f.playtime_2weeks end,
|
||||||
removed_at = case when t.removed_at is null then null else f.removed_at end,
|
removed_at = case when t.removed_at is null then null else f.removed_at end,
|
||||||
hidden_at = case when t.removed_at is null then t.hidden_at else null end
|
hidden_at = case when t.removed_at is null then t.hidden_at else f.hidden_at end
|
||||||
from f
|
from f
|
||||||
where t.appid = :to and t.user_id = f.user_id
|
where t.appid = :to and t.user_id = f.user_id
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
// A user with both rows before the merge gets the dropped row back, if the kept row is still there.
|
||||||
|
// Hand-set fields not changed since the merge: each row gets its own old values. Changed: both rows get the
|
||||||
|
// current ones. A game hidden since the merge stays hidden on both rows; the dropped row also keeps its own old hide.
|
||||||
|
// The kept row also gets its Steam ownership back when the merge replaced it (removed copy) and
|
||||||
|
// nothing took the row over since. The update runs on the rows as they were before the insert (one statement).
|
||||||
|
private static final String RESTORE_COMBINED = """
|
||||||
|
with restored as (
|
||||||
|
insert into user_game
|
||||||
|
select r.* from game_merge_undo_user u
|
||||||
|
join user_game t on t.user_id = u.user_id and t.appid = :to
|
||||||
|
cross join lateral jsonb_populate_record(null::user_game, u.merged_row) m
|
||||||
|
cross join lateral jsonb_populate_record(null::user_game,
|
||||||
|
u.from_row
|
||||||
|
|| jsonb_build_object('hidden_at', coalesce(t.hidden_at, (u.from_row->>'hidden_at')::timestamptz), 'import_id',
|
||||||
|
(select i.id from game_import i where i.id = (u.from_row->>'import_id')::bigint))
|
||||||
|
|| case when {SAME} then '{}'::jsonb
|
||||||
|
else jsonb_build_object('status', t.status, 'status_set_by_user', t.status_set_by_user,
|
||||||
|
'rating', t.rating, 'notes', t.notes, 'priority', t.priority) end) r
|
||||||
|
where u.merge_id = :id and u.combined
|
||||||
|
and not exists (select 1 from user_game f where f.user_id = u.user_id and f.appid = :from)
|
||||||
|
returning user_id)
|
||||||
|
update user_game t set
|
||||||
|
status = case when {SAME} then o.status else t.status end,
|
||||||
|
status_set_by_user = case when {SAME} then o.status_set_by_user else t.status_set_by_user end,
|
||||||
|
rating = case when {SAME} then o.rating else t.rating end,
|
||||||
|
notes = case when {SAME} then o.notes else t.notes end,
|
||||||
|
priority = case when {SAME} then o.priority else t.priority end,
|
||||||
|
source = case when {OWN} then o.source else t.source end,
|
||||||
|
platform = case when {OWN} then o.platform else t.platform end,
|
||||||
|
playtime_minutes = case when {OWN} then o.playtime_minutes else t.playtime_minutes end,
|
||||||
|
playtime_2weeks = case when {OWN} then o.playtime_2weeks else t.playtime_2weeks end,
|
||||||
|
removed_at = case when {OWN} then o.removed_at else t.removed_at end,
|
||||||
|
hidden_at = case when t.hidden_at is null and {OWN} then o.hidden_at else t.hidden_at end
|
||||||
|
from game_merge_undo_user u
|
||||||
|
cross join lateral jsonb_populate_record(null::user_game, u.to_row) o
|
||||||
|
cross join lateral jsonb_populate_record(null::user_game, u.merged_row) m
|
||||||
|
where u.merge_id = :id and u.combined and t.user_id = u.user_id and t.appid = :to
|
||||||
|
and t.user_id in (select user_id from restored)
|
||||||
|
""".replace("{SAME}", HAND_SET_NOW + " is not distinct from " + HAND_SET_MERGED)
|
||||||
|
.replace("{OWN}", "o.removed_at is not null and t.source = 'MANUAL'");
|
||||||
|
|
||||||
private final NamedParameterJdbcTemplate jdbc;
|
private final NamedParameterJdbcTemplate jdbc;
|
||||||
|
|
||||||
|
private static String sharedRows() {
|
||||||
|
Map<String, String> sources = new LinkedHashMap<>();
|
||||||
|
for (String table : CATALOG_TABLES) {
|
||||||
|
sources.put(table, "select * from " + table + " where appid = :from");
|
||||||
|
}
|
||||||
|
sources.put("game_link", LINK_SET_ROWS);
|
||||||
|
sources.put("game_link_pair", "select * from game_link_pair where :from in (appid_a, appid_b)");
|
||||||
|
sources.put("game_link_request", "select * from game_link_request where :from in (appid_a, appid_b)");
|
||||||
|
List<String> parts = new ArrayList<>();
|
||||||
|
sources.forEach((name, select) -> parts.add("'" + name + "', (select coalesce(jsonb_agg(to_jsonb(r)), '[]') from ("
|
||||||
|
+ select + ") r)"));
|
||||||
|
return "jsonb_build_object(" + String.join(", ", parts) + ")";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Locks the catalog rows of both keys until the transaction ends, so two merges of one game do not cross.
|
* Locks the catalog rows of both keys until the transaction ends, so two merges of one game do not cross.
|
||||||
*
|
*
|
||||||
@@ -73,13 +156,19 @@ public class GameMergeRepository {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Moves every user row of {@code from} to {@code to}, drops the shared rows of {@code from} and points the
|
* Moves every user row of {@code from} to {@code to}, drops the shared rows of {@code from} and points the
|
||||||
* aliases there. Needs {@link #lockGames} in the same transaction.
|
* aliases there. Saves a copy first, for {@link #undo}. Needs {@link #lockGames} in the same transaction.
|
||||||
|
*
|
||||||
|
* @param mergeId the admin_audit id of the merge
|
||||||
*/
|
*/
|
||||||
public MergeCounts merge(long from, long to) {
|
public MergeCounts merge(long mergeId, long from, long to) {
|
||||||
MapSqlParameterSource keys = new MapSqlParameterSource("from", from).addValue("to", to);
|
MapSqlParameterSource keys = new MapSqlParameterSource("from", from).addValue("to", to)
|
||||||
|
.addValue("id", mergeId);
|
||||||
|
saveSnapshot(keys);
|
||||||
// A user's edit of a from row waits for the merge instead of being copied too early and then deleted.
|
// A user's edit of a from row waits for the merge instead of being copied too early and then deleted.
|
||||||
jdbc.query("select 1 from user_game where appid = :from for update", keys, rs -> { });
|
jdbc.query("select 1 from user_game where appid = :from for update", keys, rs -> { });
|
||||||
int combined = jdbc.update(COMBINE, keys);
|
int combined = jdbc.update(COMBINE, keys);
|
||||||
|
jdbc.update("update game_merge_undo_user u set merged_row = to_jsonb(t) from user_game t "
|
||||||
|
+ "where u.merge_id = :id and u.combined and t.user_id = u.user_id and t.appid = :to", keys);
|
||||||
// Both histories stay on the kept row. The V14 trigger's entry for the status taken over is no real change;
|
// Both histories stay on the kept row. The V14 trigger's entry for the status taken over is no real change;
|
||||||
// it has now(), the time of this transaction. The dropped row's history moves before the row goes.
|
// it has now(), the time of this transaction. The dropped row's history moves before the row goes.
|
||||||
jdbc.update("delete from user_game_status_change where appid = :to and changed_at = now()", keys);
|
jdbc.update("delete from user_game_status_change where appid = :to and changed_at = now()", keys);
|
||||||
@@ -89,12 +178,7 @@ public class GameMergeRepository {
|
|||||||
+ "(select 1 from user_game t where t.user_id = f.user_id and t.appid = :to)", keys);
|
+ "(select 1 from user_game t where t.user_id = f.user_id and t.appid = :to)", keys);
|
||||||
int moved = jdbc.update("update user_game set appid = :to where appid = :from", keys);
|
int moved = jdbc.update("update user_game set appid = :to where appid = :from", keys);
|
||||||
// Paid games stay paid under the kept key; a user who earned both keys keeps both rows.
|
// Paid games stay paid under the kept key; a user who earned both keys keeps both rows.
|
||||||
jdbc.update("""
|
jdbc.update("update reward_ledger l set ref = cast(:to as text) where " + LEDGER_MOVED, keys);
|
||||||
update reward_ledger l set ref = cast(:to as text)
|
|
||||||
where l.ref = cast(:from as text) and l.kind in ('STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED')
|
|
||||||
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))
|
|
||||||
""", keys);
|
|
||||||
mergeSets(from, to, 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_pair where :from in (appid_a, appid_b)", keys);
|
||||||
jdbc.update("delete from game_link_request where :from in (appid_a, appid_b)", keys);
|
jdbc.update("delete from game_link_request where :from in (appid_a, appid_b)", keys);
|
||||||
@@ -104,9 +188,36 @@ public class GameMergeRepository {
|
|||||||
jdbc.update("update game_alias set to_appid = :to where to_appid = :from", keys);
|
jdbc.update("update game_alias set to_appid = :to where to_appid = :from", keys);
|
||||||
jdbc.update("insert into game_alias (from_appid, to_appid) values (:from, :to) "
|
jdbc.update("insert into game_alias (from_appid, to_appid) values (:from, :to) "
|
||||||
+ "on conflict (from_appid) do update set to_appid = excluded.to_appid", keys);
|
+ "on conflict (from_appid) do update set to_appid = excluded.to_appid", keys);
|
||||||
|
jdbc.update("update game_merge_undo set links_after = (" + LINKS_AFTER + ") where merge_id = :id", keys);
|
||||||
return new MergeCounts(moved, combined);
|
return new MergeCounts(moved, combined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The copy an undo restores from: the shared rows of both keys, and per user the rows and ids the merge changes.
|
||||||
|
private void saveSnapshot(MapSqlParameterSource keys) {
|
||||||
|
jdbc.update("insert into game_merge_undo (merge_id, from_appid, to_appid, rows, aliases) "
|
||||||
|
+ "values (:id, :from, :to, " + SHARED_ROWS + ", "
|
||||||
|
+ "(select coalesce(array_agg(from_appid), '{}') from game_alias where to_appid = :from))", keys);
|
||||||
|
jdbc.update("""
|
||||||
|
insert into game_merge_undo_user (merge_id, user_id, combined, from_row, to_row, status_change_ids,
|
||||||
|
ledger_ids)
|
||||||
|
select :id, f.user_id, t.user_id is not null,
|
||||||
|
case when t.user_id is not null then to_jsonb(f) end,
|
||||||
|
case when t.user_id is not null then to_jsonb(t) end,
|
||||||
|
case when t.user_id is not null then
|
||||||
|
coalesce((select array_agg(c.id) from user_game_status_change c
|
||||||
|
where c.user_id = f.user_id and c.appid = :from), '{}')
|
||||||
|
else '{}' end,
|
||||||
|
coalesce((select array_agg(l.id) from reward_ledger l
|
||||||
|
where l.user_id = f.user_id and %s), '{}')
|
||||||
|
from user_game f left join user_game t on t.user_id = f.user_id and t.appid = :to
|
||||||
|
where f.appid = :from
|
||||||
|
""".formatted(LEDGER_MOVED), keys);
|
||||||
|
// The merge moves the ledger of every user; users with no row on from have no per-user entry.
|
||||||
|
jdbc.update("update game_merge_undo set other_ledger_ids = (select coalesce(array_agg(l.id), '{}') "
|
||||||
|
+ "from reward_ledger l where " + LEDGER_MOVED + " and not exists (select 1 from user_game f "
|
||||||
|
+ "where f.user_id = l.user_id and f.appid = :from)) where merge_id = :id", keys);
|
||||||
|
}
|
||||||
|
|
||||||
// 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.
|
// 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.
|
||||||
private void mergeSets(long from, long to, MapSqlParameterSource keys) {
|
private void mergeSets(long from, long to, MapSqlParameterSource keys) {
|
||||||
Optional<Long> fromSet = setOf(from);
|
Optional<Long> fromSet = setOf(from);
|
||||||
@@ -143,4 +254,112 @@ public class GameMergeRepository {
|
|||||||
rs -> { targets.put(rs.getLong("from_appid"), rs.getLong("to_appid")); });
|
rs -> { targets.put(rs.getLong("from_appid"), rs.getLong("to_appid")); });
|
||||||
return targets;
|
return targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the saved copy of a merge, locked until the transaction ends; empty when there is none
|
||||||
|
*/
|
||||||
|
public Optional<MergeUndo> lockUndo(long mergeId) {
|
||||||
|
return jdbc.query("select merge_id, from_appid, to_appid, rows->'steam_game'->0->>'name' as from_name, "
|
||||||
|
+ "created_at from game_merge_undo where merge_id = :id for update",
|
||||||
|
new MapSqlParameterSource("id", mergeId), (rs, row) -> new MergeUndo(rs.getLong("merge_id"),
|
||||||
|
rs.getLong("from_appid"), rs.getLong("to_appid"), rs.getString("from_name"),
|
||||||
|
rs.getTimestamp("created_at").toInstant())).stream().findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return whether a later merge saw the kept key of this one (as its kept or its merged key)
|
||||||
|
*/
|
||||||
|
public boolean hasNewerMerge(long mergeId, long to) {
|
||||||
|
Boolean found = jdbc.queryForObject("select exists (select 1 from game_merge_undo where merge_id > :id "
|
||||||
|
+ "and :to in (to_appid, from_appid))",
|
||||||
|
new MapSqlParameterSource("id", mergeId).addValue("to", to), Boolean.class);
|
||||||
|
return Boolean.TRUE.equals(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives the merged key and its users their rows back and deletes the copy. Needs {@link #lockGames} and
|
||||||
|
* {@link #lockUndo} in the same transaction.
|
||||||
|
*/
|
||||||
|
public UndoCounts undo(MergeUndo undo) {
|
||||||
|
MapSqlParameterSource keys = new MapSqlParameterSource("id", undo.mergeId()).addValue("from", undo.from())
|
||||||
|
.addValue("to", undo.to()).addValue("since", Timestamp.from(undo.createdAt()));
|
||||||
|
// steam_game first, user_game refers to it.
|
||||||
|
List<String> tables = new ArrayList<>(CATALOG_TABLES);
|
||||||
|
Collections.reverse(tables);
|
||||||
|
for (String table : tables) {
|
||||||
|
restoreRows(table, "", keys);
|
||||||
|
}
|
||||||
|
// The V14 trigger logs the restored rows; those entries are no real change and have now().
|
||||||
|
int split = jdbc.update(RESTORE_COMBINED, keys);
|
||||||
|
jdbc.update("delete from user_game_status_change where appid in (:from, :to) and changed_at = now()", keys);
|
||||||
|
jdbc.update("""
|
||||||
|
update user_game_status_change c set appid = :from
|
||||||
|
from game_merge_undo_user u
|
||||||
|
where u.merge_id = :id and u.combined and c.id = any(u.status_change_ids) and c.user_id = u.user_id
|
||||||
|
and c.appid = :to
|
||||||
|
and exists (select 1 from user_game f where f.user_id = c.user_id and f.appid = :from)
|
||||||
|
""", keys);
|
||||||
|
int back = jdbc.update("""
|
||||||
|
update user_game t set appid = :from
|
||||||
|
from game_merge_undo_user u
|
||||||
|
where u.merge_id = :id and not u.combined and t.user_id = u.user_id and t.appid = :to
|
||||||
|
and t.source = 'MANUAL'
|
||||||
|
and not exists (select 1 from user_game f where f.user_id = u.user_id and f.appid = :from)
|
||||||
|
""", keys);
|
||||||
|
// The rows the merge rewrote, and what the moved users earned on the kept key after the merge,
|
||||||
|
// and what a combined user earned from the history that goes back (its event is one of the moved changes).
|
||||||
|
jdbc.update("""
|
||||||
|
update reward_ledger l set ref = cast(:from as text)
|
||||||
|
where (l.id in (select unnest(u.ledger_ids) from game_merge_undo_user u where u.merge_id = :id)
|
||||||
|
or (l.kind in %s and l.ref = cast(:to as text) and l.created_at >= :since
|
||||||
|
and exists (select 1 from game_merge_undo_user u
|
||||||
|
where u.merge_id = :id and u.user_id = l.user_id
|
||||||
|
and (not u.combined or exists (select 1 from user_game_status_change c
|
||||||
|
where c.id = any(u.status_change_ids) and c.changed_at = l.event_at)))))
|
||||||
|
and exists (select 1 from user_game g where g.user_id = l.user_id and g.appid = :from)
|
||||||
|
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(:from as text))
|
||||||
|
""".formatted(GAME_KINDS), keys);
|
||||||
|
// Users with no row on from before the merge: their refs go back too.
|
||||||
|
jdbc.update("""
|
||||||
|
update reward_ledger l set ref = cast(:from as text)
|
||||||
|
where l.id in (select unnest(other_ledger_ids) from game_merge_undo where merge_id = :id)
|
||||||
|
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(:from as text))
|
||||||
|
""", keys);
|
||||||
|
Boolean sameLinks = jdbc.queryForObject("select (" + LINKS_AFTER + ") = (select links_after "
|
||||||
|
+ "from game_merge_undo where merge_id = :id)", keys, Boolean.class);
|
||||||
|
boolean linksKept = !Boolean.TRUE.equals(sameLinks);
|
||||||
|
if (!linksKept) {
|
||||||
|
jdbc.update("""
|
||||||
|
delete from game_link where appid in (select (e->>'appid')::bigint
|
||||||
|
from game_merge_undo, jsonb_array_elements(links_after) e where merge_id = :id)
|
||||||
|
""", keys);
|
||||||
|
restoreRows("game_link", "", keys);
|
||||||
|
}
|
||||||
|
restoreRows("game_link_pair", "", keys);
|
||||||
|
restoreRows("game_link_request", "where exists (select 1 from user_game g where g.user_id = r.user_id)", keys);
|
||||||
|
jdbc.update("delete from game_alias where from_appid = :from", keys);
|
||||||
|
jdbc.update("update game_alias set to_appid = :from where to_appid = :to and from_appid in "
|
||||||
|
+ "(select unnest(aliases) from game_merge_undo where merge_id = :id)", keys);
|
||||||
|
Integer users = jdbc.queryForObject("select count(*) from game_merge_undo_user where merge_id = :id", keys,
|
||||||
|
Integer.class);
|
||||||
|
jdbc.update("delete from game_merge_undo where merge_id = :id", keys);
|
||||||
|
return new UndoCounts(back, split, (users == null ? 0 : users) - back - split, linksKept);
|
||||||
|
}
|
||||||
|
|
||||||
|
// table and condition come from constants only
|
||||||
|
private void restoreRows(String table, String condition, MapSqlParameterSource keys) {
|
||||||
|
jdbc.update("insert into " + table + " select r.* from jsonb_populate_recordset(null::" + table
|
||||||
|
+ ", (select rows->'" + table + "' from game_merge_undo where merge_id = :id)) r " + condition
|
||||||
|
+ " on conflict do nothing", keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return how many saved copies were deleted
|
||||||
|
*/
|
||||||
|
public int deleteUndoBefore(Instant cutoff) {
|
||||||
|
return jdbc.update("delete from game_merge_undo where created_at < :cutoff",
|
||||||
|
new MapSqlParameterSource("cutoff", Timestamp.from(cutoff)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,9 @@ import java.util.Set;
|
|||||||
|
|
||||||
public interface GameTimeToBeatRepository extends JpaRepository<GameTimeToBeat, Long> {
|
public interface GameTimeToBeatRepository extends JpaRepository<GameTimeToBeat, Long> {
|
||||||
|
|
||||||
|
String CORRECTED = "exists (select 1 from game_correction c where c.appid = game_time_to_beat.appid "
|
||||||
|
+ "and c.field = 'TIME_TO_BEAT')";
|
||||||
|
|
||||||
@Query("select t.appid from GameTimeToBeat t where t.appid in :appids and t.fetchedAt > :since")
|
@Query("select t.appid from GameTimeToBeat t where t.appid in :appids and t.fetchedAt > :since")
|
||||||
Set<Long> findFreshAppids(@Param("appids") Collection<Long> appids, @Param("since") Instant since);
|
Set<Long> findFreshAppids(@Param("appids") Collection<Long> appids, @Param("since") Instant since);
|
||||||
|
|
||||||
@@ -22,7 +25,10 @@ public interface GameTimeToBeatRepository extends JpaRepository<GameTimeToBeat,
|
|||||||
@Query(value = "insert into game_time_to_beat (appid, hastily_minutes, normally_minutes, completely_minutes, fetched_at) " +
|
@Query(value = "insert into game_time_to_beat (appid, hastily_minutes, normally_minutes, completely_minutes, fetched_at) " +
|
||||||
"values (:appid, :hastily, :normally, :completely, :fetchedAt) " +
|
"values (:appid, :hastily, :normally, :completely, :fetchedAt) " +
|
||||||
"on conflict (appid) do update set hastily_minutes = excluded.hastily_minutes, " +
|
"on conflict (appid) do update set hastily_minutes = excluded.hastily_minutes, " +
|
||||||
"normally_minutes = excluded.normally_minutes, completely_minutes = excluded.completely_minutes, " +
|
"normally_minutes = case when " + CORRECTED + " then game_time_to_beat.normally_minutes " +
|
||||||
|
"else excluded.normally_minutes end, " +
|
||||||
|
"completely_minutes = case when " + CORRECTED + " then game_time_to_beat.completely_minutes " +
|
||||||
|
"else excluded.completely_minutes end, " +
|
||||||
"fetched_at = excluded.fetched_at",
|
"fetched_at = excluded.fetched_at",
|
||||||
nativeQuery = true)
|
nativeQuery = true)
|
||||||
void upsert(@Param("appid") Long appid,
|
void upsert(@Param("appid") Long appid,
|
||||||
@@ -30,4 +36,21 @@ public interface GameTimeToBeatRepository extends JpaRepository<GameTimeToBeat,
|
|||||||
@Param("normally") Integer normallyMinutes,
|
@Param("normally") Integer normallyMinutes,
|
||||||
@Param("completely") Integer completelyMinutes,
|
@Param("completely") Integer completelyMinutes,
|
||||||
@Param("fetchedAt") Instant fetchedAt);
|
@Param("fetchedAt") Instant fetchedAt);
|
||||||
|
|
||||||
|
// fetchedAt is set only on a new row, i.e. a game IGDB has no times for.
|
||||||
|
@Modifying
|
||||||
|
@Query(value = "insert into game_time_to_beat (appid, hastily_minutes, normally_minutes, completely_minutes, fetched_at) " +
|
||||||
|
"values (:appid, null, :normally, :completely, :fetchedAt) " +
|
||||||
|
"on conflict (appid) do update set normally_minutes = excluded.normally_minutes, " +
|
||||||
|
"completely_minutes = excluded.completely_minutes",
|
||||||
|
nativeQuery = true)
|
||||||
|
void writeCorrected(@Param("appid") Long appid, @Param("normally") Integer normally,
|
||||||
|
@Param("completely") Integer completely, @Param("fetchedAt") Instant fetchedAt);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next time to beat refresh asks IGDB again.
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("update GameTimeToBeat t set t.fetchedAt = :longAgo where t.appid = :appid")
|
||||||
|
void markStale(@Param("appid") Long appid, @Param("longAgo") Instant longAgo);
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,8 @@ public class AuditLogService {
|
|||||||
if (action != null && !AdminAuditRepository.ACTIONS.contains(action)) {
|
if (action != null && !AdminAuditRepository.ACTIONS.contains(action)) {
|
||||||
return new PageImpl<>(List.of(), pageable, 0);
|
return new PageImpl<>(List.of(), pageable, 0);
|
||||||
}
|
}
|
||||||
return new PageImpl<>(adminAuditRepository.findNewest(action, (int) pageable.getOffset(), pageable.getPageSize()),
|
return new PageImpl<>(adminAuditRepository.findNewest(action, GameMergeService.undoCutoff(),
|
||||||
|
(int) pageable.getOffset(), pageable.getPageSize()),
|
||||||
pageable, adminAuditRepository.count(action));
|
pageable, adminAuditRepository.count(action));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,6 @@ package core_service.modules.library.service;
|
|||||||
import core_service.modules.library.api.dto.BacklogEntryDto;
|
import core_service.modules.library.api.dto.BacklogEntryDto;
|
||||||
import core_service.modules.library.api.dto.BacklogPatch;
|
import core_service.modules.library.api.dto.BacklogPatch;
|
||||||
import core_service.modules.library.domain.FinishedHintType;
|
import core_service.modules.library.domain.FinishedHintType;
|
||||||
import core_service.modules.library.domain.GameSource;
|
|
||||||
import core_service.modules.library.domain.GameStatus;
|
import core_service.modules.library.domain.GameStatus;
|
||||||
import core_service.modules.library.domain.UserGame;
|
import core_service.modules.library.domain.UserGame;
|
||||||
import core_service.modules.library.domain.UserGameId;
|
import core_service.modules.library.domain.UserGameId;
|
||||||
@@ -62,11 +61,10 @@ public class BacklogService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum HideResult { DONE, NOT_FOUND, MANUAL_GAME }
|
public enum HideResult { DONE, NOT_FOUND }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hides only a Steam game; one added by hand can be deleted instead. Unhiding works for every game. Hiding again
|
* Hides or shows a game, Steam or added by hand. Hiding again keeps the first time.
|
||||||
* keeps the first time.
|
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public HideResult setHidden(Long userId, Long appid, boolean hidden) {
|
public HideResult setHidden(Long userId, Long appid, boolean hidden) {
|
||||||
@@ -77,7 +75,6 @@ public class BacklogService {
|
|||||||
game.get().setHiddenAt(null);
|
game.get().setHiddenAt(null);
|
||||||
return HideResult.DONE;
|
return HideResult.DONE;
|
||||||
}
|
}
|
||||||
if (game.get().getSource() != GameSource.STEAM) return HideResult.MANUAL_GAME;
|
|
||||||
if (game.get().getHiddenAt() == null) {
|
if (game.get().getHiddenAt() == null) {
|
||||||
game.get().setHiddenAt(Instant.now());
|
game.get().setHiddenAt(Instant.now());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
package core_service.modules.library.service;
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.CatalogGameDetail;
|
||||||
|
import core_service.modules.library.domain.CatalogHit;
|
||||||
import core_service.modules.library.domain.CorrectionField;
|
import core_service.modules.library.domain.CorrectionField;
|
||||||
import core_service.modules.library.domain.GameCorrection;
|
import core_service.modules.library.domain.GameCorrection;
|
||||||
import core_service.modules.library.domain.GameCorrectionId;
|
import core_service.modules.library.domain.GameCorrectionId;
|
||||||
import core_service.modules.library.domain.GameMoods;
|
import core_service.modules.library.domain.GameMoods;
|
||||||
import core_service.modules.library.domain.GameSpeedrun;
|
import core_service.modules.library.domain.GameSpeedrun;
|
||||||
|
import core_service.modules.library.domain.GameTimeToBeat;
|
||||||
import core_service.modules.library.domain.Mood;
|
import core_service.modules.library.domain.Mood;
|
||||||
import core_service.modules.library.domain.SteamGame;
|
import core_service.modules.library.domain.SteamGame;
|
||||||
|
import core_service.modules.library.repo.AdminAuditRepository;
|
||||||
|
import core_service.modules.library.repo.CatalogRepository;
|
||||||
import core_service.modules.library.repo.GameCorrectionRepository;
|
import core_service.modules.library.repo.GameCorrectionRepository;
|
||||||
import core_service.modules.library.repo.GameMoodsRepository;
|
import core_service.modules.library.repo.GameMoodsRepository;
|
||||||
import core_service.modules.library.repo.GameSpeedrunRepository;
|
import core_service.modules.library.repo.GameSpeedrunRepository;
|
||||||
|
import core_service.modules.library.repo.GameTimeToBeatRepository;
|
||||||
import core_service.modules.library.repo.SteamGameRepository;
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
import core_service.shared.security.RequiresRole;
|
import core_service.shared.security.RequiresRole;
|
||||||
import core_service.shared.security.Role;
|
import core_service.shared.security.Role;
|
||||||
import lombok.RequiredArgsConstructor;
|
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 org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import tools.jackson.databind.json.JsonMapper;
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
@@ -22,7 +31,9 @@ import java.net.URI;
|
|||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@@ -43,6 +54,9 @@ public class GameCorrectionService {
|
|||||||
private static final int MAX_TEXT = 512;
|
private static final int MAX_TEXT = 512;
|
||||||
private static final int MAX_GENRES = 10;
|
private static final int MAX_GENRES = 10;
|
||||||
private static final int MAX_GENRE_LENGTH = 64;
|
private static final int MAX_GENRE_LENGTH = 64;
|
||||||
|
private static final String NORMALLY = "normallyMinutes";
|
||||||
|
private static final String COMPLETELY = "completelyMinutes";
|
||||||
|
static final int MAX_TIME_TO_BEAT_MINUTES = 60_000;
|
||||||
// Far enough back that the next refresh asks the source again.
|
// Far enough back that the next refresh asks the source again.
|
||||||
private static final Instant LONG_AGO = Instant.EPOCH;
|
private static final Instant LONG_AGO = Instant.EPOCH;
|
||||||
|
|
||||||
@@ -50,6 +64,9 @@ public class GameCorrectionService {
|
|||||||
private final GameCorrectionRepository correctionRepository;
|
private final GameCorrectionRepository correctionRepository;
|
||||||
private final GameMoodsRepository gameMoodsRepository;
|
private final GameMoodsRepository gameMoodsRepository;
|
||||||
private final GameSpeedrunRepository gameSpeedrunRepository;
|
private final GameSpeedrunRepository gameSpeedrunRepository;
|
||||||
|
private final GameTimeToBeatRepository gameTimeToBeatRepository;
|
||||||
|
private final AdminAuditRepository adminAuditRepository;
|
||||||
|
private final CatalogRepository catalogRepository;
|
||||||
private final JsonMapper jsonMapper;
|
private final JsonMapper jsonMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,10 +82,12 @@ public class GameCorrectionService {
|
|||||||
return Result.NOT_FOUND;
|
return Result.NOT_FOUND;
|
||||||
}
|
}
|
||||||
Object clean = validate(field, value);
|
Object clean = validate(field, value);
|
||||||
|
String json = jsonMapper.writeValueAsString(clean);
|
||||||
// One statement, not find-then-save: two auditors' first correction of a field must not collide on the key.
|
// One statement, not find-then-save: two auditors' first correction of a field must not collide on the key.
|
||||||
correctionRepository.upsert(appid, field.name(), jsonMapper.writeValueAsString(clean),
|
correctionRepository.upsert(appid, field.name(), json,
|
||||||
jsonMapper.writeValueAsString(cachedValue(field, game.get())), auditorId, Instant.now());
|
jsonMapper.writeValueAsString(cachedValue(field, game.get())), auditorId, Instant.now());
|
||||||
write(field, game.get(), clean);
|
write(field, game.get(), clean);
|
||||||
|
adminAuditRepository.write(auditorId, "GAME_CORRECT", appid, null, field.name() + " " + json);
|
||||||
return Result.DONE;
|
return Result.DONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +96,7 @@ public class GameCorrectionService {
|
|||||||
* asked again at the next refresh.
|
* asked again at the next refresh.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public Result reset(long appid, String fieldName) {
|
public Result reset(long appid, String fieldName, long auditorId) {
|
||||||
CorrectionField field = field(fieldName);
|
CorrectionField field = field(fieldName);
|
||||||
Optional<SteamGame> game = steamGameRepository.findById(appid);
|
Optional<SteamGame> game = steamGameRepository.findById(appid);
|
||||||
if (game.isEmpty()) {
|
if (game.isEmpty()) {
|
||||||
@@ -88,15 +107,44 @@ public class GameCorrectionService {
|
|||||||
return Result.DONE;
|
return Result.DONE;
|
||||||
}
|
}
|
||||||
correctionRepository.delete(correction.get());
|
correctionRepository.delete(correction.get());
|
||||||
write(field, game.get(), jsonMapper.readValue(correction.get().getSourceValue(), Object.class));
|
String sourceValue = correction.get().getSourceValue();
|
||||||
|
write(field, game.get(), jsonMapper.readValue(sourceValue, Object.class));
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case MOODS, GENRES, ENDLESS -> gameMoodsRepository.markStale(appid, LONG_AGO);
|
case MOODS, GENRES, ENDLESS -> gameMoodsRepository.markStale(appid, LONG_AGO);
|
||||||
case SPEEDRUN -> gameSpeedrunRepository.markStale(appid, LONG_AGO);
|
case SPEEDRUN -> gameSpeedrunRepository.markStale(appid, LONG_AGO);
|
||||||
|
case TIME_TO_BEAT -> gameTimeToBeatRepository.markStale(appid, LONG_AGO);
|
||||||
case NAME, IMAGE -> { }
|
case NAME, IMAGE -> { }
|
||||||
}
|
}
|
||||||
|
adminAuditRepository.write(auditorId, "GAME_CORRECTION_RESET", appid, null, field.name() + " " + sourceValue);
|
||||||
return Result.DONE;
|
return Result.DONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param query a name part; blank for every game
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<CatalogHit> search(String query, Pageable pageable) {
|
||||||
|
return new PageImpl<>(catalogRepository.search(query, (int) pageable.getOffset(), pageable.getPageSize()),
|
||||||
|
pageable, catalogRepository.count(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the correction dialog shows; empty for a key without a catalog row.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<CatalogGameDetail> detail(long appid) {
|
||||||
|
return steamGameRepository.findById(appid).map(game -> {
|
||||||
|
Optional<GameMoods> moods = gameMoodsRepository.findById(appid);
|
||||||
|
return new CatalogGameDetail(appid, game.getName(), game.getImageUrl(),
|
||||||
|
gameSpeedrunRepository.findById(appid).map(GameSpeedrun::getWeblink).orElse(null),
|
||||||
|
moods.map(GameMoods::getMoods).orElse(List.of()),
|
||||||
|
moods.map(GameMoods::getEndless).orElse(null),
|
||||||
|
moods.map(GameMoods::getGenres).orElse(List.of()),
|
||||||
|
gameTimeToBeatRepository.findById(appid).orElse(null),
|
||||||
|
correctionRepository.findByAppidOrderByField(appid));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Here, not in the controller: a USER gets 403 before learning which fields exist.
|
// Here, not in the controller: a USER gets 403 before learning which fields exist.
|
||||||
private static CorrectionField field(String name) {
|
private static CorrectionField field(String name) {
|
||||||
return CorrectionField.fromPath(name).orElseThrow(() -> new InvalidCorrectionException("unknown field " + name));
|
return CorrectionField.fromPath(name).orElseThrow(() -> new InvalidCorrectionException("unknown field " + name));
|
||||||
@@ -110,6 +158,11 @@ public class GameCorrectionService {
|
|||||||
case MOODS -> gameMoodsRepository.findById(game.getAppid()).map(GameMoods::getMoods).orElse(List.of());
|
case MOODS -> gameMoodsRepository.findById(game.getAppid()).map(GameMoods::getMoods).orElse(List.of());
|
||||||
case GENRES -> gameMoodsRepository.findById(game.getAppid()).map(GameMoods::getGenres).orElse(List.of());
|
case GENRES -> gameMoodsRepository.findById(game.getAppid()).map(GameMoods::getGenres).orElse(List.of());
|
||||||
case ENDLESS -> gameMoodsRepository.findById(game.getAppid()).map(GameMoods::getEndless).orElse(null);
|
case ENDLESS -> gameMoodsRepository.findById(game.getAppid()).map(GameMoods::getEndless).orElse(null);
|
||||||
|
case TIME_TO_BEAT -> {
|
||||||
|
Optional<GameTimeToBeat> t = gameTimeToBeatRepository.findById(game.getAppid());
|
||||||
|
yield times(t.map(GameTimeToBeat::getNormallyMinutes).orElse(null),
|
||||||
|
t.map(GameTimeToBeat::getCompletelyMinutes).orElse(null));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +174,32 @@ public class GameCorrectionService {
|
|||||||
case MOODS -> gameMoodsRepository.writeMoods(game.getAppid(), strings(value), LONG_AGO);
|
case MOODS -> gameMoodsRepository.writeMoods(game.getAppid(), strings(value), LONG_AGO);
|
||||||
case GENRES -> gameMoodsRepository.writeGenres(game.getAppid(), strings(value), LONG_AGO);
|
case GENRES -> gameMoodsRepository.writeGenres(game.getAppid(), strings(value), LONG_AGO);
|
||||||
case ENDLESS -> gameMoodsRepository.writeEndless(game.getAppid(), (Boolean) value, LONG_AGO);
|
case ENDLESS -> gameMoodsRepository.writeEndless(game.getAppid(), (Boolean) value, LONG_AGO);
|
||||||
|
case TIME_TO_BEAT -> {
|
||||||
|
Map<?, ?> times = (Map<?, ?>) value;
|
||||||
|
gameTimeToBeatRepository.writeCorrected(game.getAppid(), (Integer) times.get(NORMALLY),
|
||||||
|
(Integer) times.get(COMPLETELY), LONG_AGO);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Map<String, Integer> times(Integer normally, Integer completely) {
|
||||||
|
Map<String, Integer> times = new LinkedHashMap<>();
|
||||||
|
times.put(NORMALLY, normally);
|
||||||
|
times.put(COMPLETELY, completely);
|
||||||
|
return times;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer minutes(Object value, String name) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!(value instanceof Integer m) || m < 1 || m > MAX_TIME_TO_BEAT_MINUTES) {
|
||||||
|
throw new InvalidCorrectionException(name + " must be null or a whole number of minutes from 1 to "
|
||||||
|
+ MAX_TIME_TO_BEAT_MINUTES);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
private static String[] strings(Object list) {
|
private static String[] strings(Object list) {
|
||||||
return ((List<?>) list).stream().map(String.class::cast).toArray(String[]::new);
|
return ((List<?>) list).stream().map(String.class::cast).toArray(String[]::new);
|
||||||
}
|
}
|
||||||
@@ -171,6 +247,17 @@ public class GameCorrectionService {
|
|||||||
}
|
}
|
||||||
yield value;
|
yield value;
|
||||||
}
|
}
|
||||||
|
case TIME_TO_BEAT -> {
|
||||||
|
if (!(value instanceof Map<?, ?> map) || !Set.of(NORMALLY, COMPLETELY).containsAll(map.keySet())) {
|
||||||
|
throw new InvalidCorrectionException("time_to_beat must be {" + NORMALLY + ", " + COMPLETELY + "}");
|
||||||
|
}
|
||||||
|
Integer normally = minutes(map.get(NORMALLY), NORMALLY);
|
||||||
|
Integer completely = minutes(map.get(COMPLETELY), COMPLETELY);
|
||||||
|
if (normally != null && completely != null && completely < normally) {
|
||||||
|
throw new InvalidCorrectionException(COMPLETELY + " must not be less than " + NORMALLY);
|
||||||
|
}
|
||||||
|
yield times(normally, completely);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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.GameLinkRepository;
|
||||||
|
import core_service.modules.library.repo.GameMergeRepository;
|
||||||
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;
|
||||||
@@ -66,6 +67,7 @@ public class GameDetailService {
|
|||||||
private final GameMoodsRepository gameMoodsRepository;
|
private final GameMoodsRepository gameMoodsRepository;
|
||||||
private final GameCorrectionRepository gameCorrectionRepository;
|
private final GameCorrectionRepository gameCorrectionRepository;
|
||||||
private final GameLinkRepository gameLinkRepository;
|
private final GameLinkRepository gameLinkRepository;
|
||||||
|
private final GameMergeRepository mergeRepository;
|
||||||
|
|
||||||
@Value("${app.library.achievement-ttl-hours:1}")
|
@Value("${app.library.achievement-ttl-hours:1}")
|
||||||
private long achievementTtlHours;
|
private long achievementTtlHours;
|
||||||
@@ -76,8 +78,17 @@ public class GameDetailService {
|
|||||||
*
|
*
|
||||||
* @param steamId null when the user has none; then the stored achievements are shown without a refresh
|
* @param steamId null when the user has none; then the stored achievements are shown without a refresh
|
||||||
*/
|
*/
|
||||||
public Optional<GameDetailDto> getGameDetail(Long userId, String steamId, Long appid) {
|
public Optional<GameDetailDto> getGameDetail(Long userId, String steamId, Long askedAppid) {
|
||||||
|
Long appid = askedAppid;
|
||||||
Optional<UserGame> userGameOpt = userGameRepository.findById(new UserGameId(userId, appid));
|
Optional<UserGame> userGameOpt = userGameRepository.findById(new UserGameId(userId, appid));
|
||||||
|
if (userGameOpt.isEmpty()) {
|
||||||
|
// A merged key leads to the kept key; the detail then carries the kept appid.
|
||||||
|
Long target = mergeRepository.findTargets(List.of(appid)).get(appid);
|
||||||
|
if (target != null) {
|
||||||
|
appid = target;
|
||||||
|
userGameOpt = userGameRepository.findById(new UserGameId(userId, appid));
|
||||||
|
}
|
||||||
|
}
|
||||||
if (userGameOpt.isEmpty() || userGameOpt.get().getRemovedAt() != null) {
|
if (userGameOpt.isEmpty() || userGameOpt.get().getRemovedAt() != null) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,23 @@ 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.domain.MergeUndo;
|
||||||
|
import core_service.modules.library.domain.UndoCounts;
|
||||||
import core_service.modules.library.repo.AdminAuditRepository;
|
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;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.PlatformTransactionManager;
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
import org.springframework.transaction.support.TransactionTemplate;
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An auditor merges two library keys of the same game, for every user.
|
* An auditor merges two library keys of the same game, for every user.
|
||||||
@@ -33,6 +39,31 @@ public class GameMergeService {
|
|||||||
record SteamGames() implements Result {}
|
record SteamGames() implements Result {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static final int UNDO_DAYS = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the time before which a merge can no longer be undone
|
||||||
|
*/
|
||||||
|
public static Instant undoCutoff() {
|
||||||
|
return Instant.now().minus(UNDO_DAYS, ChronoUnit.DAYS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed interface UndoResult {
|
||||||
|
/** @param appid the key that has its rows again */
|
||||||
|
record Undone(long appid) implements UndoResult {}
|
||||||
|
|
||||||
|
/** No GAME_MERGE row with this id. */
|
||||||
|
record NotFound() implements UndoResult {}
|
||||||
|
|
||||||
|
/** Undone already, or merged before the undo existed. */
|
||||||
|
record NotAvailable() implements UndoResult {}
|
||||||
|
|
||||||
|
record Expired() implements UndoResult {}
|
||||||
|
|
||||||
|
/** A later merge uses the kept key; that one goes first. */
|
||||||
|
record NewerMerge() implements UndoResult {}
|
||||||
|
}
|
||||||
|
|
||||||
private final GameMergeRepository mergeRepository;
|
private final GameMergeRepository mergeRepository;
|
||||||
private final AdminAuditRepository adminAuditRepository;
|
private final AdminAuditRepository adminAuditRepository;
|
||||||
private final TimeToBeatService timeToBeatService;
|
private final TimeToBeatService timeToBeatService;
|
||||||
@@ -67,8 +98,9 @@ public class GameMergeService {
|
|||||||
if (names.size() < 2) {
|
if (names.size() < 2) {
|
||||||
return new Result.NotFound();
|
return new Result.NotFound();
|
||||||
}
|
}
|
||||||
MergeCounts counts = mergeRepository.merge(from, to);
|
long auditId = adminAuditRepository.write(auditorId, "GAME_MERGE", from, to, null);
|
||||||
adminAuditRepository.write(auditorId, "GAME_MERGE", from, to,
|
MergeCounts counts = mergeRepository.merge(auditId, from, to);
|
||||||
|
adminAuditRepository.updateDetail(auditId,
|
||||||
"\"" + 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);
|
||||||
@@ -81,4 +113,51 @@ public class GameMergeService {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives the merged key and its users their rows back; newer merges of the kept key go first.
|
||||||
|
*/
|
||||||
|
public UndoResult undo(long mergeId, long auditorId) {
|
||||||
|
Instant cutoff = undoCutoff();
|
||||||
|
UndoResult result = transaction.execute(tx -> {
|
||||||
|
Optional<MergeUndo> found = mergeRepository.lockUndo(mergeId);
|
||||||
|
if (found.isEmpty()) {
|
||||||
|
Optional<Instant> mergedAt = adminAuditRepository.findMergeTime(mergeId);
|
||||||
|
if (mergedAt.isEmpty()) {
|
||||||
|
return new UndoResult.NotFound();
|
||||||
|
}
|
||||||
|
return mergedAt.get().isBefore(cutoff) ? new UndoResult.Expired() : new UndoResult.NotAvailable();
|
||||||
|
}
|
||||||
|
MergeUndo undo = found.get();
|
||||||
|
if (undo.createdAt().isBefore(cutoff)) {
|
||||||
|
return new UndoResult.Expired();
|
||||||
|
}
|
||||||
|
// After the lock: a merge into the kept key that commits while we wait must be seen.
|
||||||
|
Map<Long, String> names = mergeRepository.lockGames(undo.to(), undo.from());
|
||||||
|
if (mergeRepository.hasNewerMerge(mergeId, undo.to())) {
|
||||||
|
return new UndoResult.NewerMerge();
|
||||||
|
}
|
||||||
|
if (!names.containsKey(undo.to())) {
|
||||||
|
return new UndoResult.NotAvailable();
|
||||||
|
}
|
||||||
|
UndoCounts counts = mergeRepository.undo(undo);
|
||||||
|
adminAuditRepository.write(auditorId, "GAME_MERGE_UNDO", undo.from(), undo.to(),
|
||||||
|
"\"" + undo.fromName() + "\" from \"" + names.get(undo.to()) + "\": " + counts.back() + " back, "
|
||||||
|
+ counts.split() + " split, " + counts.stayed() + " stayed"
|
||||||
|
+ (counts.linksKept() ? "; links kept" : ""));
|
||||||
|
return new UndoResult.Undone(undo.from());
|
||||||
|
});
|
||||||
|
if (result instanceof UndoResult.Undone undone) {
|
||||||
|
log.info("Auditor {} undid merge {} (appid={})", auditorId, mergeId, undone.appid());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(cron = "${app.library.merge-undo-purge-cron:0 45 3 * * *}", zone = "${app.library.zone:Europe/Berlin}")
|
||||||
|
public void purgeExpiredUndo() {
|
||||||
|
int deleted = mergeRepository.deleteUndoBefore(undoCutoff());
|
||||||
|
if (deleted > 0) {
|
||||||
|
log.info("Deleted {} expired merge undo copies", deleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -246,7 +246,7 @@ public class ManualGameService {
|
|||||||
revived.setPlaytime2Weeks(0);
|
revived.setPlaytime2Weeks(0);
|
||||||
revived.setLastSeenAt(now);
|
revived.setLastSeenAt(now);
|
||||||
revived.setRemovedAt(null);
|
revived.setRemovedAt(null);
|
||||||
// Only a Steam game can be hidden.
|
// A game added again is listed again.
|
||||||
revived.setHiddenAt(null);
|
revived.setHiddenAt(null);
|
||||||
// The row holds rating, notes and history from before, so an undo of the import must not delete it.
|
// The row holds rating, notes and history from before, so an undo of the import must not delete it.
|
||||||
revived.setImportId(null);
|
revived.setImportId(null);
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ app:
|
|||||||
edition-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 * * *"
|
||||||
|
merge-undo-purge-cron: "0 45 3 * * *"
|
||||||
image-repair-batch: 200
|
image-repair-batch: 200
|
||||||
image-repair-max-attempts: 3
|
image-repair-max-attempts: 3
|
||||||
image-repair-retry-days: 30
|
image-repair-retry-days: 30
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- What an auditor merge (V21) changed, so it can be undone for 30 days; a nightly job then deletes the copy.
|
||||||
|
-- One row per merge, merge_id = the admin_audit id of its GAME_MERGE row (no foreign key: audit rows have none).
|
||||||
|
-- rows holds the deleted shared rows per table as JSON, so the undo restores each table with one
|
||||||
|
-- jsonb_populate_recordset. A migration that adds a NOT NULL column without default to one of those tables makes
|
||||||
|
-- the restore of a merge made before it fail for up to 30 days.
|
||||||
|
CREATE TABLE game_merge_undo (
|
||||||
|
merge_id BIGINT PRIMARY KEY,
|
||||||
|
from_appid BIGINT NOT NULL,
|
||||||
|
to_appid BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
rows JSONB NOT NULL,
|
||||||
|
-- The rows of the kept key's link set after the merge, to see whether the links changed since.
|
||||||
|
links_after JSONB NOT NULL DEFAULT '[]',
|
||||||
|
-- The game_alias keys the merge flattened from from_appid to to_appid.
|
||||||
|
aliases BIGINT[] NOT NULL DEFAULT '{}',
|
||||||
|
-- The reward_ledger rows the merge rewrote for users with no row on from_appid (ids only, no user data).
|
||||||
|
other_ledger_ids BIGINT[] NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
-- One row per user the merge touched. Holds the user's notes, so it goes with the account (item 16).
|
||||||
|
CREATE TABLE game_merge_undo_user (
|
||||||
|
merge_id BIGINT NOT NULL REFERENCES game_merge_undo ON DELETE CASCADE,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
-- The user had a row on both keys. from_row, to_row (before) and merged_row (after the merge) are user_game JSON.
|
||||||
|
combined BOOLEAN NOT NULL,
|
||||||
|
from_row JSONB,
|
||||||
|
to_row JSONB,
|
||||||
|
merged_row JSONB,
|
||||||
|
-- The history of the dropped row, which the merge moved to the kept key.
|
||||||
|
status_change_ids BIGINT[] NOT NULL DEFAULT '{}',
|
||||||
|
-- The reward_ledger rows whose ref the merge rewrote.
|
||||||
|
ledger_ids BIGINT[] NOT NULL DEFAULT '{}',
|
||||||
|
PRIMARY KEY (merge_id, user_id)
|
||||||
|
);
|
||||||
@@ -17,7 +17,7 @@ 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_link_request", "account_name", "game_import", "reward_ledger",
|
"user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
|
||||||
"reward_equipped");
|
"reward_equipped");
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@@ -29,6 +29,8 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void seed() {
|
void seed() {
|
||||||
jdbc.update("delete from backlog_snapshot");
|
jdbc.update("delete from backlog_snapshot");
|
||||||
|
jdbc.update("delete from game_merge_undo");
|
||||||
|
jdbc.update("insert into game_merge_undo (merge_id, from_appid, to_appid, rows) values (1, -5, 220, '{}')");
|
||||||
jdbc.update("insert into steam_game (appid, name) values (220, 'Half-Life 2') on conflict do nothing");
|
jdbc.update("insert into steam_game (appid, name) values (220, 'Half-Life 2') on conflict do nothing");
|
||||||
for (long userId : List.of(1L, 2L)) {
|
for (long userId : List.of(1L, 2L)) {
|
||||||
// The V14 trigger adds the user_game_status_change row.
|
// The V14 trigger adds the user_game_status_change row.
|
||||||
@@ -43,6 +45,7 @@ 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_merge_undo_user (merge_id, user_id, combined) values (1, ?, false)", userId);
|
||||||
jdbc.update("insert into game_link_request (user_id, appid_a, appid_b) values (?, 220, 620)", 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 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);
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
import core_service.configuration.security.ClientHeaderFilter;
|
||||||
|
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.contains;
|
||||||
|
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 auditor catalog through the real (non-dev) filter chain, so the role in the token decides.
|
||||||
|
*/
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ActiveProfiles(value = "prod", inheritProfiles = false)
|
||||||
|
class CatalogControllerTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private static final long AUDITOR_ID = 6101L;
|
||||||
|
private static final long OWNER = 6102L;
|
||||||
|
private static final long OTHER = 6103L;
|
||||||
|
private static final long HADES = -1_940_001L;
|
||||||
|
private static final long REMOVED = -1_940_002L;
|
||||||
|
private static final long NOBODY = -1_940_003L;
|
||||||
|
private static final long PERCENT = -1_940_004L;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
jdbc.update("delete from user_game");
|
||||||
|
jdbc.update("delete from game_correction");
|
||||||
|
jdbc.update("delete from game_time_to_beat");
|
||||||
|
jdbc.update("delete from steam_game where appid in (?, ?, ?, ?) or name like 'Paged %'", HADES, REMOVED, NOBODY, PERCENT);
|
||||||
|
jdbc.update("insert into steam_game (appid, name) values (?, 'Hades'), (?, 'Removed Game'), "
|
||||||
|
+ "(?, 'Nobody Game'), (?, '100% Orange Juice')", HADES, REMOVED, NOBODY, PERCENT);
|
||||||
|
jdbc.update("insert into user_game (user_id, appid, playtime_minutes, first_seen_at, last_seen_at, status, source) "
|
||||||
|
+ "values (?, ?, 0, now(), now(), 'BACKLOG', 'STEAM')", OWNER, HADES);
|
||||||
|
jdbc.update("insert into user_game (user_id, appid, playtime_minutes, first_seen_at, last_seen_at, status, source, platform) "
|
||||||
|
+ "values (?, ?, 0, now(), now(), 'BACKLOG', 'MANUAL', 'PS5')", OTHER, HADES);
|
||||||
|
jdbc.update("insert into user_game (user_id, appid, playtime_minutes, first_seen_at, last_seen_at, status, source, removed_at) "
|
||||||
|
+ "values (?, ?, 0, now(), now(), 'BACKLOG', 'STEAM', now())", OWNER, REMOVED);
|
||||||
|
jdbc.update("insert into user_game (user_id, appid, playtime_minutes, first_seen_at, last_seen_at, status, source) "
|
||||||
|
+ "values (?, ?, 0, now(), now(), 'BACKLOG', 'STEAM')", OWNER, PERCENT);
|
||||||
|
jdbc.update("insert into game_time_to_beat (appid, normally_minutes, completely_minutes, fetched_at) "
|
||||||
|
+ "values (?, 600, 1200, now())", HADES);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResultActions call(Role role, String path) throws Exception {
|
||||||
|
return mockMvc.perform(get("/api/library/catalog" + path)
|
||||||
|
.header("Authorization", "Bearer " + ContractTokens.loginToken(AUDITOR_ID, role))
|
||||||
|
.header(ClientHeaderFilter.CLIENT_HEADER, "test"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_FindsAGameOnlyOthersOwnWithItsPlatforms() throws Exception {
|
||||||
|
call(Role.AUDITOR, "?q=hAD").andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.totalElements").value(1))
|
||||||
|
.andExpect(jsonPath("$.content[0].appid").value(HADES))
|
||||||
|
.andExpect(jsonPath("$.content[0].name").value("Hades"))
|
||||||
|
.andExpect(jsonPath("$.content[0].platforms", contains("PS5", "Steam")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_ListsOnlyGamesSomeoneStillHasAndMatchesPercentLiterally() throws Exception {
|
||||||
|
call(Role.AUDITOR, "").andExpect(jsonPath("$.content[*].appid", contains((int) PERCENT, (int) HADES)));
|
||||||
|
call(Role.PLATFORM_ADMIN, "?q=%").andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.content", hasSize(1)))
|
||||||
|
.andExpect(jsonPath("$.content[0].appid").value(PERCENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_PagesTheMatches() throws Exception {
|
||||||
|
for (int i = 0; i < 21; i++) {
|
||||||
|
long appid = -1_950_000L - i;
|
||||||
|
jdbc.update("insert into steam_game (appid, name) values (?, ?)", appid, "Paged %02d".formatted(i));
|
||||||
|
jdbc.update("insert into user_game (user_id, appid, playtime_minutes, first_seen_at, last_seen_at, status, source) "
|
||||||
|
+ "values (?, ?, 0, now(), now(), 'BACKLOG', 'STEAM')", OWNER, appid);
|
||||||
|
}
|
||||||
|
|
||||||
|
call(Role.AUDITOR, "?q=paged&page=0&size=20").andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.content", hasSize(20)))
|
||||||
|
.andExpect(jsonPath("$.totalElements").value(21))
|
||||||
|
.andExpect(jsonPath("$.totalPages").value(2));
|
||||||
|
call(Role.AUDITOR, "?q=paged&page=1&size=20")
|
||||||
|
.andExpect(jsonPath("$.content", hasSize(1)))
|
||||||
|
.andExpect(jsonPath("$.content[0].name").value("Paged 20"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detail_ShowsWhatTheDialogNeedsForAGameTheAuditorDoesNotOwn() throws Exception {
|
||||||
|
call(Role.AUDITOR, "/" + HADES).andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.name").value("Hades"))
|
||||||
|
.andExpect(jsonPath("$.timeToBeat.normallyMinutes").value(600))
|
||||||
|
.andExpect(jsonPath("$.moods", hasSize(0)))
|
||||||
|
.andExpect(jsonPath("$.corrections", hasSize(0)));
|
||||||
|
call(Role.AUDITOR, "/-1940099").andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void roles_UserIsForbiddenAndAdminIsAllowedAndLongQueryIsRefused() throws Exception {
|
||||||
|
call(Role.USER, "").andExpect(status().isForbidden());
|
||||||
|
call(Role.USER, "/" + HADES).andExpect(status().isForbidden());
|
||||||
|
call(Role.PLATFORM_ADMIN, "/" + HADES).andExpect(status().isOk());
|
||||||
|
call(Role.AUDITOR, "?q=" + "a".repeat(101)).andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import core_service.modules.library.domain.UserGame;
|
|||||||
import core_service.modules.library.repo.GameCorrectionRepository;
|
import core_service.modules.library.repo.GameCorrectionRepository;
|
||||||
import core_service.modules.library.repo.GameMoodsRepository;
|
import core_service.modules.library.repo.GameMoodsRepository;
|
||||||
import core_service.modules.library.repo.GameSpeedrunRepository;
|
import core_service.modules.library.repo.GameSpeedrunRepository;
|
||||||
|
import core_service.modules.library.repo.GameTimeToBeatRepository;
|
||||||
import core_service.modules.library.repo.SteamGameRepository;
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
import core_service.modules.library.repo.UserGameRepository;
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
import core_service.shared.security.Role;
|
import core_service.shared.security.Role;
|
||||||
@@ -17,6 +18,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
import org.springframework.test.web.servlet.ResultActions;
|
import org.springframework.test.web.servlet.ResultActions;
|
||||||
@@ -60,12 +62,20 @@ class GameCorrectionFlowTest extends AbstractIntegrationTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private GameCorrectionRepository gameCorrectionRepository;
|
private GameCorrectionRepository gameCorrectionRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GameTimeToBeatRepository gameTimeToBeatRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void seed() {
|
void seed() {
|
||||||
gameCorrectionRepository.deleteAll();
|
gameCorrectionRepository.deleteAll();
|
||||||
userGameRepository.deleteAll();
|
userGameRepository.deleteAll();
|
||||||
gameMoodsRepository.deleteAll();
|
gameMoodsRepository.deleteAll();
|
||||||
gameSpeedrunRepository.deleteAll();
|
gameSpeedrunRepository.deleteAll();
|
||||||
|
gameTimeToBeatRepository.deleteAll();
|
||||||
|
jdbc.update("delete from admin_audit");
|
||||||
steamGameRepository.save(new SteamGame(GAME, "Portal 2", null, Instant.now()));
|
steamGameRepository.save(new SteamGame(GAME, "Portal 2", null, Instant.now()));
|
||||||
UserGame owned = new UserGame(AUDITOR_ID, GAME, 60, Instant.now(), Instant.now());
|
UserGame owned = new UserGame(AUDITOR_ID, GAME, 60, Instant.now(), Instant.now());
|
||||||
// Hidden: the details make no calls to Steam, IGDB or speedrun.com.
|
// Hidden: the details make no calls to Steam, IGDB or speedrun.com.
|
||||||
@@ -73,6 +83,7 @@ class GameCorrectionFlowTest extends AbstractIntegrationTest {
|
|||||||
userGameRepository.save(owned);
|
userGameRepository.save(owned);
|
||||||
gameMoodsRepository.upsert(GAME, new String[]{"STORY"}, new String[]{"Puzzle"}, false, Instant.now());
|
gameMoodsRepository.upsert(GAME, new String[]{"STORY"}, new String[]{"Puzzle"}, false, Instant.now());
|
||||||
gameSpeedrunRepository.upsert(GAME, "om1mw4d2", "https://www.speedrun.com/portal_2", Instant.now());
|
gameSpeedrunRepository.upsert(GAME, "om1mw4d2", "https://www.speedrun.com/portal_2", Instant.now());
|
||||||
|
gameTimeToBeatRepository.upsert(GAME, 300, 600, 1200, Instant.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String token(Role role) {
|
private static String token(Role role) {
|
||||||
@@ -181,4 +192,75 @@ class GameCorrectionFlowTest extends AbstractIntegrationTest {
|
|||||||
correct(Role.AUDITOR, GAME, "moods", "[\"SPEEDRUN\"]").andExpect(status().isBadRequest());
|
correct(Role.AUDITOR, GAME, "moods", "[\"SPEEDRUN\"]").andExpect(status().isBadRequest());
|
||||||
correct(Role.AUDITOR, GAME, "name", "\" \"").andExpect(status().isBadRequest());
|
correct(Role.AUDITOR, GAME, "name", "\" \"").andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final String TIMES = "{\"normallyMinutes\":700,\"completelyMinutes\":1500}";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void timeToBeat_IsCorrectedForEveryoneAndSurvivesTheRefresh() throws Exception {
|
||||||
|
correct(Role.AUDITOR, GAME, "time_to_beat", TIMES).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
details().andExpect(jsonPath("$.timeToBeat.normallyMinutes").value(700))
|
||||||
|
.andExpect(jsonPath("$.timeToBeat.completelyMinutes").value(1500))
|
||||||
|
.andExpect(jsonPath("$.timeToBeat.hastilyMinutes").value(300))
|
||||||
|
.andExpect(jsonPath("$.corrections[0].field").value("TIME_TO_BEAT"));
|
||||||
|
|
||||||
|
gameTimeToBeatRepository.upsert(GAME, 310, 610, 1210, Instant.now());
|
||||||
|
|
||||||
|
details().andExpect(jsonPath("$.timeToBeat.normallyMinutes").value(700))
|
||||||
|
.andExpect(jsonPath("$.timeToBeat.completelyMinutes").value(1500))
|
||||||
|
.andExpect(jsonPath("$.timeToBeat.hastilyMinutes").value(310));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void timeToBeat_ResetRestoresIgdbAndAsksAgain() throws Exception {
|
||||||
|
correct(Role.AUDITOR, GAME, "time_to_beat", TIMES).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
reset("time_to_beat").andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
details().andExpect(jsonPath("$.timeToBeat.normallyMinutes").value(600))
|
||||||
|
.andExpect(jsonPath("$.timeToBeat.completelyMinutes").value(1200))
|
||||||
|
.andExpect(jsonPath("$.corrections", empty()));
|
||||||
|
assertThat(gameTimeToBeatRepository.findById(GAME).orElseThrow().getFetchedAt()).isEqualTo(Instant.EPOCH);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void timeToBeat_CanBeSetForAGameWithoutIgdbData() throws Exception {
|
||||||
|
gameTimeToBeatRepository.deleteAll();
|
||||||
|
|
||||||
|
correct(Role.AUDITOR, GAME, "time_to_beat", TIMES).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
var row = gameTimeToBeatRepository.findById(GAME).orElseThrow();
|
||||||
|
assertThat(row.getNormallyMinutes()).isEqualTo(700);
|
||||||
|
assertThat(row.getCompletelyMinutes()).isEqualTo(1500);
|
||||||
|
assertThat(row.getFetchedAt()).isEqualTo(Instant.EPOCH);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void timeToBeat_BadValuesAreRefused() throws Exception {
|
||||||
|
for (String bad : new String[]{"{\"normallyMinutes\":-1}", "{\"normallyMinutes\":0}",
|
||||||
|
"{\"normallyMinutes\":1.5}", "{\"normallyMinutes\":60001}", "{\"normallyMinutes\":\"60\"}",
|
||||||
|
"{\"normallyMinutes\":60,\"hastilyMinutes\":30}", "{\"normallyMinutes\":100,\"completelyMinutes\":99}"}) {
|
||||||
|
correct(Role.AUDITOR, GAME, "time_to_beat", bad).andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("INVALID_CORRECTION"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void corrections_AreAudited() throws Exception {
|
||||||
|
correct(Role.AUDITOR, GAME, "time_to_beat", TIMES).andExpect(status().isNoContent());
|
||||||
|
reset("time_to_beat").andExpect(status().isNoContent());
|
||||||
|
reset("name").andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
var rows = jdbc.queryForList("select actor_user_id, action, appid, detail from admin_audit order by id");
|
||||||
|
assertThat(rows).hasSize(2);
|
||||||
|
assertThat(rows.get(0)).containsEntry("action", "GAME_CORRECT").containsEntry("actor_user_id", AUDITOR_ID)
|
||||||
|
.containsEntry("appid", GAME);
|
||||||
|
assertThat((String) rows.get(0).get("detail")).startsWith("TIME_TO_BEAT ");
|
||||||
|
assertThat(rows.get(1)).containsEntry("action", "GAME_CORRECTION_RESET");
|
||||||
|
assertThat((String) rows.get(1).get("detail")).startsWith("TIME_TO_BEAT ");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/audit-log?action=GAME_CORRECT")
|
||||||
|
.header("Authorization", "Bearer " + token(Role.AUDITOR)).header(ClientHeaderFilter.CLIENT_HEADER, "test"))
|
||||||
|
.andExpect(status().isOk()).andExpect(jsonPath("$.totalElements").value(1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -77,6 +77,7 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void seed() {
|
void seed() {
|
||||||
|
jdbc.update("delete from game_merge_undo");
|
||||||
jdbc.update("delete from game_alias");
|
jdbc.update("delete from game_alias");
|
||||||
jdbc.update("delete from admin_audit");
|
jdbc.update("delete from admin_audit");
|
||||||
jdbc.update("delete from reward_ledger where user_id in (?, ?)", AUDITOR_ID, OTHER_USER);
|
jdbc.update("delete from reward_ledger where user_id in (?, ?)", AUDITOR_ID, OTHER_USER);
|
||||||
@@ -157,6 +158,8 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
|
|||||||
assertThat(steamGameRepository.findById(MANUAL)).isEmpty();
|
assertThat(steamGameRepository.findById(MANUAL)).isEmpty();
|
||||||
assertThat(gameMoodsRepository.findById(MANUAL)).isEmpty();
|
assertThat(gameMoodsRepository.findById(MANUAL)).isEmpty();
|
||||||
assertThat(aliasOf(MANUAL)).isEqualTo(STEAM);
|
assertThat(aliasOf(MANUAL)).isEqualTo(STEAM);
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from game_merge_undo", Integer.class)).isEqualTo(1);
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from game_merge_undo_user", Integer.class)).isEqualTo(1);
|
||||||
assertThat(audit()).containsExactly(AUDITOR_ID + " GAME_MERGE " + MANUAL + " " + STEAM
|
assertThat(audit()).containsExactly(AUDITOR_ID + " GAME_MERGE " + MANUAL + " " + STEAM
|
||||||
+ " \"Hades (Switch)\" into \"Hades\": 1 moved, 0 combined");
|
+ " \"Hades (Switch)\" into \"Hades\": 1 moved, 0 combined");
|
||||||
}
|
}
|
||||||
@@ -369,4 +372,18 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
|
|||||||
assertThat(userStatsRepository.finishedPerYear(AUDITOR_ID, zone)).containsExactly(new UserStats.YearCount(2025, 1));
|
assertThat(userStatsRepository.finishedPerYear(AUDITOR_ID, zone)).containsExactly(new UserStats.YearCount(2025, 1));
|
||||||
assertThat(userStatsRepository.pileMoves(AUDITOR_ID, Instant.now().minus(1, ChronoUnit.DAYS)).done()).isZero();
|
assertThat(userStatsRepository.pileMoves(AUDITOR_ID, Instant.now().minus(1, ChronoUnit.DAYS)).done()).isZero();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void userWithBothRows_SteamCopyGone_KeepsTheHideOfTheCopyAddedByHand() throws Exception {
|
||||||
|
UserGame steam = new UserGame(AUDITOR_ID, STEAM, 300, Instant.now(), Instant.now());
|
||||||
|
steam.setRemovedAt(Instant.now());
|
||||||
|
userGameRepository.save(steam);
|
||||||
|
UserGame manual = manualRow(AUDITOR_ID, MANUAL, GameStatus.PLAYING);
|
||||||
|
manual.setHiddenAt(Instant.parse("2026-01-02T00:00:00Z"));
|
||||||
|
userGameRepository.save(manual);
|
||||||
|
|
||||||
|
merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
|
||||||
|
assertThat(row(AUDITOR_ID, STEAM).getHiddenAt()).as("hide of the copy added by hand").isNotNull();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
import core_service.configuration.security.ClientHeaderFilter;
|
||||||
|
import core_service.modules.account.repo.AccountCleanupRepository;
|
||||||
|
import core_service.modules.library.domain.GamePriority;
|
||||||
|
import core_service.modules.library.domain.GameSource;
|
||||||
|
import core_service.modules.library.domain.GameStatus;
|
||||||
|
import core_service.modules.library.domain.SteamGame;
|
||||||
|
import core_service.modules.library.domain.UserGame;
|
||||||
|
import core_service.modules.library.domain.UserGameId;
|
||||||
|
import core_service.modules.library.repo.GameCorrectionRepository;
|
||||||
|
import core_service.modules.library.repo.GameMoodsRepository;
|
||||||
|
import core_service.modules.library.repo.GameTimeToBeatRepository;
|
||||||
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
|
import core_service.modules.library.service.GameMergeService;
|
||||||
|
import core_service.modules.rewards.service.RewardService;
|
||||||
|
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.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.ResultActions;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo of a merge through the real (non-dev) filter chain, with real SQL.
|
||||||
|
*/
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ActiveProfiles(value = "prod", inheritProfiles = false)
|
||||||
|
class GameMergeUndoFlowTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private static final long AUDITOR_ID = 4711L;
|
||||||
|
private static final long OTHER_USER = 4712L;
|
||||||
|
private static final long STEAM = 1_900_100L;
|
||||||
|
private static final long STEAM_2 = 1_900_200L;
|
||||||
|
private static final long MANUAL = -1_900_007L;
|
||||||
|
private static final long MANUAL_2 = -1_900_008L;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SteamGameRepository steamGameRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserGameRepository userGameRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GameMoodsRepository gameMoodsRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GameTimeToBeatRepository gameTimeToBeatRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GameCorrectionRepository gameCorrectionRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AccountCleanupRepository accountCleanupRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GameMergeService gameMergeService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RewardService rewardService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PlatformTransactionManager transactionManager;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
jdbc.update("delete from game_merge_undo");
|
||||||
|
jdbc.update("delete from game_alias");
|
||||||
|
jdbc.update("delete from admin_audit");
|
||||||
|
jdbc.update("delete from game_link");
|
||||||
|
jdbc.update("delete from game_link_pair");
|
||||||
|
jdbc.update("delete from game_link_request");
|
||||||
|
jdbc.update("delete from reward_ledger where user_id in (?, ?)", AUDITOR_ID, OTHER_USER);
|
||||||
|
gameCorrectionRepository.deleteAll();
|
||||||
|
userGameRepository.deleteAll();
|
||||||
|
gameMoodsRepository.deleteAll();
|
||||||
|
gameTimeToBeatRepository.deleteAll();
|
||||||
|
for (long appid : List.of(STEAM, STEAM_2, MANUAL, MANUAL_2)) {
|
||||||
|
steamGameRepository.findById(appid).ifPresent(steamGameRepository::delete);
|
||||||
|
}
|
||||||
|
steamGameRepository.save(new SteamGame(STEAM, "Hades", "icon", Instant.now()));
|
||||||
|
steamGameRepository.save(new SteamGame(STEAM_2, "Hades II", "icon", Instant.now()));
|
||||||
|
steamGameRepository.save(new SteamGame(MANUAL, "Hades (Switch)", null, Instant.now()));
|
||||||
|
steamGameRepository.save(new SteamGame(MANUAL_2, "Hades Nintendo", null, Instant.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String token(Role role) {
|
||||||
|
return ContractTokens.loginToken(AUDITOR_ID, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResultActions merge(long appid, long otherAppid) throws Exception {
|
||||||
|
return mockMvc.perform(post("/api/library/games/" + appid + "/merge")
|
||||||
|
.header("Authorization", "Bearer " + token(Role.AUDITOR)).header(ClientHeaderFilter.CLIENT_HEADER, "test")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{\"otherAppid\": " + otherAppid + "}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResultActions undo(Role role, long mergeId) throws Exception {
|
||||||
|
return mockMvc.perform(post("/api/library/merges/" + mergeId + "/undo")
|
||||||
|
.header("Authorization", "Bearer " + token(role)).header(ClientHeaderFilter.CLIENT_HEADER, "test"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResultActions undo(long mergeId) throws Exception {
|
||||||
|
return undo(Role.AUDITOR, mergeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The audit id of the newest merge. */
|
||||||
|
private long lastMerge() {
|
||||||
|
Long id = jdbc.queryForObject("select max(id) from admin_audit where action = 'GAME_MERGE'", Long.class);
|
||||||
|
return id == null ? 0 : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserGame manualRow(long userId, long appid, GameStatus status) {
|
||||||
|
return userGameRepository.save(UserGame.manual(userId, appid, "Switch", status, 600, Instant.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserGame steamRow(long userId, long appid, GameStatus status) {
|
||||||
|
UserGame game = new UserGame(userId, appid, 300, Instant.now(), Instant.now());
|
||||||
|
game.setStatus(status);
|
||||||
|
game.setStatusSetByUser(true);
|
||||||
|
return userGameRepository.save(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserGame row(long userId, long appid) {
|
||||||
|
return userGameRepository.findById(new UserGameId(userId, appid)).orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasRow(long userId, long appid) {
|
||||||
|
return userGameRepository.findById(new UserGameId(userId, appid)).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long aliasOf(long appid) {
|
||||||
|
List<Long> found = jdbc.queryForList("select to_appid from game_alias where from_appid = ?", Long.class, appid);
|
||||||
|
return found.isEmpty() ? null : found.getFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> auditActions() {
|
||||||
|
return jdbc.queryForList("select action from admin_audit order by id", String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String undoDetail() {
|
||||||
|
return jdbc.queryForObject("select detail from admin_audit where action = 'GAME_MERGE_UNDO'", String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int count(String sql, Object... args) {
|
||||||
|
Integer count = jdbc.queryForObject(sql, Integer.class, args);
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int logRows(long userId, long appid) {
|
||||||
|
return count("select count(*) from user_game_status_change where user_id = ? and appid = ?", userId, appid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> ledger(long userId) {
|
||||||
|
return jdbc.queryForList("select kind || ' ' || ref from reward_ledger where user_id = ? "
|
||||||
|
+ "and kind in ('STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED') order by kind, ref", String.class, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void movedUser_KeepsTheEditsMadeAfterTheMerge_AndTheHistoryGoesBack() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.PLAYING);
|
||||||
|
gameMoodsRepository.upsert(MANUAL, new String[]{"ACTION"}, new String[]{"Roguelike"}, false, Instant.now());
|
||||||
|
jdbc.update("insert into game_time_to_beat (appid, normally_minutes, fetched_at) values (?, 600, now())", MANUAL);
|
||||||
|
jdbc.update("insert into game_correction (appid, field, value, corrected_by, corrected_at) "
|
||||||
|
+ "values (?, 'NAME', 'Hades Switch', 1, now())", MANUAL);
|
||||||
|
merge(MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
jdbc.update("update user_game set status = 'FINISHED', rating = 4 where user_id = ? and appid = ?", AUDITOR_ID, STEAM);
|
||||||
|
int history = logRows(AUDITOR_ID, STEAM);
|
||||||
|
|
||||||
|
undo(lastMerge()).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
UserGame back = row(AUDITOR_ID, MANUAL);
|
||||||
|
assertThat(back.getStatus()).isEqualTo(GameStatus.FINISHED);
|
||||||
|
assertThat(back.getRating()).isEqualTo(4);
|
||||||
|
assertThat(back.getSource()).isEqualTo(GameSource.MANUAL);
|
||||||
|
assertThat(hasRow(AUDITOR_ID, STEAM)).isFalse();
|
||||||
|
assertThat(logRows(AUDITOR_ID, MANUAL)).isEqualTo(history);
|
||||||
|
assertThat(steamGameRepository.findById(MANUAL)).isPresent();
|
||||||
|
assertThat(steamGameRepository.findById(MANUAL).orElseThrow().getName()).isEqualTo("Hades (Switch)");
|
||||||
|
assertThat(gameMoodsRepository.findById(MANUAL)).isPresent();
|
||||||
|
assertThat(gameTimeToBeatRepository.findById(MANUAL)).isPresent();
|
||||||
|
assertThat(count("select count(*) from game_correction where appid = ?", MANUAL)).isEqualTo(1);
|
||||||
|
assertThat(aliasOf(MANUAL)).isNull();
|
||||||
|
assertThat(count("select count(*) from game_merge_undo")).isZero();
|
||||||
|
assertThat(auditActions()).containsExactly("GAME_MERGE", "GAME_MERGE_UNDO");
|
||||||
|
assertThat(undoDetail()).isEqualTo("\"Hades (Switch)\" from \"Hades\": 1 back, 0 split, 0 stayed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void combinedUser_NotChangedSinceTheMerge_GetsEachRowBackWithItsOwnOldValues() throws Exception {
|
||||||
|
UserGame steam = steamRow(AUDITOR_ID, STEAM, GameStatus.PLAYING);
|
||||||
|
steam.setRating(4);
|
||||||
|
steam.setPriority(GamePriority.HIGH);
|
||||||
|
userGameRepository.save(steam);
|
||||||
|
UserGame manual = manualRow(AUDITOR_ID, MANUAL, GameStatus.FINISHED);
|
||||||
|
manual.setNotes("beat it on Switch");
|
||||||
|
userGameRepository.save(manual);
|
||||||
|
int steamHistory = logRows(AUDITOR_ID, STEAM);
|
||||||
|
int manualHistory = logRows(AUDITOR_ID, MANUAL);
|
||||||
|
merge(MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
assertThat(row(AUDITOR_ID, STEAM).getStatus()).isEqualTo(GameStatus.FINISHED);
|
||||||
|
|
||||||
|
undo(lastMerge()).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
UserGame keptSteam = row(AUDITOR_ID, STEAM);
|
||||||
|
assertThat(keptSteam.getStatus()).isEqualTo(GameStatus.PLAYING);
|
||||||
|
assertThat(keptSteam.getRating()).isEqualTo(4);
|
||||||
|
assertThat(keptSteam.getNotes()).isNull();
|
||||||
|
assertThat(keptSteam.getPriority()).isEqualTo(GamePriority.HIGH);
|
||||||
|
assertThat(keptSteam.getSource()).isEqualTo(GameSource.STEAM);
|
||||||
|
assertThat(keptSteam.getPlaytimeMinutes()).isEqualTo(300);
|
||||||
|
UserGame keptManual = row(AUDITOR_ID, MANUAL);
|
||||||
|
assertThat(keptManual.getStatus()).isEqualTo(GameStatus.FINISHED);
|
||||||
|
assertThat(keptManual.getNotes()).isEqualTo("beat it on Switch");
|
||||||
|
assertThat(keptManual.getRating()).isNull();
|
||||||
|
assertThat(keptManual.getSource()).isEqualTo(GameSource.MANUAL);
|
||||||
|
assertThat(keptManual.getPlaytimeMinutes()).isEqualTo(600);
|
||||||
|
// The old histories are back on their keys, with no entry for the status the merge took over or gave back.
|
||||||
|
assertThat(logRows(AUDITOR_ID, STEAM)).isEqualTo(steamHistory);
|
||||||
|
assertThat(logRows(AUDITOR_ID, MANUAL)).isEqualTo(manualHistory);
|
||||||
|
assertThat(undoDetail()).contains("0 back, 1 split, 0 stayed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void combinedUser_ChangedSinceTheMerge_BothRowsGetTheCurrentValues() throws Exception {
|
||||||
|
UserGame steam = steamRow(AUDITOR_ID, STEAM, GameStatus.PLAYING);
|
||||||
|
steam.setRating(4);
|
||||||
|
userGameRepository.save(steam);
|
||||||
|
UserGame manual = manualRow(AUDITOR_ID, MANUAL, GameStatus.FINISHED);
|
||||||
|
manual.setNotes("beat it on Switch");
|
||||||
|
userGameRepository.save(manual);
|
||||||
|
int manualHistory = logRows(AUDITOR_ID, MANUAL);
|
||||||
|
merge(MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
jdbc.update("update user_game set status = 'DROPPED', notes = 'too hard', rating = 2 where user_id = ? and appid = ?",
|
||||||
|
AUDITOR_ID, STEAM);
|
||||||
|
|
||||||
|
undo(lastMerge()).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
for (long appid : List.of(STEAM, MANUAL)) {
|
||||||
|
UserGame game = row(AUDITOR_ID, appid);
|
||||||
|
assertThat(game.getStatus()).as("status of %d", appid).isEqualTo(GameStatus.DROPPED);
|
||||||
|
assertThat(game.getNotes()).as("notes of %d", appid).isEqualTo("too hard");
|
||||||
|
assertThat(game.getRating()).as("rating of %d", appid).isEqualTo(2);
|
||||||
|
}
|
||||||
|
assertThat(row(AUDITOR_ID, MANUAL).getSource()).isEqualTo(GameSource.MANUAL);
|
||||||
|
assertThat(row(AUDITOR_ID, MANUAL).getPlaytimeMinutes()).isEqualTo(600);
|
||||||
|
assertThat(row(AUDITOR_ID, STEAM).getSource()).isEqualTo(GameSource.STEAM);
|
||||||
|
assertThat(row(AUDITOR_ID, STEAM).getPlaytimeMinutes()).isEqualTo(300);
|
||||||
|
// The old history of the manual row is back on it; the status entry of today is the user's own, on STEAM.
|
||||||
|
assertThat(logRows(AUDITOR_ID, MANUAL)).isEqualTo(manualHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void combinedUser_SteamCopyWasGone_TheSteamRowGetsItsOwnershipBack() throws Exception {
|
||||||
|
UserGame steam = steamRow(AUDITOR_ID, STEAM, GameStatus.BACKLOG);
|
||||||
|
steam.setRemovedAt(Instant.parse("2026-01-01T00:00:00Z"));
|
||||||
|
steam.setHiddenAt(Instant.parse("2026-01-02T00:00:00Z"));
|
||||||
|
userGameRepository.save(steam);
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.PLAYING);
|
||||||
|
merge(MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
assertThat(row(AUDITOR_ID, STEAM).getRemovedAt()).isNull();
|
||||||
|
|
||||||
|
undo(lastMerge()).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
UserGame keptSteam = row(AUDITOR_ID, STEAM);
|
||||||
|
assertThat(keptSteam.getRemovedAt()).isEqualTo(Instant.parse("2026-01-01T00:00:00Z"));
|
||||||
|
assertThat(keptSteam.getHiddenAt()).isEqualTo(Instant.parse("2026-01-02T00:00:00Z"));
|
||||||
|
assertThat(keptSteam.getSource()).isEqualTo(GameSource.STEAM);
|
||||||
|
assertThat(keptSteam.getPlaytimeMinutes()).isEqualTo(300);
|
||||||
|
assertThat(row(AUDITOR_ID, MANUAL).getSource()).isEqualTo(GameSource.MANUAL);
|
||||||
|
assertThat(row(AUDITOR_ID, MANUAL).getRemovedAt()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rewards_StayPaidOnceUnderTheKeyTheGameHasAgain() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.FINISHED);
|
||||||
|
manualRow(OTHER_USER, MANUAL, GameStatus.PLAYING);
|
||||||
|
rewardService.settleAll();
|
||||||
|
List<String> paid = ledger(AUDITOR_ID);
|
||||||
|
assertThat(paid).isNotEmpty().allMatch(entry -> entry.endsWith(" " + MANUAL));
|
||||||
|
merge(MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
// The moved user finishes the game after the merge: paid on the kept key.
|
||||||
|
jdbc.update("update user_game set status = 'FINISHED', status_set_by_user = true where user_id = ? and appid = ?",
|
||||||
|
OTHER_USER, STEAM);
|
||||||
|
rewardService.settleAll();
|
||||||
|
assertThat(ledger(OTHER_USER)).isNotEmpty().allMatch(entry -> entry.endsWith(" " + STEAM));
|
||||||
|
|
||||||
|
undo(lastMerge()).andExpect(status().isNoContent());
|
||||||
|
rewardService.settleAll();
|
||||||
|
|
||||||
|
assertThat(ledger(AUDITOR_ID)).isEqualTo(paid);
|
||||||
|
assertThat(ledger(OTHER_USER)).isNotEmpty().allMatch(entry -> entry.endsWith(" " + MANUAL));
|
||||||
|
assertThat(count("select count(*) from reward_ledger where ref = ?", String.valueOf(STEAM))).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void movedRowTakenOverBySteam_StaysWhereItIs() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.PLAYING);
|
||||||
|
merge(MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
jdbc.update("update user_game set source = 'STEAM' where user_id = ? and appid = ?", AUDITOR_ID, STEAM);
|
||||||
|
|
||||||
|
undo(lastMerge()).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
assertThat(hasRow(AUDITOR_ID, STEAM)).isTrue();
|
||||||
|
assertThat(hasRow(AUDITOR_ID, MANUAL)).isFalse();
|
||||||
|
assertThat(undoDetail()).endsWith("0 back, 0 split, 1 stayed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void links_ComeBackWithTheirSetAndPairs() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
|
||||||
|
jdbc.update("insert into game_link (appid, set_id) values (?, 7), (?, 7)", MANUAL, 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 (?, ?, ?)", AUDITOR_ID, MANUAL_2, MANUAL);
|
||||||
|
merge(MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
assertThat(count("select count(*) from game_link_pair")).isZero();
|
||||||
|
|
||||||
|
undo(lastMerge()).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
assertThat(jdbc.queryForList("select appid || ':' || set_id from game_link order by appid", String.class))
|
||||||
|
.containsExactly(MANUAL + ":7", STEAM_2 + ":7");
|
||||||
|
assertThat(count("select count(*) from game_link_pair where appid_a = ? and appid_b = ?", MANUAL_2, MANUAL)).isEqualTo(1);
|
||||||
|
assertThat(count("select count(*) from game_link_request")).isEqualTo(1);
|
||||||
|
assertThat(undoDetail()).doesNotContain("links kept");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void linksChangedAfterTheMerge_UndoStillRuns_TheOldKeyComesBackUnlinked() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
|
||||||
|
jdbc.update("insert into game_link (appid, set_id) values (?, 7), (?, 7)", MANUAL, STEAM_2);
|
||||||
|
merge(MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
jdbc.update("delete from game_link");
|
||||||
|
|
||||||
|
undo(lastMerge()).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
assertThat(count("select count(*) from game_link")).isZero();
|
||||||
|
assertThat(hasRow(AUDITOR_ID, MANUAL)).isTrue();
|
||||||
|
assertThat(undoDetail()).endsWith("; links kept");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void secondMergeIntoTheKeptKey_BlocksTheOlderUndoUntilTheNewerIsUndone() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
|
||||||
|
manualRow(OTHER_USER, MANUAL_2, GameStatus.BACKLOG);
|
||||||
|
merge(MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
long first = lastMerge();
|
||||||
|
merge(MANUAL_2, STEAM).andExpect(status().isOk());
|
||||||
|
long second = lastMerge();
|
||||||
|
|
||||||
|
undo(first).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("NEWER_MERGE"));
|
||||||
|
undo(second).andExpect(status().isNoContent());
|
||||||
|
undo(first).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
assertThat(hasRow(AUDITOR_ID, MANUAL)).isTrue();
|
||||||
|
assertThat(hasRow(OTHER_USER, MANUAL_2)).isTrue();
|
||||||
|
assertThat(aliasOf(MANUAL)).isNull();
|
||||||
|
assertThat(aliasOf(MANUAL_2)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keptKeyMergedFurther_BlocksTheOlderUndo() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
|
||||||
|
merge(MANUAL, MANUAL_2).andExpect(status().isOk());
|
||||||
|
long first = lastMerge();
|
||||||
|
merge(MANUAL_2, STEAM).andExpect(status().isOk());
|
||||||
|
|
||||||
|
undo(first).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("NEWER_MERGE"));
|
||||||
|
|
||||||
|
assertThat(aliasOf(MANUAL)).isEqualTo(STEAM);
|
||||||
|
undo(lastMerge()).andExpect(status().isNoContent());
|
||||||
|
assertThat(aliasOf(MANUAL)).isEqualTo(MANUAL_2);
|
||||||
|
undo(first).andExpect(status().isNoContent());
|
||||||
|
assertThat(hasRow(AUDITOR_ID, MANUAL)).isTrue();
|
||||||
|
assertThat(aliasOf(MANUAL)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void undoTwice_OldMerge_UnknownId_AndANonMergeRow_AreRefused() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
|
||||||
|
merge(MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
long id = lastMerge();
|
||||||
|
undo(id).andExpect(status().isNoContent());
|
||||||
|
undo(id).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("UNDO_NOT_AVAILABLE"));
|
||||||
|
|
||||||
|
// A merge from before the undo existed has an audit row and no copy.
|
||||||
|
jdbc.update("insert into admin_audit (actor_user_id, action, appid, target_appid) values (1, 'GAME_MERGE', ?, ?)",
|
||||||
|
MANUAL_2, STEAM_2);
|
||||||
|
undo(lastMerge()).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("UNDO_NOT_AVAILABLE"));
|
||||||
|
|
||||||
|
undo(999_999_999L).andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("MERGE_NOT_FOUND"));
|
||||||
|
Long linkRow = jdbc.queryForObject("insert into admin_audit (actor_user_id, action) values (1, 'GAME_LINK') "
|
||||||
|
+ "returning id", Long.class);
|
||||||
|
undo(linkRow).andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("MERGE_NOT_FOUND"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void afterThirtyDays_UndoIsGone_AndThePurgeDeletesOnlyTheOldCopies() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
|
||||||
|
manualRow(AUDITOR_ID, MANUAL_2, GameStatus.BACKLOG);
|
||||||
|
merge(MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
long old = lastMerge();
|
||||||
|
merge(MANUAL_2, STEAM_2).andExpect(status().isOk());
|
||||||
|
long recent = lastMerge();
|
||||||
|
jdbc.update("update game_merge_undo set created_at = now() - interval '31 days' where merge_id = ?", old);
|
||||||
|
jdbc.update("update admin_audit set created_at = now() - interval '31 days' where id = ?", old);
|
||||||
|
jdbc.update("update game_merge_undo set created_at = now() - interval '29 days' where merge_id = ?", recent);
|
||||||
|
|
||||||
|
undo(old).andExpect(status().isGone()).andExpect(jsonPath("$.code").value("UNDO_EXPIRED"));
|
||||||
|
gameMergeService.purgeExpiredUndo();
|
||||||
|
|
||||||
|
assertThat(count("select count(*) from game_merge_undo where merge_id = ?", old)).isZero();
|
||||||
|
assertThat(count("select count(*) from game_merge_undo_user where merge_id = ?", old)).isZero();
|
||||||
|
assertThat(count("select count(*) from game_merge_undo where merge_id = ?", recent)).isEqualTo(1);
|
||||||
|
undo(old).andExpect(status().isGone());
|
||||||
|
undo(recent).andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void userDeletedMeanwhile_DoesNotComeBack_AndTheOtherUserIsRestored() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
|
||||||
|
manualRow(OTHER_USER, MANUAL, GameStatus.BACKLOG);
|
||||||
|
jdbc.update("insert into game_link_request (user_id, appid_a, appid_b) values (?, ?, ?)", AUDITOR_ID, MANUAL_2, MANUAL);
|
||||||
|
merge(MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
accountCleanupRepository.deleteRows(List.of(AUDITOR_ID));
|
||||||
|
assertThat(count("select count(*) from game_merge_undo_user where user_id = ?", AUDITOR_ID)).isZero();
|
||||||
|
|
||||||
|
undo(lastMerge()).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
assertThat(hasRow(AUDITOR_ID, MANUAL)).isFalse();
|
||||||
|
assertThat(hasRow(AUDITOR_ID, STEAM)).isFalse();
|
||||||
|
assertThat(hasRow(OTHER_USER, MANUAL)).isTrue();
|
||||||
|
assertThat(count("select count(*) from game_link_request")).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void roles_OnlyAuditorsAndAdminsUndo() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
|
||||||
|
manualRow(AUDITOR_ID, MANUAL_2, GameStatus.BACKLOG);
|
||||||
|
merge(MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
long first = lastMerge();
|
||||||
|
merge(MANUAL_2, STEAM_2).andExpect(status().isOk());
|
||||||
|
|
||||||
|
undo(Role.USER, first).andExpect(status().isForbidden());
|
||||||
|
assertThat(count("select count(*) from game_merge_undo")).isEqualTo(2);
|
||||||
|
undo(Role.AUDITOR, first).andExpect(status().isNoContent());
|
||||||
|
undo(Role.PLATFORM_ADMIN, lastMerge()).andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oldKey_FollowsTheAliasWhileMerged_AndNotAfterTheUndo() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
|
||||||
|
merge(MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/games/" + MANUAL).header("Authorization", "Bearer " + token(Role.USER))
|
||||||
|
.header(ClientHeaderFilter.CLIENT_HEADER, "test"))
|
||||||
|
.andExpect(status().isOk()).andExpect(jsonPath("$.appid").value(STEAM));
|
||||||
|
|
||||||
|
undo(lastMerge()).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/games/" + MANUAL).header("Authorization", "Bearer " + token(Role.USER))
|
||||||
|
.header(ClientHeaderFilter.CLIENT_HEADER, "test"))
|
||||||
|
.andExpect(status().isOk()).andExpect(jsonPath("$.appid").value(MANUAL));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void auditLog_FlagsAMergeUndoableUntilItIsUndone_AndFiltersTheUndo() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
|
||||||
|
merge(MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
String auth = "Bearer " + token(Role.AUDITOR);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/audit-log").header("Authorization", auth)
|
||||||
|
.header(ClientHeaderFilter.CLIENT_HEADER, "test"))
|
||||||
|
.andExpect(status().isOk()).andExpect(jsonPath("$.content[0].action").value("GAME_MERGE"))
|
||||||
|
.andExpect(jsonPath("$.content[0].undoable").value(true));
|
||||||
|
undo(lastMerge()).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/audit-log").header("Authorization", auth)
|
||||||
|
.header(ClientHeaderFilter.CLIENT_HEADER, "test"))
|
||||||
|
.andExpect(jsonPath("$.content[1].action").value("GAME_MERGE"))
|
||||||
|
.andExpect(jsonPath("$.content[1].undoable").value(false));
|
||||||
|
mockMvc.perform(get("/api/library/audit-log?action=GAME_MERGE_UNDO").header("Authorization", auth)
|
||||||
|
.header(ClientHeaderFilter.CLIENT_HEADER, "test"))
|
||||||
|
.andExpect(jsonPath("$.content.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.content[0].action").value("GAME_MERGE_UNDO"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void combinedUserFinishPaidAfterTheMerge_IsNotPaidAgainAfterTheUndo() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL_2, GameStatus.BACKLOG);
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.FINISHED);
|
||||||
|
merge(MANUAL, MANUAL_2).andExpect(status().isOk());
|
||||||
|
// The nightly settle runs after the merge: the finish is paid on the kept key.
|
||||||
|
rewardService.settleAll();
|
||||||
|
assertThat(ledger(AUDITOR_ID)).isNotEmpty().allMatch(entry -> entry.endsWith(" " + MANUAL_2));
|
||||||
|
|
||||||
|
undo(lastMerge()).andExpect(status().isNoContent());
|
||||||
|
rewardService.settleAll();
|
||||||
|
|
||||||
|
assertThat(count("select count(*) from reward_ledger where user_id = ? and kind in ('FINISHED', 'FINISHED_MANUAL')",
|
||||||
|
AUDITOR_ID)).as("finish rows for one finish").isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ledgerOfAUserWhoRemovedTheOldKeyBeforeTheMerge_GoesBackToTheOldKey() throws Exception {
|
||||||
|
manualRow(OTHER_USER, MANUAL, GameStatus.FINISHED);
|
||||||
|
rewardService.settleAll();
|
||||||
|
List<String> paid = ledger(OTHER_USER);
|
||||||
|
assertThat(paid).isNotEmpty().allMatch(entry -> entry.endsWith(" " + MANUAL));
|
||||||
|
jdbc.update("delete from user_game where user_id = ? and appid = ?", OTHER_USER, MANUAL);
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
|
||||||
|
merge(MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
assertThat(ledger(OTHER_USER)).allMatch(entry -> entry.endsWith(" " + STEAM));
|
||||||
|
|
||||||
|
undo(lastMerge()).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
assertThat(ledger(OTHER_USER)).isEqualTo(paid);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void undoKeepsAGameHiddenThatTheUserHidAfterTheMerge() throws Exception {
|
||||||
|
steamRow(AUDITOR_ID, STEAM, GameStatus.PLAYING);
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.FINISHED);
|
||||||
|
merge(MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", AUDITOR_ID, STEAM);
|
||||||
|
|
||||||
|
undo(lastMerge()).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
assertThat(row(AUDITOR_ID, STEAM).getHiddenAt()).isNotNull();
|
||||||
|
assertThat(row(AUDITOR_ID, MANUAL).getHiddenAt()).as("restored copy of a hidden game").isNotNull();
|
||||||
|
assertThat(count("select count(*) from listed_user_game where user_id = ?", AUDITOR_ID)).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void accountDeleteRemovesTheUsersLinkRequestsFromTheMergeCopy() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
|
||||||
|
jdbc.update("insert into game_link_request (user_id, appid_a, appid_b) values (?, ?, ?)", AUDITOR_ID, MANUAL_2, MANUAL);
|
||||||
|
merge(MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
|
||||||
|
accountCleanupRepository.deleteRows(List.of(AUDITOR_ID));
|
||||||
|
|
||||||
|
assertThat(count("select count(*) from game_merge_undo u, jsonb_array_elements(u.rows->'game_link_request') r "
|
||||||
|
+ "where (r->>'user_id')::bigint = ?", AUDITOR_ID)).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mergeIntoTheKeptKeyCommittingWhileTheUndoWaits_BlocksTheUndo() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
|
||||||
|
merge(MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
long mergeId = lastMerge();
|
||||||
|
CompletableFuture<GameMergeService.UndoResult> undoing;
|
||||||
|
// This transaction holds the kept key like a running merge; the undo waits for it.
|
||||||
|
undoing = new TransactionTemplate(transactionManager).execute(tx -> {
|
||||||
|
jdbc.queryForList("select appid from steam_game where appid = ? for update", STEAM);
|
||||||
|
CompletableFuture<GameMergeService.UndoResult> waiting = CompletableFuture
|
||||||
|
.supplyAsync(() -> gameMergeService.undo(mergeId, AUDITOR_ID));
|
||||||
|
awaitLockWaiter(jdbc);
|
||||||
|
jdbc.update("insert into game_merge_undo (merge_id, from_appid, to_appid, rows) values (?, ?, ?, '{}')",
|
||||||
|
mergeId + 1000, MANUAL_2, STEAM);
|
||||||
|
return waiting;
|
||||||
|
});
|
||||||
|
assertThat(undoing.get(30, TimeUnit.SECONDS)).isInstanceOf(GameMergeService.UndoResult.NewerMerge.class);
|
||||||
|
assertThat(count("select count(*) from game_merge_undo where merge_id = ?", mergeId)).isOne();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void combinedUserFinishesTheKeptRowAfterTheMerge_IsPaidOnceAfterTheUndo() throws Exception {
|
||||||
|
manualRow(AUDITOR_ID, MANUAL_2, GameStatus.BACKLOG);
|
||||||
|
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
|
||||||
|
merge(MANUAL, MANUAL_2).andExpect(status().isOk());
|
||||||
|
jdbc.update("update user_game set status = 'FINISHED', status_set_by_user = true where user_id = ? and appid = ?",
|
||||||
|
AUDITOR_ID, MANUAL_2);
|
||||||
|
rewardService.settleAll();
|
||||||
|
assertThat(ledger(AUDITOR_ID)).isNotEmpty().allMatch(entry -> entry.endsWith(" " + MANUAL_2));
|
||||||
|
|
||||||
|
undo(lastMerge()).andExpect(status().isNoContent());
|
||||||
|
rewardService.settleAll();
|
||||||
|
|
||||||
|
assertThat(count("select count(*) from reward_ledger where user_id = ? and kind in ('FINISHED', 'FINISHED_MANUAL')",
|
||||||
|
AUDITOR_ID)).as("finish rows for one finish").isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void undoKeepsTheHideTheOldKeyHadBeforeTheMerge() throws Exception {
|
||||||
|
steamRow(AUDITOR_ID, STEAM, GameStatus.PLAYING);
|
||||||
|
UserGame manual = manualRow(AUDITOR_ID, MANUAL, GameStatus.FINISHED);
|
||||||
|
manual.setHiddenAt(Instant.parse("2026-01-02T00:00:00Z"));
|
||||||
|
userGameRepository.save(manual);
|
||||||
|
merge(MANUAL, STEAM).andExpect(status().isOk());
|
||||||
|
|
||||||
|
undo(lastMerge()).andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
assertThat(row(AUDITOR_ID, MANUAL).getHiddenAt()).as("hide of the old key from before the merge").isNotNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||||||
import org.springframework.test.web.servlet.ResultActions;
|
import org.springframework.test.web.servlet.ResultActions;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
|
|
||||||
import static org.hamcrest.Matchers.hasSize;
|
import static org.hamcrest.Matchers.hasSize;
|
||||||
@@ -121,10 +122,13 @@ class LibraryControllerHiddenTest extends AbstractMockedAdaptersTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void hide_GameAddedByHandIs409() throws Exception {
|
void hide_GameAddedByHand_LeavesTheListedGamesAndComesBack() throws Exception {
|
||||||
setHidden(-1942L, "{\"hidden\":true}")
|
setHidden(-1942L, "{\"hidden\":true}").andExpect(status().isOk());
|
||||||
.andExpect(status().isConflict())
|
|
||||||
.andExpect(jsonPath("$.code").value("MANUAL_GAME"));
|
assertNotNull(stored(-1942L).getHiddenAt());
|
||||||
|
assertEquals(List.of(-1942L), userGameRepository.findHiddenAppids(USER_ID));
|
||||||
|
|
||||||
|
setHidden(-1942L, "{\"hidden\":false}").andExpect(status().isOk());
|
||||||
|
|
||||||
assertNull(stored(-1942L).getHiddenAt());
|
assertNull(stored(-1942L).getHiddenAt());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -15,6 +17,8 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
@Import(AdminAuditRepository.class)
|
@Import(AdminAuditRepository.class)
|
||||||
class AdminAuditRepositoryTest extends AbstractPersistenceTest {
|
class AdminAuditRepositoryTest extends AbstractPersistenceTest {
|
||||||
|
|
||||||
|
private static final Instant SINCE = Instant.now().minus(30, ChronoUnit.DAYS);
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private AdminAuditRepository repository;
|
private AdminAuditRepository repository;
|
||||||
|
|
||||||
@@ -34,7 +38,7 @@ class AdminAuditRepositoryTest extends AbstractPersistenceTest {
|
|||||||
repository.write(1, "GAME_LINK", 220L, 620L, "\"Half-Life 2\" with \"Portal 2\"");
|
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");
|
repository.write(2, "GAME_MERGE", -5L, 220L, "\"Old\" into \"Half-Life 2\": 1 moved, 0 combined");
|
||||||
|
|
||||||
List<AuditEntry> entries = repository.findNewest(null, 0, 10);
|
List<AuditEntry> entries = repository.findNewest(null, SINCE, 0, 10);
|
||||||
|
|
||||||
assertThat(entries).extracting(AuditEntry::action).containsExactly("GAME_MERGE", "GAME_LINK");
|
assertThat(entries).extracting(AuditEntry::action).containsExactly("GAME_MERGE", "GAME_LINK");
|
||||||
AuditEntry merge = entries.get(0);
|
AuditEntry merge = entries.get(0);
|
||||||
@@ -55,9 +59,26 @@ class AdminAuditRepositoryTest extends AbstractPersistenceTest {
|
|||||||
repository.write(1, "GAME_UNLINK", 220L, null, "Was linked with \"Portal 2\"");
|
repository.write(1, "GAME_UNLINK", 220L, null, "Was linked with \"Portal 2\"");
|
||||||
repository.write(1, "GAME_LINK", 620L, 220L, null);
|
repository.write(1, "GAME_LINK", 620L, 220L, null);
|
||||||
|
|
||||||
assertThat(repository.findNewest("GAME_LINK", 0, 10)).extracting(AuditEntry::appid).containsExactly(620L, 220L);
|
assertThat(repository.findNewest("GAME_LINK", SINCE, 0, 10)).extracting(AuditEntry::appid).containsExactly(620L, 220L);
|
||||||
assertThat(repository.findNewest(null, 1, 1)).extracting(AuditEntry::action).containsExactly("GAME_UNLINK");
|
assertThat(repository.findNewest(null, SINCE, 1, 1)).extracting(AuditEntry::action).containsExactly("GAME_UNLINK");
|
||||||
assertThat(repository.findNewest("GAME_UNLINK", 0, 10).getFirst().targetAppid()).isNull();
|
assertThat(repository.findNewest("GAME_UNLINK", SINCE, 0, 10).getFirst().targetAppid()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void write_ReturnsTheId_AndFindNewestFlagsAMergeWithAFreshUndoCopy() {
|
||||||
|
long fresh = repository.write(1, "GAME_MERGE", -5L, 220L, null);
|
||||||
|
long old = repository.write(1, "GAME_MERGE", -6L, 220L, null);
|
||||||
|
repository.write(1, "GAME_LINK", 220L, 620L, null);
|
||||||
|
jdbc.update("delete from game_merge_undo");
|
||||||
|
jdbc.update("insert into game_merge_undo (merge_id, from_appid, to_appid, rows) values (?, -5, 220, '{}')", fresh);
|
||||||
|
jdbc.update("insert into game_merge_undo (merge_id, from_appid, to_appid, created_at, rows) "
|
||||||
|
+ "values (?, -6, 220, now() - interval '31 days', '{}')", old);
|
||||||
|
|
||||||
|
assertThat(repository.findNewest(null, SINCE, 0, 10)).extracting(AuditEntry::undoable)
|
||||||
|
.containsExactly(false, false, true);
|
||||||
|
assertThat(repository.findMergeTime(fresh)).isPresent();
|
||||||
|
assertThat(repository.findMergeTime(fresh + 2)).isEmpty();
|
||||||
|
jdbc.update("delete from game_merge_undo");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GameTimeToBeat;
|
||||||
|
import core_service.shared.test.AbstractPersistenceTest;
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class GameTimeToBeatRepositoryTest extends AbstractPersistenceTest {
|
||||||
|
|
||||||
|
private static final long GAME = 620L;
|
||||||
|
private static final Instant OLD = Instant.parse("2026-01-01T00:00:00Z");
|
||||||
|
private static final Instant NEW = Instant.parse("2026-02-01T00:00:00Z");
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GameTimeToBeatRepository repository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GameCorrectionRepository correctionRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private EntityManager entityManager;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void clean() {
|
||||||
|
correctionRepository.deleteAll();
|
||||||
|
repository.deleteAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private GameTimeToBeat stored() {
|
||||||
|
entityManager.clear();
|
||||||
|
return repository.findById(GAME).orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void corrected() {
|
||||||
|
correctionRepository.upsert(GAME, "TIME_TO_BEAT", "{}", "{}", 1L, OLD);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void upsert_UpdatesEveryTimeWithoutACorrection() {
|
||||||
|
repository.upsert(GAME, 300, 600, 1200, OLD);
|
||||||
|
|
||||||
|
repository.upsert(GAME, 310, 610, 1210, NEW);
|
||||||
|
|
||||||
|
GameTimeToBeat t = stored();
|
||||||
|
assertThat(t.getHastilyMinutes()).isEqualTo(310);
|
||||||
|
assertThat(t.getNormallyMinutes()).isEqualTo(610);
|
||||||
|
assertThat(t.getCompletelyMinutes()).isEqualTo(1210);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void upsert_KeepsTheCorrectedTimesAndUpdatesTheRest() {
|
||||||
|
repository.upsert(GAME, 300, 600, 1200, OLD);
|
||||||
|
corrected();
|
||||||
|
repository.writeCorrected(GAME, 700, 1500, OLD);
|
||||||
|
|
||||||
|
repository.upsert(GAME, 310, 610, 1210, NEW);
|
||||||
|
|
||||||
|
GameTimeToBeat t = stored();
|
||||||
|
assertThat(t.getNormallyMinutes()).isEqualTo(700);
|
||||||
|
assertThat(t.getCompletelyMinutes()).isEqualTo(1500);
|
||||||
|
assertThat(t.getHastilyMinutes()).isEqualTo(310);
|
||||||
|
assertThat(t.getFetchedAt()).isEqualTo(NEW);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void writeCorrected_NewRowKeepsTheGivenFetchedAt() {
|
||||||
|
repository.writeCorrected(GAME, 700, null, Instant.EPOCH);
|
||||||
|
|
||||||
|
GameTimeToBeat t = stored();
|
||||||
|
assertThat(t.getNormallyMinutes()).isEqualTo(700);
|
||||||
|
assertThat(t.getCompletelyMinutes()).isNull();
|
||||||
|
assertThat(t.getHastilyMinutes()).isNull();
|
||||||
|
assertThat(t.getFetchedAt()).isEqualTo(Instant.EPOCH);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void writeCorrected_OldRowKeepsFetchedAtAndHastily() {
|
||||||
|
repository.upsert(GAME, 300, 600, 1200, OLD);
|
||||||
|
|
||||||
|
repository.writeCorrected(GAME, 700, 1500, Instant.EPOCH);
|
||||||
|
|
||||||
|
GameTimeToBeat t = stored();
|
||||||
|
assertThat(t.getFetchedAt()).isEqualTo(OLD);
|
||||||
|
assertThat(t.getHastilyMinutes()).isEqualTo(300);
|
||||||
|
assertThat(t.getNormallyMinutes()).isEqualTo(700);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import core_service.shared.test.AbstractUnitTest;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
@@ -17,6 +19,39 @@ class GameCorrectionServiceTest extends AbstractUnitTest {
|
|||||||
.as(field + " " + value).isInstanceOf(InvalidCorrectionException.class);
|
.as(field + " " + value).isInstanceOf(InvalidCorrectionException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> times(Object normally, Object completely) {
|
||||||
|
Map<String, Object> map = new HashMap<>();
|
||||||
|
map.put("normallyMinutes", normally);
|
||||||
|
map.put("completelyMinutes", completely);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void timeToBeat_AcceptsNullsEqualAndBounds() {
|
||||||
|
for (Map<String, Object> ok : List.of(times(null, null), times(60, null), times(null, 90), times(100, 100),
|
||||||
|
times(1, 60_000))) {
|
||||||
|
assertThat(GameCorrectionService.validate(CorrectionField.TIME_TO_BEAT, ok)).isEqualTo(ok);
|
||||||
|
}
|
||||||
|
Map<String, Object> missing = Map.of("normallyMinutes", 60);
|
||||||
|
assertThat(GameCorrectionService.validate(CorrectionField.TIME_TO_BEAT, missing))
|
||||||
|
.isEqualTo(times(60, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void timeToBeat_RefusesBadValues() {
|
||||||
|
refused(CorrectionField.TIME_TO_BEAT, null);
|
||||||
|
refused(CorrectionField.TIME_TO_BEAT, "60");
|
||||||
|
refused(CorrectionField.TIME_TO_BEAT, times(-1, null));
|
||||||
|
refused(CorrectionField.TIME_TO_BEAT, times(0, null));
|
||||||
|
refused(CorrectionField.TIME_TO_BEAT, times(1.5, null));
|
||||||
|
refused(CorrectionField.TIME_TO_BEAT, times(60_001, null));
|
||||||
|
refused(CorrectionField.TIME_TO_BEAT, times("60", null));
|
||||||
|
refused(CorrectionField.TIME_TO_BEAT, times(100, 99));
|
||||||
|
Map<String, Object> extra = times(60, 90);
|
||||||
|
extra.put("hastilyMinutes", 30);
|
||||||
|
refused(CorrectionField.TIME_TO_BEAT, extra);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void image_OnlyHttpsOnTheAllowedHosts() {
|
void image_OnlyHttpsOnTheAllowedHosts() {
|
||||||
assertThat(GameCorrectionService.validate(CorrectionField.IMAGE,
|
assertThat(GameCorrectionService.validate(CorrectionField.IMAGE,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import java.util.concurrent.atomic.AtomicReference;
|
|||||||
// import watchdog runs only when a test calls it.
|
// import watchdog runs only when a test calls it.
|
||||||
// Every cached context keeps its own pool: small pools keep them all below Postgres' 100 clients.
|
// Every cached context keeps its own pool: small pools keep them all below Postgres' 100 clients.
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||||
properties = {"app.library.nightly-sync-cron=-", "app.library.image-repair-cron=-", "app.rewards.settle-cron=-", "app.speedrun.enabled=false",
|
properties = {"app.library.nightly-sync-cron=-", "app.library.image-repair-cron=-", "app.library.merge-undo-purge-cron=-", "app.rewards.settle-cron=-", "app.speedrun.enabled=false",
|
||||||
"app.library.import-watchdog-ms=86400000",
|
"app.library.import-watchdog-ms=86400000",
|
||||||
"spring.datasource.hikari.maximum-pool-size=4"})
|
"spring.datasource.hikari.maximum-pool-size=4"})
|
||||||
@ActiveProfiles("dev")
|
@ActiveProfiles("dev")
|
||||||
|
|||||||
Reference in new issue
Block a user