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