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

@@ -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")