diff --git a/readme.md b/readme.md index 48cff89..20af94a 100644 --- a/readme.md +++ b/readme.md @@ -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`. @@ -140,14 +140,15 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `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`, `GAME_LINK_DISMISS`, `GAME_CORRECT` or `GAME_CORRECTION_RESET`; 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 | @@ -344,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 diff --git a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java index e779518..582c6ec 100644 --- a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java +++ b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java @@ -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 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; } diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index f73ad49..011edee 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -244,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. */ @@ -327,7 +346,8 @@ public class LibraryController { 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() { @@ -368,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")); }; } diff --git a/src/main/java/core_service/modules/library/api/dto/AuditEntryDto.java b/src/main/java/core_service/modules/library/api/dto/AuditEntryDto.java index 644363f..c7b5c32 100644 --- a/src/main/java/core_service/modules/library/api/dto/AuditEntryDto.java +++ b/src/main/java/core_service/modules/library/api/dto/AuditEntryDto.java @@ -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) {} diff --git a/src/main/java/core_service/modules/library/domain/AuditEntry.java b/src/main/java/core_service/modules/library/domain/AuditEntry.java index 4ad9bab..138681a 100644 --- a/src/main/java/core_service/modules/library/domain/AuditEntry.java +++ b/src/main/java/core_service/modules/library/domain/AuditEntry.java @@ -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) {} diff --git a/src/main/java/core_service/modules/library/domain/MergeUndo.java b/src/main/java/core_service/modules/library/domain/MergeUndo.java new file mode 100644 index 0000000..b364f64 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/MergeUndo.java @@ -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) {} diff --git a/src/main/java/core_service/modules/library/domain/UndoCounts.java b/src/main/java/core_service/modules/library/domain/UndoCounts.java new file mode 100644 index 0000000..2202b69 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/UndoCounts.java @@ -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) {} diff --git a/src/main/java/core_service/modules/library/repo/AdminAuditRepository.java b/src/main/java/core_service/modules/library/repo/AdminAuditRepository.java index 4c1e98f..aed1ffd 100644 --- a/src/main/java/core_service/modules/library/repo/AdminAuditRepository.java +++ b/src/main/java/core_service/modules/library/repo/AdminAuditRepository.java @@ -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). @@ -16,24 +19,46 @@ import java.util.List; public class AdminAuditRepository { public static final List ACTIONS = List.of("GAME_MERGE", "GAME_LINK", "GAME_UNLINK", "GAME_LINK_DISMISS", - "GAME_CORRECT", "GAME_CORRECTION_RESET"); + "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 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 findNewest(String action, int offset, int limit) { + public List 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 @@ -41,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"))); } /** diff --git a/src/main/java/core_service/modules/library/repo/GameMergeRepository.java b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java index e6e80be..1d865e1 100644 --- a/src/main/java/core_service/modules/library/repo/GameMergeRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java @@ -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 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 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 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 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 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 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))); + } } diff --git a/src/main/java/core_service/modules/library/service/AuditLogService.java b/src/main/java/core_service/modules/library/service/AuditLogService.java index b89e3b7..cc7a721 100644 --- a/src/main/java/core_service/modules/library/service/AuditLogService.java +++ b/src/main/java/core_service/modules/library/service/AuditLogService.java @@ -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)); } } diff --git a/src/main/java/core_service/modules/library/service/BacklogService.java b/src/main/java/core_service/modules/library/service/BacklogService.java index e890042..4fd7db9 100644 --- a/src/main/java/core_service/modules/library/service/BacklogService.java +++ b/src/main/java/core_service/modules/library/service/BacklogService.java @@ -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()); } diff --git a/src/main/java/core_service/modules/library/service/GameDetailService.java b/src/main/java/core_service/modules/library/service/GameDetailService.java index 7d69e6c..c3d352d 100644 --- a/src/main/java/core_service/modules/library/service/GameDetailService.java +++ b/src/main/java/core_service/modules/library/service/GameDetailService.java @@ -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 getGameDetail(Long userId, String steamId, Long appid) { + public Optional getGameDetail(Long userId, String steamId, Long askedAppid) { + Long appid = askedAppid; Optional 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(); } diff --git a/src/main/java/core_service/modules/library/service/GameMergeService.java b/src/main/java/core_service/modules/library/service/GameMergeService.java index ff553fe..d71c086 100644 --- a/src/main/java/core_service/modules/library/service/GameMergeService.java +++ b/src/main/java/core_service/modules/library/service/GameMergeService.java @@ -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 found = mergeRepository.lockUndo(mergeId); + if (found.isEmpty()) { + Optional 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 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); + } + } } diff --git a/src/main/java/core_service/modules/library/service/ManualGameService.java b/src/main/java/core_service/modules/library/service/ManualGameService.java index 17f1248..506fbbf 100644 --- a/src/main/java/core_service/modules/library/service/ManualGameService.java +++ b/src/main/java/core_service/modules/library/service/ManualGameService.java @@ -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); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 222691c..e34da7f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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 diff --git a/src/main/resources/db/migration/V29__Game_Merge_Undo.sql b/src/main/resources/db/migration/V29__Game_Merge_Undo.sql new file mode 100644 index 0000000..3af65c6 --- /dev/null +++ b/src/main/resources/db/migration/V29__Game_Merge_Undo.sql @@ -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) +); diff --git a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java index 8093980..c6e3d84 100644 --- a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java +++ b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java @@ -17,7 +17,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest { private static final List 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); diff --git a/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java b/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java index 8558900..edee19b 100644 --- a/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java +++ b/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java @@ -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(); + } } diff --git a/src/test/java/core_service/modules/library/api/GameMergeUndoFlowTest.java b/src/test/java/core_service/modules/library/api/GameMergeUndoFlowTest.java new file mode 100644 index 0000000..d81fc6c --- /dev/null +++ b/src/test/java/core_service/modules/library/api/GameMergeUndoFlowTest.java @@ -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 found = jdbc.queryForList("select to_appid from game_alias where from_appid = ?", Long.class, appid); + return found.isEmpty() ? null : found.getFirst(); + } + + private List 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 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 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 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 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 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(); + } +} diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerHiddenTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerHiddenTest.java index 22bc4d5..a50abe3 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerHiddenTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerHiddenTest.java @@ -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()); } diff --git a/src/test/java/core_service/modules/library/repo/AdminAuditRepositoryTest.java b/src/test/java/core_service/modules/library/repo/AdminAuditRepositoryTest.java index 19f541c..09b5076 100644 --- a/src/test/java/core_service/modules/library/repo/AdminAuditRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/AdminAuditRepositoryTest.java @@ -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 entries = repository.findNewest(null, 0, 10); + List 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 diff --git a/src/test/java/core_service/shared/test/AbstractIntegrationTest.java b/src/test/java/core_service/shared/test/AbstractIntegrationTest.java index 9e928c8..79e7874 100644 --- a/src/test/java/core_service/shared/test/AbstractIntegrationTest.java +++ b/src/test/java/core_service/shared/test/AbstractIntegrationTest.java @@ -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")