diff --git a/readme.md b/readme.md index c5d96b7..ab2951b 100644 --- a/readme.md +++ b/readme.md @@ -243,6 +243,8 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `GET` | `/api/library/questions`, `/api/library/questions/count` (`{results: [{appid, name, imageUrl, question, status, playtimeMinutes, normallyMinutes, lastActivity}]}`, `{count}`) | JWT | | `POST` | `/api/library/questions/{appid}` (`{question, yes, rating?}`; `200 {next}`, `400 INVALID_ANSWER`, `404 GAME_NOT_FOUND`, `409 QUESTION_GONE`) | JWT | | `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT | +| `GET` | `/api/library/removed-games` (`{results: [{appid, name, imageUrl, source, platform, removedAt}]}`: the caller's removed `MANUAL` and `PSN` games, newest first) | JWT | +| `POST` | `/api/library/games/{appid}/restore` (a removed game comes back with its old data; `204`, also when it is already back; `404 GAME_NOT_FOUND`, `409 STEAM_GAME`) | JWT | | `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; Steam and manual games) | JWT | | `POST` | `/api/library/imports` (`{source: TEXT\|CSV\|SEARCH\|GALAXY\|GROUVEE\|HLTB\|PLAYNITE\|BOOKMARKLET, rows: [{title, platform, playtimeMinutes, status, igdbId}]}`, 1–1000 rows; `202 {id}` in `QUEUED`, `409 IMPORT_RUNNING`, `503 IGDB_OFF`) | JWT | | `GET` | `/api/library/imports` (`{imports}`, the last 20), `/api/library/imports/{id}` (status, progress counts, `ahead` while it waits in a queue, `attempts`; the rows with their candidates while `READY`) | JWT | @@ -647,11 +649,21 @@ A game the user adds by hand comes from IGDB (`ManualGameService`). Steam games `user_game.source` (`GameSource`) says who owns the row. The sync never changes or removes a `MANUAL` row, with one exception: when the user later owns the game on Steam under the same appid, the sync takes the row over: it becomes `STEAM`, and status, rating and notes stay. `PSN` rows (a PlayStation import, see below) live the same way. Only `MANUAL` and `PSN` rows can be edited (`PUT /api/library/games/{appid}/manual`) -or deleted (`DELETE /api/library/games/{appid}`). Time to beat uses the Steam appid for positive keys and the IGDB +or removed (`DELETE /api/library/games/{appid}`, soft: it sets `removed_at`, the row keeps status, rating, notes +and history) and restored (`POST /api/library/games/{appid}/restore`, see the diagram). Time to beat uses the Steam appid for positive keys and the IGDB id for negative keys. Search and add check both keys of a game. A game added under minus its IGDB id can still get a second row when IGDB learns its Steam appid later and the user owns it on Steam; an auditor then merges the two (see "Merging duplicates"). +```mermaid +stateDiagram-v2 + [*] --> Listed: add / sync + Listed --> Removed: DELETE /games/{appid} (MANUAL, PSN) + Removed --> Listed: POST /games/{appid}/restore or Undo + Removed: removed_at set, data kept + Removed: PSN sync skips the row +``` + ### Imports Many games at once (V22, `GameImportService`): a list, a CSV or several picks of the game search become one @@ -723,8 +735,10 @@ project ships it; all values in `app.psn.*`). `PsnAccountService` in `library` h - Every `LINKED` or `STEAM_OWNED` title also keeps its own platform, minutes and last played in `user_psn_title` (V44), so the detail shows PlayStation play next to Steam's on a shared row. The Steam row's own playtime stays Steam's. - A `LINKED` title updates its row (`source = PSN`, not removed): playtime, `last_played_at`, `PLAYTIME` changes and the - playtime history; `playtime_2weeks` is the sum of the history of the last 14 days. A game the user deleted stays - `LINKED` and is not added again. A game also owned on Steam stays one row (the Steam row wins). + playtime history; `playtime_2weeks` is the sum of the history of the last 14 days. A game the user removed stays + `LINKED` and the sync does not add it again; its row stays removed, so the sync skips it until the user restores it: + a restore takes the stored title play (`psn_title_play`) and the sync updates the row again. Flyway V46 gave games + removed before (soft delete) a removed `PSN` row and turned a `MANUAL` row re-added on a `LINKED` key back to `PSN`. A game also owned on Steam stays one row (the Steam row wins). - The first run of a user (no `user_psn_title` yet) is a baseline like the first Steam sync: its adds are a count in `sync_result.imported_games`, not a list. PSN changes share the `sync_result` of the Steam part, so the banner and the follow digest need no change. diff --git a/src/main/java/core_service/modules/follow/repo/FollowDigestRepository.java b/src/main/java/core_service/modules/follow/repo/FollowDigestRepository.java index a69568c..768579e 100644 --- a/src/main/java/core_service/modules/follow/repo/FollowDigestRepository.java +++ b/src/main/java/core_service/modules/follow/repo/FollowDigestRepository.java @@ -41,20 +41,20 @@ public class FollowDigestRepository { } /** - * Hidden games never count. Wishlist adds are counted apart, see {@link #countWishlist}. + * Hidden and removed games never count. Wishlist adds are counted apart, see {@link #countWishlist}. */ public Digest countSync(long userId) { return jdbc.queryForObject(""" select (select count(distinct c.appid) from sync_change c join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid - where c.user_id = :userId and c.kind = 'ADDED' and ug.hidden_at is null) as new_games, + where c.user_id = :userId and c.kind = 'ADDED' and ug.hidden_at is null and ug.removed_at is null) as new_games, (select coalesce(sum(c.amount), 0) from sync_change c join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid - where c.user_id = :userId and c.kind = 'ACHIEVEMENTS' and ug.hidden_at is null) as achievements, + where c.user_id = :userId and c.kind = 'ACHIEVEMENTS' and ug.hidden_at is null and ug.removed_at is null) as achievements, (select count(*) from sync_change c join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid - where c.user_id = :userId and c.kind = 'STATUS' and ug.hidden_at is null) as status_changes""", + where c.user_id = :userId and c.kind = 'STATUS' and ug.hidden_at is null and ug.removed_at is null) as status_changes""", user(userId), (rs, i) -> new Digest(rs.getInt("new_games"), rs.getInt("achievements"), rs.getInt("status_changes"), 0)); } @@ -93,7 +93,7 @@ public class FollowDigestRepository { from user_game_status_change c join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid where c.user_id = :userId and c.by_user and c.id > :afterId and c.id <= :upToId - and ug.hidden_at is null""", + and ug.hidden_at is null and ug.removed_at is null""", user(userId).addValue("afterId", afterId).addValue("upToId", upToId), (rs, i) -> new Digest(rs.getInt("new_games"), 0, rs.getInt("status_changes"), 0)); } diff --git a/src/main/java/core_service/modules/library/api/ManualGameController.java b/src/main/java/core_service/modules/library/api/ManualGameController.java index 1bf90fe..25fb922 100644 --- a/src/main/java/core_service/modules/library/api/ManualGameController.java +++ b/src/main/java/core_service/modules/library/api/ManualGameController.java @@ -5,6 +5,7 @@ import core_service.modules.library.api.dto.AddGameRequest; import core_service.modules.library.api.dto.AddedGameDto; import core_service.modules.library.api.dto.GameSearchDto; import core_service.modules.library.api.dto.ManualGameUpdate; +import core_service.modules.library.api.dto.RemovedGamesDto; import core_service.modules.library.service.ManualGameService; import core_service.shared.security.UserPrincipal; import jakarta.validation.Valid; @@ -79,6 +80,20 @@ public class ManualGameController { return toResponse(manualGameService.remove(principal.getIdAsLong(), appid)); } + @PostMapping("/games/{appid}/restore") + public ResponseEntity restore(@AuthenticationPrincipal UserPrincipal principal, + @PathVariable("appid") long appid) { + return toResponse(manualGameService.restore(principal.getIdAsLong(), appid)); + } + + @GetMapping("/removed-games") + public RemovedGamesDto removedGames(@AuthenticationPrincipal UserPrincipal principal) { + return new RemovedGamesDto(manualGameService.removedGames(principal.getIdAsLong()).stream() + .map(game -> new RemovedGamesDto.Game(game.appid(), game.name(), game.imageUrl(), game.source().name(), + game.platform(), game.removedAt())) + .toList()); + } + private static ResponseEntity toResponse(ManualGameService.ChangeResult result) { return switch (result) { case DONE -> ResponseEntity.noContent().build(); diff --git a/src/main/java/core_service/modules/library/api/dto/RemovedGamesDto.java b/src/main/java/core_service/modules/library/api/dto/RemovedGamesDto.java new file mode 100644 index 0000000..df81064 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/RemovedGamesDto.java @@ -0,0 +1,9 @@ +package core_service.modules.library.api.dto; + +import java.time.Instant; +import java.util.List; + +public record RemovedGamesDto(List results) { + + public record Game(long appid, String name, String imageUrl, String source, String platform, Instant removedAt) {} +} diff --git a/src/main/java/core_service/modules/library/domain/RemovedGame.java b/src/main/java/core_service/modules/library/domain/RemovedGame.java new file mode 100644 index 0000000..0fd4466 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/RemovedGame.java @@ -0,0 +1,8 @@ +package core_service.modules.library.domain; + +import java.time.Instant; + +/** + * A game the user removed and can restore. + */ +public record RemovedGame(long appid, String name, String imageUrl, GameSource source, String platform, Instant removedAt) {} diff --git a/src/main/java/core_service/modules/library/repo/SyncChangeRepository.java b/src/main/java/core_service/modules/library/repo/SyncChangeRepository.java index c74c6fb..de0d4ce 100644 --- a/src/main/java/core_service/modules/library/repo/SyncChangeRepository.java +++ b/src/main/java/core_service/modules/library/repo/SyncChangeRepository.java @@ -104,7 +104,7 @@ public class SyncChangeRepository { } /** - * Hidden games are left out when read, so hiding a game after the sync hides it here too. + * Hidden games are left out when read, so hiding a game after the sync hides it here too. A removed game stays only for its own REMOVED entry. */ public Optional find(long userId) { MapSqlParameterSource params = new MapSqlParameterSource("userId", userId); @@ -131,7 +131,7 @@ public class SyncChangeRepository { from sync_change c join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid left join steam_game sg on sg.appid = c.appid - where c.user_id = :userId and ug.hidden_at is null and c.kind <> 'WISHLIST' + where c.user_id = :userId and ug.hidden_at is null and (ug.removed_at is null or c.kind = 'REMOVED') and c.kind <> 'WISHLIST' group by c.kind, c.appid, c.from_status, c.to_status, sg.name, sg.image_url order by c.kind, amount desc nulls last, sg.name """, params, (rs, i) -> new SyncChanges.Entry( diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index 0fe39cb..60e36c4 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -6,6 +6,7 @@ import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.Mood; import core_service.modules.library.domain.RecentEntry; +import core_service.modules.library.domain.RemovedGame; import core_service.modules.library.domain.StatusCount; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameId; @@ -450,6 +451,16 @@ public interface UserGameRepository extends JpaRepository "core_service.modules.library.domain.GameStatus.PLAYING)") int finishIfUntouched(@Param("userId") Long userId, @Param("appid") Long appid); + /** + * Games the user removed (MANUAL and PSN rows); a Steam row the sync removed is not listed, the next sync would + * remove it again. + */ + @Query("select new core_service.modules.library.domain.RemovedGame(ug.appid, sg.name, sg.imageUrl, ug.source, ug.platform, ug.removedAt) " + + "from UserGame ug join SteamGame sg on sg.appid = ug.appid " + + "where ug.userId = :userId and ug.removedAt is not null " + + "and ug.source <> core_service.modules.library.domain.GameSource.STEAM order by ug.removedAt desc, ug.appid") + List findUserRemoved(@Param("userId") Long userId); + /** * Only games with an unlocked achievement: the others cannot get an estimate. */ diff --git a/src/main/java/core_service/modules/library/service/ManualGameService.java b/src/main/java/core_service/modules/library/service/ManualGameService.java index 677457c..fe1a085 100644 --- a/src/main/java/core_service/modules/library/service/ManualGameService.java +++ b/src/main/java/core_service/modules/library/service/ManualGameService.java @@ -7,10 +7,13 @@ import core_service.modules.library.domain.GameKeys; import core_service.modules.library.domain.IgdbGame; import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.RemovedGame; import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.TitlePlay; 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.security.RequiresRole; @@ -29,6 +32,7 @@ import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Consumer; @@ -69,6 +73,7 @@ public class ManualGameService { private final GameEditionService gameEditionService; private final GameCreditService gameCreditService; private final ReleaseDateService releaseDateService; + private final PsnTitleRepository psnTitleRepository; private final TransactionTemplate transaction; public ManualGameService(IgdbApiService igdbApiService, UserGameRepository userGameRepository, @@ -76,7 +81,7 @@ public class ManualGameService { TimeToBeatService timeToBeatService, MoodService moodService, GameEditionService gameEditionService, GameCreditService gameCreditService, ReleaseDateService releaseDateService, - PlatformTransactionManager transactionManager) { + PsnTitleRepository psnTitleRepository, PlatformTransactionManager transactionManager) { this.igdbApiService = igdbApiService; this.userGameRepository = userGameRepository; this.steamGameRepository = steamGameRepository; @@ -86,6 +91,7 @@ public class ManualGameService { this.gameEditionService = gameEditionService; this.gameCreditService = gameCreditService; this.releaseDateService = releaseDateService; + this.psnTitleRepository = psnTitleRepository; this.transaction = new TransactionTemplate(transactionManager); } @@ -287,11 +293,51 @@ public class ManualGameService { } /** - * Deletes the row with its status, rating and notes. Steam games cannot be removed: the next sync would add - * them again. + * Sets removed_at; the row keeps status, rating, notes and history, and {@link #restore} brings it back. Steam + * games cannot be removed: the next sync would add them again. */ public ChangeResult remove(Long userId, long appid) { - return transaction.execute(tx -> changeManual(userId, appid, userGameRepository::delete)); + return transaction.execute(tx -> changeManual(userId, appid, game -> game.setRemovedAt(Instant.now()))); + } + + /** + * Brings a removed game back with its old data. A PSN row takes the stored title play, so no outside call can + * fail; the next sync corrects it. A removed Steam row belongs to the sync and is not restorable. + */ + public ChangeResult restore(Long userId, long appid) { + return transaction.execute(tx -> { + Optional found = userGameRepository.findById(new UserGameId(userId, appid)); + if (found.isEmpty()) { + return ChangeResult.NOT_FOUND; + } + UserGame game = found.get(); + if (game.getRemovedAt() == null) { + return ChangeResult.DONE; + } + if (game.getSource() == GameSource.STEAM) { + return ChangeResult.STEAM_GAME; + } + game.setRemovedAt(null); + game.setLastSeenAt(Instant.now()); + if (game.getSource() == GameSource.PSN) { + applyPsnPlay(userId, game); + } + return ChangeResult.DONE; + }); + } + + public List removedGames(Long userId) { + return userGameRepository.findUserRemoved(userId); + } + + private void applyPsnPlay(Long userId, UserGame game) { + List plays = psnTitleRepository.findPlays(userId, List.of(game.getAppid())); + if (plays.isEmpty()) { + return; + } + game.setPlaytimeMinutes((int) Math.min(plays.stream().mapToLong(TitlePlay::playtimeMinutes).sum(), Integer.MAX_VALUE)); + plays.stream().map(TitlePlay::lastPlayedAt).filter(Objects::nonNull).max(Comparator.naturalOrder()) + .ifPresent(game::setLastPlayedAt); } private ChangeResult changeManual(Long userId, long appid, Consumer change) { diff --git a/src/main/resources/db/migration/V46__Removed_Psn_Games.sql b/src/main/resources/db/migration/V46__Removed_Psn_Games.sql new file mode 100644 index 0000000..2a3c0fd --- /dev/null +++ b/src/main/resources/db/migration/V46__Removed_Psn_Games.sql @@ -0,0 +1,35 @@ +-- Gitea #39: a remove keeps the row now (removed_at), and the user can restore it. Rows from before need two fixes. +-- Both statements are safe to run again. + +-- A game re-added by search after a remove became MANUAL, so the PSN sync skipped it. It is PSN again; the user's +-- status, rating, notes, sets and history stay on the same row. PlayStation owns the playtime of a PSN row. +UPDATE user_game ug +SET source = 'PSN', + playtime_minutes = p.minutes, + last_played_at = COALESCE(p.last_played_at, ug.last_played_at) +FROM (SELECT t.user_id, COALESCE(a.to_appid, t.appid) AS appid, + LEAST(SUM(COALESCE(t.playtime_minutes, 0)), 2147483647)::int AS minutes, + MAX(t.last_played_at) AS last_played_at + FROM user_psn_title t LEFT JOIN game_alias a ON a.from_appid = t.appid + WHERE t.state = 'LINKED' AND t.appid IS NOT NULL + GROUP BY t.user_id, COALESCE(a.to_appid, t.appid)) p +WHERE ug.user_id = p.user_id AND ug.appid = p.appid AND ug.source = 'MANUAL'; + +-- A remove deleted the row before; a LINKED title kept its appid, so the sync never added it again. One +-- soft-removed PSN row per such game, so the user can restore it. The status is a guess, so it is no user choice. Old status, rating and notes are gone: PLAYING +-- when PlayStation has playtime, else BACKLOG. +INSERT INTO user_game (user_id, appid, source, platform, status, status_set_by_user, playtime_minutes, + last_played_at, first_seen_at, last_seen_at, removed_at) +SELECT p.user_id, p.appid, 'PSN', (array_agg(p.platform ORDER BY p.playtime_minutes DESC NULLS LAST))[1], + CASE WHEN SUM(COALESCE(p.playtime_minutes, 0)) > 0 THEN 'PLAYING' ELSE 'BACKLOG' END, + false, LEAST(SUM(COALESCE(p.playtime_minutes, 0)), 2147483647)::int, + MAX(p.last_played_at), MIN(t.created_at), NOW(), NOW() +FROM user_psn_title t +LEFT JOIN game_alias a ON a.from_appid = t.appid +CROSS JOIN LATERAL (SELECT t.user_id, COALESCE(a.to_appid, t.appid) AS appid, t.platform, + t.playtime_minutes, t.last_played_at) p +WHERE t.state = 'LINKED' AND t.appid IS NOT NULL + AND EXISTS (SELECT 1 FROM steam_game g WHERE g.appid = p.appid) + AND NOT EXISTS (SELECT 1 FROM user_game ug WHERE ug.user_id = p.user_id AND ug.appid = p.appid) + AND p.user_id NOT IN (SELECT user_id FROM deleted_user) +GROUP BY p.user_id, p.appid; diff --git a/src/test/java/core_service/modules/follow/repo/FollowDigestRepositoryTest.java b/src/test/java/core_service/modules/follow/repo/FollowDigestRepositoryTest.java new file mode 100644 index 0000000..f8211d8 --- /dev/null +++ b/src/test/java/core_service/modules/follow/repo/FollowDigestRepositoryTest.java @@ -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); + } +} diff --git a/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java b/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java index 13be437..3d074d4 100644 --- a/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java +++ b/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java @@ -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"); diff --git a/src/test/java/core_service/modules/library/api/PsnAccountFlowTest.java b/src/test/java/core_service/modules/library/api/PsnAccountFlowTest.java index 475f6d4..c6adea0 100644 --- a/src/test/java/core_service/modules/library/api/PsnAccountFlowTest.java +++ b/src/test/java/core_service/modules/library/api/PsnAccountFlowTest.java @@ -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 diff --git a/src/test/java/core_service/modules/library/repo/RemovedPsnGamesMigrationTest.java b/src/test/java/core_service/modules/library/repo/RemovedPsnGamesMigrationTest.java new file mode 100644 index 0000000..a53e571 --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/RemovedPsnGamesMigrationTest.java @@ -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) connection -> { + ScriptUtils.executeSqlScript(connection, new ClassPathResource("db/migration/V46__Removed_Psn_Games.sql")); + return null; + }); + } + + private List> 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 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> 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(); + } +} diff --git a/src/test/java/core_service/modules/library/repo/SyncChangeRepositoryTest.java b/src/test/java/core_service/modules/library/repo/SyncChangeRepositoryTest.java index 70c2873..5a54c5c 100644 --- a/src/test/java/core_service/modules/library/repo/SyncChangeRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/SyncChangeRepositoryTest.java @@ -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. diff --git a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java index 6635f53..2c36aab 100644 --- a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -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); diff --git a/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java b/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java index a62069e..8484a6b 100644 --- a/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java +++ b/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java @@ -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