feat(library): soft delete removed games and add restore
Build, Test & Push Core Service / quality-check (push) Successful in 1m46s
Build, Test & Push Core Service / docker (push) Successful in 1m26s

Removed games could not come back (#39). Remove now sets removed_at
and restore clears it. V46 brings back PSN games removed before.
This commit is contained in:
lobo committed 2026-10-11 09:52:11 +02:00
1 parent 696e06e7fe
commit 4d49b0f023
16 files changed
+620 -20

No files matched your search

@@ -0,0 +1,52 @@
package core_service.modules.follow.repo;
import core_service.modules.follow.domain.Digest;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.assertj.core.api.Assertions.assertThat;
@Import(FollowDigestRepository.class)
class FollowDigestRepositoryTest extends AbstractPersistenceTest {
private static final long USER = 1L;
private static final long LISTED = 10L;
private static final long REMOVED = 11L;
@Autowired
private FollowDigestRepository repository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
jdbc.update("insert into sync_result (user_id, sync_started_at, started_by, imported_games) values (?, now(), 'AUTO', 0)", USER);
for (long appid : new long[]{LISTED, REMOVED}) {
jdbc.update("insert into steam_game (appid, name) values (?, 'Game')", appid);
jdbc.update("insert into user_game (user_id, appid, status, source, platform, status_set_by_user) "
+ "values (?, ?, 'BACKLOG', 'MANUAL', 'PC', true)", USER, appid);
jdbc.update("insert into sync_change (user_id, kind, appid, amount) values (?, 'ADDED', ?, null)", USER, appid);
jdbc.update("insert into sync_change (user_id, kind, appid, amount) values (?, 'ACHIEVEMENTS', ?, 3)", USER, appid);
jdbc.update("insert into sync_change (user_id, kind, appid, from_status, to_status) "
+ "values (?, 'STATUS', ?, 'BACKLOG', 'PLAYING')", USER, appid);
}
jdbc.update("update user_game set removed_at = now() where appid = ?", REMOVED);
}
@Test
void countSync_SkipsRemovedGames() {
assertThat(repository.countSync(USER)).isEqualTo(new Digest(1, 3, 1, 0));
}
@Test
void countHand_SkipsRemovedGames() {
Digest digest = repository.countHand(USER, 0, Long.MAX_VALUE);
assertThat(digest.newGames()).isEqualTo(1);
}
}
@@ -313,8 +313,9 @@ class ManualGameControllerTest extends AbstractMockedAdaptersTest {
}
@Test
void remove_DeletesAGameAddedByHandButNoSteamGame() throws Exception {
void remove_KeepsTheRowWithItsNotesAndHidesItButRemovesNoSteamGame() throws Exception {
add(119388, "Switch");
jdbc.update("update user_game set notes = 'keep me' where appid = -119388");
steamGameRepository.save(new SteamGame(220L, "Half-Life 2", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now()));
@@ -325,10 +326,147 @@ class ManualGameControllerTest extends AbstractMockedAdaptersTest {
mockMvc.perform(delete("/api/library/games/-1").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isNotFound());
assertTrue(userGameRepository.findById(new UserGameId(USER_ID, -119388L)).isEmpty());
UserGame removed = stored(-119388);
assertTrue(removed.getRemovedAt() != null);
assertEquals("keep me", removed.getNotes());
mockMvc.perform(get("/api/library/games/-119388")).andExpect(status().isNotFound());
mockMvc.perform(get("/api/library/games").param("q", "zelda"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isEmpty());
assertNull(stored(220).getRemovedAt());
}
private void remove(long appid) throws Exception {
mockMvc.perform(delete("/api/library/games/" + appid).header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isNoContent());
}
private org.springframework.test.web.servlet.ResultActions restore(long appid) throws Exception {
return mockMvc.perform(post("/api/library/games/" + appid + "/restore")
.header(ClientHeaderFilter.CLIENT_HEADER, "test"));
}
@Test
void removedGames_ListsOnlyTheCallersManualAndPsnRows() throws Exception {
add(119388, "Switch");
add(119133, "PS5");
steamGameRepository.save(new SteamGame(220L, "Half-Life 2", null, Instant.now()));
steamGameRepository.save(new SteamGame(221L, "Other", null, Instant.now()));
UserGame syncRemoved = new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now());
syncRemoved.setRemovedAt(Instant.now());
userGameRepository.save(syncRemoved);
UserGame other = new UserGame(2L, 221L, 300, Instant.now(), Instant.now());
other.setSource(GameSource.MANUAL);
other.setRemovedAt(Instant.now());
userGameRepository.save(other);
remove(-119388);
mockMvc.perform(get("/api/library/removed-games"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.results.length()").value(1))
.andExpect(jsonPath("$.results[0].appid").value(-119388))
.andExpect(jsonPath("$.results[0].name").value("Zelda: Tears of the Kingdom"))
.andExpect(jsonPath("$.results[0].source").value("MANUAL"))
.andExpect(jsonPath("$.results[0].platform").value("Switch"));
}
@Test
void restore_BringsBackStatusRatingNotesAndListsTheGame() throws Exception {
add(119388, "Switch");
jdbc.update("update user_game set status = 'FINISHED', rating = 5, notes = 'loved it' where appid = -119388");
remove(-119388);
restore(-119388).andExpect(status().isNoContent());
mockMvc.perform(get("/api/library/games/-119388"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("FINISHED"))
.andExpect(jsonPath("$.rating").value(5))
.andExpect(jsonPath("$.notes").value("loved it"));
mockMvc.perform(get("/api/library/removed-games")).andExpect(jsonPath("$.results").isEmpty());
}
@Test
void restore_Twice_Is204AndKeepsOneRow() throws Exception {
add(119388, "Switch");
remove(-119388);
restore(-119388).andExpect(status().isNoContent());
restore(-119388).andExpect(status().isNoContent());
assertEquals(1, userGameRepository.count());
}
@Test
void restore_GameOfAnotherUser_Is404() throws Exception {
steamGameRepository.save(new SteamGame(221L, "Other", null, Instant.now()));
UserGame other = new UserGame(2L, 221L, 300, Instant.now(), Instant.now());
other.setSource(GameSource.MANUAL);
other.setRemovedAt(Instant.now());
userGameRepository.save(other);
restore(221).andExpect(status().isNotFound());
assertTrue(userGameRepository.findById(new UserGameId(2L, 221L)).orElseThrow().getRemovedAt() != null);
}
@Test
void restore_SyncRemovedSteamGame_Is409() throws Exception {
steamGameRepository.save(new SteamGame(220L, "Half-Life 2", null, Instant.now()));
UserGame game = new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now());
game.setRemovedAt(Instant.now());
userGameRepository.save(game);
restore(220).andExpect(status().isConflict());
assertTrue(stored(220).getRemovedAt() != null);
}
@Test
void restore_PsnGame_TakesTheStoredTitlePlaytime() throws Exception {
add(119388, "PS5");
jdbc.update("update user_game set source = 'PSN' where appid = -119388");
jdbc.update("""
insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes, last_played_at)
values (?, 'c1', 'LINKED', -119388, 'PS5', 90, now()), (?, 't1', 'LINKED', -119388, 'PS4', 30, null)""",
USER_ID, USER_ID);
remove(-119388);
restore(-119388).andExpect(status().isNoContent());
UserGame game = stored(-119388);
assertNull(game.getRemovedAt());
assertEquals(120, game.getPlaytimeMinutes());
assertTrue(game.getLastPlayedAt() != null);
}
@Test
void restore_ManualGame_KeepsItsTypedHours() throws Exception {
add(119388, "Switch");
remove(-119388);
restore(-119388).andExpect(status().isNoContent());
assertEquals(600, stored(-119388).getPlaytimeMinutes());
}
@Test
void restore_DoesNotPayPointsTwice() throws Exception {
add(119388, "Switch");
jdbc.update("update user_game set status = 'FINISHED' where appid = -119388");
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'FINISHED', '-119388', 50, now())",
USER_ID);
int logBefore = jdbc.queryForObject("select count(*) from user_game_status_change where appid = -119388", Integer.class);
remove(-119388);
restore(-119388).andExpect(status().isNoContent());
assertEquals(1, jdbc.queryForObject("select count(*) from reward_ledger where user_id = ? and kind = 'FINISHED' "
+ "and ref = '-119388'", Integer.class, USER_ID));
assertEquals(logBefore, jdbc.queryForObject("select count(*) from user_game_status_change where appid = -119388",
Integer.class));
}
@Test
void gameAddedByHand_ShowsUpInTheLibraryAndItsDetail() throws Exception {
add(119388, "Switch");
@@ -310,7 +310,8 @@ class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
worker.runSyncNow(1L, null, SyncTrigger.AUTO);
assertThat(jdbc.queryForObject("select count(*) from user_game where appid = -301", Integer.class)).isZero();
assertThat(jdbc.queryForObject("select count(*) from user_game where appid = -301 and removed_at is null",
Integer.class)).isZero();
assertThat(jdbc.queryForObject("select count(*) from game_import", Integer.class)).isEqualTo(1);
}
@@ -414,14 +415,15 @@ class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
}
@Test
void psnRow_CanBeDeletedByHand() throws Exception {
void psnRow_CanBeRemovedByHand() throws Exception {
jdbc.update("insert into steam_game (appid, name) values (-301, 'X') on conflict do nothing");
jdbc.update("insert into user_game (user_id, appid, playtime_minutes, first_seen_at, last_seen_at, source, platform, status, "
+ "status_set_by_user) values (1, -301, 0, now(), now(), 'PSN', 'PS5', 'BACKLOG', true)");
mockMvc.perform(delete("/api/library/games/-301").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isNoContent());
assertThat(jdbc.queryForObject("select count(*) from user_game where appid = -301", Integer.class)).isZero();
assertThat(jdbc.queryForObject("select count(*) from user_game where appid = -301 and removed_at is not null",
Integer.class)).isEqualTo(1);
}
@Test
@@ -0,0 +1,236 @@
package core_service.modules.library.repo;
import core_service.modules.follow.repo.FollowDigestRepository;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.ConnectionCallback;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.datasource.init.ScriptUtils;
import java.sql.SQLException;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* V46 runs on a database that already ran it once: the test seeds the old shapes and runs the file again.
*/
class RemovedPsnGamesMigrationTest extends AbstractPersistenceTest {
private static final long USER = 7L;
@Autowired
private JdbcTemplate jdbc;
private void catalog(long appid) {
jdbc.update("insert into steam_game (appid, name) values (?, 'Game') on conflict do nothing", appid);
}
private void title(long user, String key, String state, Long appid, Integer minutes) {
jdbc.update("""
insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes, last_played_at)
values (?, ?, ?, ?, 'PS5', ?, case when ?::int is null then null else now() end)""",
user, key, state, appid, minutes, minutes);
}
private void row(long user, long appid, String source, String status) {
catalog(appid);
jdbc.update("insert into user_game (user_id, appid, status, source, platform, playtime_minutes) "
+ "values (?, ?, ?, ?, 'Switch', 5)", user, appid, status, source);
}
private void migrate() throws SQLException {
// The JdbcTemplate connection is the one of the test transaction; a pool connection would not see the seed.
jdbc.execute((ConnectionCallback<Void>) connection -> {
ScriptUtils.executeSqlScript(connection, new ClassPathResource("db/migration/V46__Removed_Psn_Games.sql"));
return null;
});
}
private List<Map<String, Object>> rows(long user) {
return jdbc.queryForList("select * from user_game where user_id = ? order by appid", user);
}
@Test
void orphanTitles_BecomeOneRemovedPsnRowPerGame() throws SQLException {
catalog(-10);
title(USER, "c1", "LINKED", -10L, 30);
title(USER, "t1", "LINKED", -10L, 45);
migrate();
assertThat(rows(USER)).singleElement().satisfies(game -> {
assertThat(game.get("source")).isEqualTo("PSN");
assertThat(game.get("removed_at")).isNotNull();
assertThat(game.get("playtime_minutes")).isEqualTo(75);
assertThat(game.get("status_set_by_user")).isEqualTo(false);
});
}
@Test
void orphanTitleWithPlaytimeIsPlaying_WithoutIsBacklog() throws SQLException {
catalog(-10);
catalog(-11);
title(USER, "c1", "LINKED", -10L, 30);
title(USER, "c2", "LINKED", -11L, 0);
migrate();
assertThat(jdbc.queryForObject("select status from user_game where appid = -10", String.class)).isEqualTo("PLAYING");
assertThat(jdbc.queryForObject("select status from user_game where appid = -11", String.class)).isEqualTo("BACKLOG");
}
@Test
void aliasedTitle_UsesTheKeptKey() throws SQLException {
catalog(-10);
catalog(-20);
jdbc.update("insert into game_alias (from_appid, to_appid) values (-10, -20)");
title(USER, "c1", "LINKED", -10L, 30);
migrate();
assertThat(rows(USER)).extracting(game -> game.get("appid")).containsExactly(-20L);
}
@Test
void deletedUser_IsSkipped() throws SQLException {
catalog(-10);
jdbc.update("insert into deleted_user (user_id) values (?)", USER);
title(USER, "c1", "LINKED", -10L, 30);
migrate();
assertThat(rows(USER)).isEmpty();
}
@Test
void titlesThatAreNotLinked_OrHaveNoCatalogEntry_AddNothing() throws SQLException {
catalog(-10);
title(USER, "c1", "DISMISSED", null, 30);
title(USER, "c2", "STEAM_OWNED", -10L, 30);
title(USER, "c3", "LINKED", -99L, 30);
migrate();
assertThat(rows(USER)).isEmpty();
}
@Test
void existingPsnOrSteamRow_IsUntouched() throws SQLException {
row(USER, -10, "PSN", "FINISHED");
row(USER, -11, "STEAM", "BACKLOG");
title(USER, "c1", "LINKED", -10L, 30);
title(USER, "c2", "LINKED", -11L, 30);
migrate();
assertThat(rows(USER)).hasSize(2).allSatisfy(game -> {
assertThat(game.get("removed_at")).isNull();
assertThat(game.get("playtime_minutes")).isEqualTo(5);
});
assertThat(jdbc.queryForObject("select status from user_game where appid = -10", String.class)).isEqualTo("FINISHED");
}
@Test
void removedRow_GetsNoBacklogOrder() throws SQLException {
catalog(-10);
title(USER, "c1", "LINKED", -10L, 0);
migrate();
assertThat(jdbc.queryForObject("select count(*) from backlog_order where user_id = ?", Integer.class, USER)).isZero();
}
@Test
void reAddedManualRow_BecomesPsnAndKeepsUserData() throws SQLException {
row(USER, -10, "MANUAL", "FINISHED");
row(USER, -11, "MANUAL", "BACKLOG");
jdbc.update("update user_game set rating = 4, notes = 'great', priority = 'HIGH' where appid = -10");
jdbc.update("insert into user_game_split (user_id, appid) values (?, -10)", USER);
title(USER, "c1", "LINKED", -10L, 120);
int logBefore = jdbc.queryForObject("select count(*) from user_game_status_change where appid = -10", Integer.class);
migrate();
Map<String, Object> game = jdbc.queryForMap("select * from user_game where user_id = ? and appid = -10", USER);
assertThat(game).containsEntry("source", "PSN").containsEntry("playtime_minutes", 120)
.containsEntry("status", "FINISHED").containsEntry("rating", 4).containsEntry("notes", "great")
.containsEntry("priority", "HIGH").containsEntry("platform", "Switch");
assertThat(game.get("last_played_at")).isNotNull();
assertThat(rows(USER)).hasSize(2);
assertThat(jdbc.queryForObject("select count(*) from user_game_split where appid = -10", Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("select count(*) from user_game_status_change where appid = -10", Integer.class))
.isEqualTo(logBefore);
assertThat(jdbc.queryForObject("select source from user_game where appid = -11", String.class)).isEqualTo("MANUAL");
}
@Test
void reAddedManualRowOnAliasedKey_BecomesPsn() throws SQLException {
row(USER, -20, "MANUAL", "BACKLOG");
jdbc.update("insert into game_alias (from_appid, to_appid) values (-10, -20)");
title(USER, "c1", "LINKED", -10L, 60);
migrate();
assertThat(rows(USER)).singleElement().satisfies(game -> {
assertThat(game.get("source")).isEqualTo("PSN");
assertThat(game.get("playtime_minutes")).isEqualTo(60);
});
}
@Test
void manualRowOfAnotherUserOrWithoutLinkedTitle_IsUntouched() throws SQLException {
row(USER, -10, "MANUAL", "BACKLOG");
row(USER, -11, "MANUAL", "BACKLOG");
row(USER, -12, "MANUAL", "BACKLOG");
title(USER + 1, "c1", "LINKED", -10L, 60);
title(USER, "c2", "DISMISSED", null, 60);
title(USER, "c3", "STEAM_OWNED", -12L, 60);
migrate();
assertThat(rows(USER)).extracting(game -> game.get("source")).containsOnly("MANUAL");
}
@Test
void runTwice_ChangesNothing() throws SQLException {
row(USER, -11, "MANUAL", "BACKLOG");
catalog(-10);
title(USER, "c1", "LINKED", -10L, 30);
title(USER, "c2", "LINKED", -11L, 30);
migrate();
List<Map<String, Object>> once = rows(USER);
migrate();
assertThat(rows(USER)).isEqualTo(once);
}
@Test
void migratedOrphan_IsNoNewGameInTheFollowDigest() throws SQLException {
catalog(-10);
title(USER, "c1", "LINKED", -10L, 30);
migrate();
assertThat(new FollowDigestRepository(new NamedParameterJdbcTemplate(jdbc)).countHand(USER, 0, Long.MAX_VALUE).newGames())
.isZero();
}
@Test
void migratedOrphanInASet_KeepsTheAutoStatusOfTheListedSteamRow() throws SQLException {
row(USER, 500, "STEAM", "BACKLOG");
catalog(-10);
jdbc.update("insert into game_link (appid, set_id) values (500, 1), (-10, 1)");
title(USER, "c1", "LINKED", -10L, 30);
migrate();
assertThat(jdbc.queryForObject("select status_set_by_user from user_game where user_id = ? and appid = 500",
Boolean.class, USER)).isFalse();
}
}
@@ -189,6 +189,16 @@ class SyncChangeRepositoryTest extends AbstractPersistenceTest {
assertThat(rows()).isEqualTo(1);
}
@Test
void find_LeavesOutTheOtherChangesOfARemovedGame() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0,
List.of(SyncChange.added(HALF_LIFE), SyncChange.added(REMOVED), SyncChange.removed(REMOVED)));
assertThat(stored().changes()).extracting(e -> e.change())
.containsExactlyInAnyOrder(SyncChange.added(HALF_LIFE), SyncChange.removed(REMOVED));
}
@Test
void findStatusMovesOfThisTransaction_ReturnsOnlyMovesOfTheSync() {
// Same transaction as the seed: the inserts (from null) are in the log with this now() too.
@@ -2,6 +2,7 @@ package core_service.modules.library.repo;
import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.RemovedGame;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.Mood;
@@ -93,6 +94,25 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
.map(UserGame::getAppid).getContent();
}
@Test
void findUserRemoved_OrdersNewestFirstAndSkipsSteamAndOtherUsers() {
Instant now = Instant.now();
removed(USER_ID, 220L, GameSource.MANUAL, now.minusSeconds(60));
removed(USER_ID, 380L, GameSource.PSN, now);
removed(USER_ID, 620L, GameSource.STEAM, now);
removed(2L, 220L, GameSource.PSN, now);
userGameRepository.flush();
entityManager.clear();
assertEquals(List.of(380L, 220L), userGameRepository.findUserRemoved(USER_ID).stream().map(RemovedGame::appid).toList());
}
private void removed(long userId, long appid, GameSource source, Instant at) {
UserGame game = userGameRepository.findById(new UserGameId(userId, appid)).orElseThrow();
game.setSource(source);
game.setRemovedAt(at);
}
@Test
void playFromPileIfUntouched_OnlyUntouchedListedBacklogGames() {
userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setStatus(GameStatus.BACKLOG);
@@ -7,6 +7,7 @@ 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.GameMergeRepository;
import core_service.modules.library.repo.PsnTitleRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractUnitTest;
@@ -61,6 +62,9 @@ class ManualGameServiceTest extends AbstractUnitTest {
@Mock
private ReleaseDateService releaseDateService;
@Mock
private PsnTitleRepository psnTitleRepository;
@Mock
private PlatformTransactionManager transactionManager;
@@ -69,7 +73,7 @@ class ManualGameServiceTest extends AbstractUnitTest {
@BeforeEach
void setUp() {
service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, mergeRepository, timeToBeatService,
moodService, gameEditionService, gameCreditService, releaseDateService, transactionManager);
moodService, gameEditionService, gameCreditService, releaseDateService, psnTitleRepository, transactionManager);
}
@Test