feat(library): undo auditor merges for 30 days, redirect old keys
Build, Test & Push Core Service / quality-check (push) Successful in 1m14s
Build, Test & Push Core Service / docker (push) Successful in 1m36s

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.
This commit is contained in:
lobo committed 2026-10-02 13:39:02 +02:00
1 parent 44af19d32e
commit bb8341dfef
22 files changed
+1163 -50

No files matched your search

@@ -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;
}
@@ -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"));
};
}
@@ -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) {}
@@ -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,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).
@@ -16,24 +19,46 @@ import java.util.List;
public class AdminAuditRepository {
public static final List<String> 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<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
@@ -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")));
}
/**
@@ -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)));
}
}
@@ -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());
}
@@ -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);
@@ -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
@@ -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")