Public Access
feat(library): undo auditor merges for 30 days, redirect old keys
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:
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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user