Compare commits

...
2 Commits
Author SHA1 Message Date
lobo bb8341dfef feat(library): undo auditor merges for 30 days, redirect old keys
Build, Test & Push Core Service / docker (push) Successful in 1m36s
Build, Test & Push Core Service / quality-check (push) Successful in 1m14s
V29 adds game_merge_undo and game_merge_undo_user: every new merge saves
a copy of what it changed. POST /api/library/merges/{mergeId}/undo
(auditors, newest merge first) puts it back; users keep later status
and rating, nothing is paid twice. A nightly job drops copies after 30
days. Old keys follow game_alias to the kept game. Manual games can now
be hidden and shown like Steam games.
2026-10-02 13:39:02 +02:00
lobo 44af19d32e feat(library): auditor catalog search and time to beat correction
GET /api/library/catalog finds any game a user has, by name, for
auditors; GET /api/library/catalog/{appid} loads it for the correction
dialog. New correction field TIME_TO_BEAT (normal + completionist,
1..60000 min, completionist >= normal) that survives the IGDB refresh.
Every correction and reset writes GAME_CORRECT / GAME_CORRECTION_RESET
to admin_audit.
2026-10-02 11:21:15 +02:00
35 changed files with 1757 additions and 61 deletions

No files matched your search

+45 -7
View File
@@ -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
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.
- **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
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`.
@@ -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 |
| `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 |
| `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/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 |
| `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 |
| `DELETE` | `/api/library/link-queue/{appid}/{otherAppid}` (dismiss; `204`, `404 PAIR_NOT_OPEN`) | JWT, AUDITOR |
| `POST` | `/api/library/games/{appid}/link-requests` (`{otherAppid}`, both in the caller's library; `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED` / `PAIR_DISMISSED`, `429 TOO_MANY_REQUESTS`) | JWT |
| `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT |
| `PUT` | `/api/library/games/{appid}/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 |
| `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 |
@@ -311,13 +314,17 @@ a restart.
### 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`,
`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
`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.
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
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.
@@ -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
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).
### 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.
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_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");
// 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) {
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;
}
@@ -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.BrokenImageReport;
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.FinishedHintDismissal;
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.domain.AuditEntry;
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.LinkPair;
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.shared.security.UserPrincipal;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
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.
*/
@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 {
return correctionAnswer(gameCorrectionService.reset(appid, field));
return correctionAnswer(gameCorrectionService.reset(appid, field, principal.getIdAsLong()));
} catch (InvalidCorrectionException e) {
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.
*/
@@ -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.
*/
@@ -283,13 +328,26 @@ public class LibraryController {
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) {
return new CatalogGameDto(game.appid(), game.name(), game.imageUrl());
}
private static AuditEntryDto toDto(AuditEntry entry) {
return new AuditEntryDto(entry.id(), entry.actorUserId(), entry.actorName(), entry.action(), entry.appid(),
entry.gameName(), entry.targetAppid(), entry.targetGameName(), entry.detail(), entry.createdAt());
entry.gameName(), entry.targetAppid(), entry.targetGameName(), entry.detail(), entry.createdAt(),
entry.undoable());
}
private static ResponseEntity<?> sameGame() {
@@ -330,8 +388,6 @@ public class LibraryController {
case DONE -> ResponseEntity.ok(update);
case NOT_FOUND -> ResponseEntity.status(HttpStatus.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 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)
*/
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;
/**
* @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) {}
@@ -9,4 +9,5 @@ import java.time.Instant;
* @param gameName null when the key has no catalog row any more (a merged key)
*/
public record AuditEntry(long id, long actorUserId, String actorName, String action, Long appid, String gameName,
Long targetAppid, String targetGameName, String detail, Instant createdAt) {}
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;
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"
@@ -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.stereotype.Repository;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
/**
* The audit log of what auditors changed for every user (V21).
@@ -15,24 +18,47 @@ import java.util.List;
@RequiredArgsConstructor
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;
public void write(long actorUserId, String action, Long appid, Long targetAppid, String detail) {
jdbc.update("insert into admin_audit (actor_user_id, action, appid, target_appid, detail) "
+ "values (:actor, :action, :appid, :target, :detail)",
/**
* @return the id of the new row
*/
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)
.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 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("""
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
left join account_name n on n.user_id = a.actor_user_id
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)
order by a.id desc
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.getString("action"), (Long) rs.getObject("appid"), rs.getString("game_name"),
(Long) rs.getObject("target_appid"), rs.getString("target_name"), rs.getString("detail"),
rs.getTimestamp("created_at").toInstant()));
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;
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 org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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",
"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
// 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
// copy added by hand, which cannot be hidden.
// copy added by hand, with its hide.
private static final String COMBINE = """
with f as (
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_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,
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
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 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.
*
@@ -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
* 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) {
MapSqlParameterSource keys = new MapSqlParameterSource("from", from).addValue("to", to);
public MergeCounts merge(long mergeId, long from, long 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.
jdbc.query("select 1 from user_game where appid = :from for update", keys, rs -> { });
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;
// 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);
@@ -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);
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.
jdbc.update("""
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);
jdbc.update("update reward_ledger l set ref = cast(:to as text) where " + LEDGER_MOVED, keys);
mergeSets(from, to, keys);
jdbc.update("delete from game_link_pair where :from in (appid_a, appid_b)", keys);
jdbc.update("delete from game_link_request where :from in (appid_a, appid_b)", keys);
@@ -104,9 +188,36 @@ public class GameMergeRepository {
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) "
+ "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);
}
// 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.
private void mergeSets(long from, long to, MapSqlParameterSource keys) {
Optional<Long> fromSet = setOf(from);
@@ -143,4 +254,112 @@ public class GameMergeRepository {
rs -> { targets.put(rs.getLong("from_appid"), rs.getLong("to_appid")); });
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> {
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")
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) " +
"values (:appid, :hastily, :normally, :completely, :fetchedAt) " +
"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",
nativeQuery = true)
void upsert(@Param("appid") Long appid,
@@ -30,4 +36,21 @@ public interface GameTimeToBeatRepository extends JpaRepository<GameTimeToBeat,
@Param("normally") Integer normallyMinutes,
@Param("completely") Integer completelyMinutes,
@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)) {
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));
}
}
@@ -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.BacklogPatch;
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.UserGame;
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
* keeps the first time.
* Hides or shows a game, Steam or added by hand. Hiding again keeps the first time.
*/
@Transactional
public HideResult setHidden(Long userId, Long appid, boolean hidden) {
@@ -77,7 +75,6 @@ public class BacklogService {
game.get().setHiddenAt(null);
return HideResult.DONE;
}
if (game.get().getSource() != GameSource.STEAM) return HideResult.MANUAL_GAME;
if (game.get().getHiddenAt() == null) {
game.get().setHiddenAt(Instant.now());
}
@@ -1,19 +1,28 @@
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.GameCorrection;
import core_service.modules.library.domain.GameCorrectionId;
import core_service.modules.library.domain.GameMoods;
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.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.GameMoodsRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tools.jackson.databind.json.JsonMapper;
@@ -22,7 +31,9 @@ import java.net.URI;
import java.net.URISyntaxException;
import java.time.Instant;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@@ -43,6 +54,9 @@ public class GameCorrectionService {
private static final int MAX_TEXT = 512;
private static final int MAX_GENRES = 10;
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.
private static final Instant LONG_AGO = Instant.EPOCH;
@@ -50,6 +64,9 @@ public class GameCorrectionService {
private final GameCorrectionRepository correctionRepository;
private final GameMoodsRepository gameMoodsRepository;
private final GameSpeedrunRepository gameSpeedrunRepository;
private final GameTimeToBeatRepository gameTimeToBeatRepository;
private final AdminAuditRepository adminAuditRepository;
private final CatalogRepository catalogRepository;
private final JsonMapper jsonMapper;
/**
@@ -65,10 +82,12 @@ public class GameCorrectionService {
return Result.NOT_FOUND;
}
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.
correctionRepository.upsert(appid, field.name(), jsonMapper.writeValueAsString(clean),
correctionRepository.upsert(appid, field.name(), json,
jsonMapper.writeValueAsString(cachedValue(field, game.get())), auditorId, Instant.now());
write(field, game.get(), clean);
adminAuditRepository.write(auditorId, "GAME_CORRECT", appid, null, field.name() + " " + json);
return Result.DONE;
}
@@ -77,7 +96,7 @@ public class GameCorrectionService {
* asked again at the next refresh.
*/
@Transactional
public Result reset(long appid, String fieldName) {
public Result reset(long appid, String fieldName, long auditorId) {
CorrectionField field = field(fieldName);
Optional<SteamGame> game = steamGameRepository.findById(appid);
if (game.isEmpty()) {
@@ -88,15 +107,44 @@ public class GameCorrectionService {
return Result.DONE;
}
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) {
case MOODS, GENRES, ENDLESS -> gameMoodsRepository.markStale(appid, LONG_AGO);
case SPEEDRUN -> gameSpeedrunRepository.markStale(appid, LONG_AGO);
case TIME_TO_BEAT -> gameTimeToBeatRepository.markStale(appid, LONG_AGO);
case NAME, IMAGE -> { }
}
adminAuditRepository.write(auditorId, "GAME_CORRECTION_RESET", appid, null, field.name() + " " + sourceValue);
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.
private static CorrectionField field(String 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 GENRES -> gameMoodsRepository.findById(game.getAppid()).map(GameMoods::getGenres).orElse(List.of());
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 GENRES -> gameMoodsRepository.writeGenres(game.getAppid(), strings(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) {
return ((List<?>) list).stream().map(String.class::cast).toArray(String[]::new);
}
@@ -171,6 +247,17 @@ public class GameCorrectionService {
}
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.GameCorrectionRepository;
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.GameTimeToBeatRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
@@ -66,6 +67,7 @@ public class GameDetailService {
private final GameMoodsRepository gameMoodsRepository;
private final GameCorrectionRepository gameCorrectionRepository;
private final GameLinkRepository gameLinkRepository;
private final GameMergeRepository mergeRepository;
@Value("${app.library.achievement-ttl-hours:1}")
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
*/
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));
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) {
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.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.GameMergeRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* An auditor merges two library keys of the same game, for every user.
@@ -33,6 +39,31 @@ public class GameMergeService {
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 AdminAuditRepository adminAuditRepository;
private final TimeToBeatService timeToBeatService;
@@ -67,8 +98,9 @@ public class GameMergeService {
if (names.size() < 2) {
return new Result.NotFound();
}
MergeCounts counts = mergeRepository.merge(from, to);
adminAuditRepository.write(auditorId, "GAME_MERGE", from, to,
long auditId = adminAuditRepository.write(auditorId, "GAME_MERGE", from, to, null);
MergeCounts counts = mergeRepository.merge(auditId, from, to);
adminAuditRepository.updateDetail(auditId,
"\"" + names.get(from) + "\" into \"" + names.get(to) + "\": " + counts.moved() + " moved, "
+ counts.combined() + " combined");
return new Result.Merged(to);
@@ -81,4 +113,51 @@ public class GameMergeService {
}
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.setLastSeenAt(now);
revived.setRemovedAt(null);
// Only a Steam game can be hidden.
// A game added again is listed again.
revived.setHiddenAt(null);
// The row holds rating, notes and history from before, so an undo of the import must not delete it.
revived.setImportId(null);
+1
View File
@@ -65,6 +65,7 @@ app:
edition-ttl-days: 30
# Reported broken pictures, repaired after the nightly sync.
image-repair-cron: "0 30 1 * * *"
merge-undo-purge-cron: "0 45 3 * * *"
image-repair-batch: 200
image-repair-max-attempts: 3
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",
"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");
@Autowired
@@ -29,6 +29,8 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
@BeforeEach
void seed() {
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");
for (long userId : List.of(1L, 2L)) {
// The V14 trigger adds the user_game_status_change row.
@@ -43,6 +45,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
jdbc.update("""
insert into user_steam_sync (user_id, steam_id, user_seen_at) values (?, '76561198000000001', now())""",
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 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);
@@ -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.GameMoodsRepository;
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.UserGameRepository;
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.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;
@@ -60,12 +62,20 @@ class GameCorrectionFlowTest extends AbstractIntegrationTest {
@Autowired
private GameCorrectionRepository gameCorrectionRepository;
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
gameCorrectionRepository.deleteAll();
userGameRepository.deleteAll();
gameMoodsRepository.deleteAll();
gameSpeedrunRepository.deleteAll();
gameTimeToBeatRepository.deleteAll();
jdbc.update("delete from admin_audit");
steamGameRepository.save(new SteamGame(GAME, "Portal 2", null, 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.
@@ -73,6 +83,7 @@ class GameCorrectionFlowTest extends AbstractIntegrationTest {
userGameRepository.save(owned);
gameMoodsRepository.upsert(GAME, new String[]{"STORY"}, new String[]{"Puzzle"}, false, 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) {
@@ -181,4 +192,75 @@ class GameCorrectionFlowTest extends AbstractIntegrationTest {
correct(Role.AUDITOR, GAME, "moods", "[\"SPEEDRUN\"]").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
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 reward_ledger where user_id in (?, ?)", AUDITOR_ID, OTHER_USER);
@@ -157,6 +158,8 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
assertThat(steamGameRepository.findById(MANUAL)).isEmpty();
assertThat(gameMoodsRepository.findById(MANUAL)).isEmpty();
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
+ " \"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.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 java.time.Instant;
import java.util.List;
import java.time.temporal.ChronoUnit;
import static org.hamcrest.Matchers.hasSize;
@@ -121,10 +122,13 @@ class LibraryControllerHiddenTest extends AbstractMockedAdaptersTest {
}
@Test
void hide_GameAddedByHandIs409() throws Exception {
setHidden(-1942L, "{\"hidden\":true}")
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("MANUAL_GAME"));
void hide_GameAddedByHand_LeavesTheListedGamesAndComesBack() throws Exception {
setHidden(-1942L, "{\"hidden\":true}").andExpect(status().isOk());
assertNotNull(stored(-1942L).getHiddenAt());
assertEquals(List.of(-1942L), userGameRepository.findHiddenAppids(USER_ID));
setHidden(-1942L, "{\"hidden\":false}").andExpect(status().isOk());
assertNull(stored(-1942L).getHiddenAt());
}
@@ -8,6 +8,8 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -15,6 +17,8 @@ import static org.assertj.core.api.Assertions.assertThat;
@Import(AdminAuditRepository.class)
class AdminAuditRepositoryTest extends AbstractPersistenceTest {
private static final Instant SINCE = Instant.now().minus(30, ChronoUnit.DAYS);
@Autowired
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(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");
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_LINK", 620L, 220L, null);
assertThat(repository.findNewest("GAME_LINK", 0, 10)).extracting(AuditEntry::appid).containsExactly(620L, 220L);
assertThat(repository.findNewest(null, 1, 1)).extracting(AuditEntry::action).containsExactly("GAME_UNLINK");
assertThat(repository.findNewest("GAME_UNLINK", 0, 10).getFirst().targetAppid()).isNull();
assertThat(repository.findNewest("GAME_LINK", SINCE, 0, 10)).extracting(AuditEntry::appid).containsExactly(620L, 220L);
assertThat(repository.findNewest(null, SINCE, 1, 1)).extracting(AuditEntry::action).containsExactly("GAME_UNLINK");
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
@@ -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 java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -17,6 +19,39 @@ class GameCorrectionServiceTest extends AbstractUnitTest {
.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
void image_OnlyHttpsOnTheAllowedHosts() {
assertThat(GameCorrectionService.validate(CorrectionField.IMAGE,
@@ -29,7 +29,7 @@ import java.util.concurrent.atomic.AtomicReference;
// 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.
@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",
"spring.datasource.hikari.maximum-pool-size=4"})
@ActiveProfiles("dev")