diff --git a/readme.md b/readme.md index 9817bbc..0513069 100644 --- a/readme.md +++ b/readme.md @@ -96,6 +96,7 @@ method calling another service's method that needs more. | `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT | | `PUT` | `/api/library/games/{appid}/backlog` | JWT | | `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR | +| `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR | | `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT | | `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT | | `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; `409 MANUAL_GAME` for a game added by hand) | JWT | @@ -218,6 +219,24 @@ lets the next refresh ask IGDB or speedrun.com again. An image must be on a host Known limit: a mood or speedrun refresh that waits on the row lock while an auditor saves can still write the source value once (its guard sees the state from before the save); the auditor saves again. +### Merging duplicates + +One game can have two keys: a Steam appid and minus its IGDB id, or two IGDB ids. An AUDITOR merges them for every +user (`GameMergeService`, V21). The key off Steam always goes into the other one; two Steam appids are refused, +since the sync would bring both back. In one transaction: + +- A user with only the old row: the row moves to the new key with its status history (FK `ON UPDATE CASCADE`). +- A user with both rows: one row stays on the new key. Status, rating, notes and priority come from the row whose + current status the user chose (both: the one chosen last); a gap is filled from the other row. Source, platform and playtime stay, unless the + Steam copy is no longer owned. Both status histories stay; the merge itself logs no status change. +- The shared rows of the old key (catalog row, moods, speedrun, time to beat, corrections, broken images) are + deleted. +- `game_alias` points the old key to the new one (kept flat), so a later add of the old IGDB game lands on the new + key. `admin_audit` gets one `GAME_MERGE` row: who, both keys, both names and how many rows moved or were combined. + +There is no undo. The game search shows games already in ToBePlayed first, with their catalog name and image, and +games other users added by hand that IGDB did not return; `inCatalog` marks both (only those can be merged). + ### Deleted accounts Auth drives it (see Auth's readme). `POST /internal/accounts/deleted` adds a tombstone in `deleted_user`: the user @@ -240,7 +259,8 @@ one exception: when the user later owns the game on Steam under the same appid, `STEAM`, and status, rating and notes stay. Only `MANUAL` 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 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. +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"). ## Run locally diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index b4a7e88..3fb14a6 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -9,6 +9,8 @@ import core_service.modules.library.api.dto.FinishedHintDismissal; import core_service.modules.library.api.dto.GameDto; import core_service.modules.library.api.dto.HiddenUpdateDto; import core_service.modules.library.api.dto.LibrarySummaryDto; +import core_service.modules.library.api.dto.MergeRequest; +import core_service.modules.library.api.dto.MergedGameDto; import core_service.modules.library.api.dto.PageDto; import core_service.modules.library.api.dto.PickerDto; import core_service.modules.library.api.dto.StatusUpdateDto; @@ -22,6 +24,7 @@ import core_service.modules.library.domain.StatGroup; import core_service.modules.library.service.BacklogService; import core_service.modules.library.service.GameCorrectionService; import core_service.modules.library.service.GameDetailService; +import core_service.modules.library.service.GameMergeService; import core_service.modules.library.service.ImageRepairService; import core_service.modules.library.service.InvalidCorrectionException; import core_service.modules.library.service.LibraryQueryService; @@ -75,6 +78,7 @@ public class LibraryController { private final ImageRepairService imageRepairService; private final UserStatsService userStatsService; private final GameCorrectionService gameCorrectionService; + private final GameMergeService gameMergeService; @GetMapping("/games-count") public ResponseEntity getGamesCount(@AuthenticationPrincipal UserPrincipal principal) { @@ -187,9 +191,28 @@ public class LibraryController { } } + /** + * An auditor's merge of two keys of the same game, for every user. + */ + @PostMapping("/games/{appid}/merge") + public ResponseEntity mergeGame(@AuthenticationPrincipal UserPrincipal principal, + @PathVariable("appid") Long appid, @Valid @RequestBody MergeRequest request) { + return switch (gameMergeService.merge(appid, request.otherAppid(), principal.getIdAsLong())) { + case GameMergeService.Result.Merged merged -> ResponseEntity.ok(new MergedGameDto(merged.appid())); + case GameMergeService.Result.NotFound _ -> unknownGame(); + case GameMergeService.Result.SameGame _ -> ResponseEntity.badRequest() + .body(Map.of("code", "SAME_GAME", "message", "A game cannot be merged with itself")); + case GameMergeService.Result.SteamGames _ -> ResponseEntity.badRequest() + .body(Map.of("code", "STEAM_GAMES", "message", "Two Steam games cannot be merged")); + }; + } + private static ResponseEntity correctionAnswer(GameCorrectionService.Result result) { - return result == GameCorrectionService.Result.DONE ? ResponseEntity.noContent().build() - : ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("code", "GAME_NOT_FOUND", "message", "Unknown game")); + return result == GameCorrectionService.Result.DONE ? ResponseEntity.noContent().build() : unknownGame(); + } + + private static ResponseEntity unknownGame() { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("code", "GAME_NOT_FOUND", "message", "Unknown game")); } private static ResponseEntity invalidCorrection(String message) { diff --git a/src/main/java/core_service/modules/library/api/dto/GameSearchDto.java b/src/main/java/core_service/modules/library/api/dto/GameSearchDto.java index 95dcbf3..0e3f331 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameSearchDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameSearchDto.java @@ -10,7 +10,8 @@ public record GameSearchDto(boolean available, List results) { /** * @param appid the library key the game gets, see GameKeys * @param inLibrary the user has this game already, from Steam or by hand + * @param inCatalog ToBePlayed knows the game (some user has it), so an auditor can merge it */ public record Hit(long appid, long igdbId, String name, Integer year, String imageUrl, List platforms, - boolean inLibrary) {} + boolean inLibrary, boolean inCatalog) {} } diff --git a/src/main/java/core_service/modules/library/api/dto/MergeRequest.java b/src/main/java/core_service/modules/library/api/dto/MergeRequest.java new file mode 100644 index 0000000..4556d94 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/MergeRequest.java @@ -0,0 +1,8 @@ +package core_service.modules.library.api.dto; + +import jakarta.validation.constraints.NotNull; + +/** + * @param otherAppid the key of the same game to merge with; Core decides which key stays + */ +public record MergeRequest(@NotNull Long otherAppid) {} diff --git a/src/main/java/core_service/modules/library/api/dto/MergedGameDto.java b/src/main/java/core_service/modules/library/api/dto/MergedGameDto.java new file mode 100644 index 0000000..75165f2 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/MergedGameDto.java @@ -0,0 +1,6 @@ +package core_service.modules.library.api.dto; + +/** + * @param appid the key the game lives under now + */ +public record MergedGameDto(long appid) {} diff --git a/src/main/java/core_service/modules/library/domain/MergeCounts.java b/src/main/java/core_service/modules/library/domain/MergeCounts.java new file mode 100644 index 0000000..7399b01 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/MergeCounts.java @@ -0,0 +1,7 @@ +package core_service.modules.library.domain; + +/** + * @param moved user rows that moved to the new key + * @param combined users who had both keys and kept one row + */ +public record MergeCounts(int moved, int combined) {} diff --git a/src/main/java/core_service/modules/library/repo/GameMergeRepository.java b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java new file mode 100644 index 0000000..250dc80 --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java @@ -0,0 +1,119 @@ +package core_service.modules.library.repo; + +import core_service.modules.library.domain.MergeCounts; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Merges one library key into another (V21). Plain SQL: a merge rewrites the key of rows in many tables at once. + */ +@Repository +@RequiredArgsConstructor +public class GameMergeRepository { + + // The shared rows of a game; steam_game last, since user_game refers to it. + private static final List CATALOG_TABLES = List.of("game_mood", "game_speedrun", "game_time_to_beat", + "game_correction", "broken_image", "steam_game"); + + // For a user with rows on both keys, the fields set by hand come from the winner: the row whose current status + // the user chose, and when both are, the one they chose last (a tie keeps the target row; rows from before V14 + // have no history). A gap is filled from the other row. A Steam copy the user no longer owns gives way to the + // copy added by hand, which cannot be hidden. + private static final String COMBINE = """ + with f as ( + select f.*, + case when f.status_set_by_user <> t.status_set_by_user then f.status_set_by_user + else coalesce((select max(c.changed_at) from user_game_status_change c + where c.user_id = f.user_id and c.appid = f.appid and c.by_user), + cast('-infinity' as timestamptz)) + > coalesce((select max(c.changed_at) from user_game_status_change c + where c.user_id = t.user_id and c.appid = t.appid and c.by_user), + cast('-infinity' as timestamptz)) end as from_wins + from user_game f join user_game t on t.user_id = f.user_id and t.appid = :to + where f.appid = :from) + update user_game t set + status = case when f.from_wins then f.status else t.status end, + status_set_by_user = case when f.from_wins then f.status_set_by_user else t.status_set_by_user end, + rating = case when f.from_wins then coalesce(f.rating, t.rating) else coalesce(t.rating, f.rating) end, + notes = case when f.from_wins then coalesce(f.notes, t.notes) else coalesce(t.notes, f.notes) end, + priority = case when f.from_wins then coalesce(f.priority, t.priority) + else coalesce(t.priority, f.priority) end, + source = case when t.removed_at is null then t.source else f.source end, + platform = case when t.removed_at is null then t.platform else f.platform end, + playtime_minutes = case when t.removed_at is null then t.playtime_minutes else f.playtime_minutes end, + playtime_2weeks = case when t.removed_at is null then t.playtime_2weeks else f.playtime_2weeks end, + removed_at = case when t.removed_at is null then null else f.removed_at end, + hidden_at = case when t.removed_at is null then t.hidden_at else null end + from f + where t.appid = :to and t.user_id = f.user_id + """; + + private final NamedParameterJdbcTemplate jdbc; + + /** + * Locks the catalog rows of both keys until the transaction ends, so two merges of one game do not cross. + * + * @return the name per key found + */ + public Map lockGames(long appid, long otherAppid) { + Map names = new HashMap<>(); + jdbc.query("select appid, name from steam_game where appid in (:a, :b) order by appid for update", + new MapSqlParameterSource("a", appid).addValue("b", otherAppid), + rs -> { names.put(rs.getLong("appid"), rs.getString("name")); }); + return names; + } + + /** + * Moves every user row of {@code from} to {@code to}, drops the shared rows of {@code from} and points the + * aliases there. Needs {@link #lockGames} in the same transaction. + */ + public MergeCounts merge(long from, long to) { + MapSqlParameterSource keys = new MapSqlParameterSource("from", from).addValue("to", to); + // A user's edit of a from row waits for the merge instead of being copied too early and then deleted. + jdbc.query("select 1 from user_game where appid = :from for update", keys, rs -> { }); + int combined = jdbc.update(COMBINE, keys); + // Both histories stay on the kept row. The V14 trigger's entry for the status taken over is no real change; + // it has now(), the time of this transaction. The dropped row's history moves before the row goes. + jdbc.update("delete from user_game_status_change where appid = :to and changed_at = now()", keys); + jdbc.update("update user_game_status_change c set appid = :to where c.appid = :from and exists " + + "(select 1 from user_game t where t.user_id = c.user_id and t.appid = :to)", keys); + jdbc.update("delete from user_game f where f.appid = :from and exists " + + "(select 1 from user_game t where t.user_id = f.user_id and t.appid = :to)", keys); + int moved = jdbc.update("update user_game set appid = :to where appid = :from", keys); + for (String table : CATALOG_TABLES) { + jdbc.update("delete from " + table + " where appid = :from", keys); + } + jdbc.update("update game_alias set to_appid = :to where to_appid = :from", keys); + jdbc.update("insert into game_alias (from_appid, to_appid) values (:from, :to) " + + "on conflict (from_appid) do update set to_appid = excluded.to_appid", keys); + return new MergeCounts(moved, combined); + } + + public void auditMerge(long actorUserId, long from, long to, String detail) { + jdbc.update("insert into admin_audit (actor_user_id, action, appid, target_appid, detail) " + + "values (:actor, 'GAME_MERGE', :from, :to, :detail)", + new MapSqlParameterSource("actor", actorUserId).addValue("from", from).addValue("to", to) + .addValue("detail", detail)); + } + + /** + * @return the key each merged key now lives under; keys never merged are left out + */ + public Map findTargets(Collection appids) { + Map targets = new HashMap<>(); + if (appids.isEmpty()) { + return targets; + } + jdbc.query("select from_appid, to_appid from game_alias where from_appid in (:appids)", + new MapSqlParameterSource("appids", appids), + rs -> { targets.put(rs.getLong("from_appid"), rs.getLong("to_appid")); }); + return targets; + } +} diff --git a/src/main/java/core_service/modules/library/repo/SteamGameRepository.java b/src/main/java/core_service/modules/library/repo/SteamGameRepository.java index ae5c816..0fc4457 100644 --- a/src/main/java/core_service/modules/library/repo/SteamGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/SteamGameRepository.java @@ -1,12 +1,15 @@ package core_service.modules.library.repo; import core_service.modules.library.domain.SteamGame; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + public interface SteamGameRepository extends JpaRepository { // Only for a name from Steam or IGDB: an auditor's name stays. @@ -22,4 +25,12 @@ public interface SteamGameRepository extends JpaRepository { @Query("update SteamGame g set g.imageUrl = :imageUrl where g.appid = :appid and not exists " + "(select 1 from GameCorrection c where c.appid = g.appid and c.field = core_service.modules.library.domain.CorrectionField.IMAGE)") void setImageUrl(@Param("appid") Long appid, @Param("imageUrl") String imageUrl); + + /** + * Games added by hand (key below 0) whose name matches. + * + * @param namePattern a {@link UserGameRepository#containsPattern} result + */ + @Query("select g from SteamGame g where g.appid < 0 and lower(g.name) like :namePattern escape '\\' order by g.name") + List findManualByName(@Param("namePattern") String namePattern, Pageable pageable); } diff --git a/src/main/java/core_service/modules/library/service/GameMergeService.java b/src/main/java/core_service/modules/library/service/GameMergeService.java new file mode 100644 index 0000000..c031c9b --- /dev/null +++ b/src/main/java/core_service/modules/library/service/GameMergeService.java @@ -0,0 +1,80 @@ +package core_service.modules.library.service; + +import core_service.modules.library.domain.GameKeys; +import core_service.modules.library.domain.MergeCounts; +import core_service.modules.library.repo.GameMergeRepository; +import core_service.shared.security.RequiresRole; +import core_service.shared.security.Role; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; +import java.util.Map; + +/** + * An auditor merges two library keys of the same game, for every user. + */ +@Service +@RequiresRole(Role.AUDITOR) +@Slf4j +public class GameMergeService { + + public sealed interface Result { + record Merged(long appid) implements Result {} + + record NotFound() implements Result {} + + record SameGame() implements Result {} + + /** The Steam sync would bring both keys back. */ + record SteamGames() implements Result {} + } + + private final GameMergeRepository mergeRepository; + private final TimeToBeatService timeToBeatService; + private final MoodService moodService; + private final TransactionTemplate transaction; + + public GameMergeService(GameMergeRepository mergeRepository, TimeToBeatService timeToBeatService, + MoodService moodService, PlatformTransactionManager transactionManager) { + this.mergeRepository = mergeRepository; + this.timeToBeatService = timeToBeatService; + this.moodService = moodService; + this.transaction = new TransactionTemplate(transactionManager); + } + + /** + * The key off Steam goes into the other one; with two keys off Steam, {@code appid} goes into + * {@code otherAppid}. + */ + public Result merge(long appid, long otherAppid, long auditorId) { + if (appid == otherAppid) { + return new Result.SameGame(); + } + if (GameKeys.isSteam(appid) && GameKeys.isSteam(otherAppid)) { + return new Result.SteamGames(); + } + long from = GameKeys.isSteam(appid) ? otherAppid : appid; + long to = from == appid ? otherAppid : appid; + Result result = transaction.execute(tx -> { + Map names = mergeRepository.lockGames(from, to); + if (names.size() < 2) { + return new Result.NotFound(); + } + MergeCounts counts = mergeRepository.merge(from, to); + mergeRepository.auditMerge(auditorId, from, to, + "\"" + names.get(from) + "\" into \"" + names.get(to) + "\": " + counts.moved() + " moved, " + + counts.combined() + " combined"); + return new Result.Merged(to); + }); + if (result instanceof Result.Merged) { + log.info("Auditor {} merged appid={} into appid={}", auditorId, from, to); + // The target may have had no moods or time to beat yet; both skip what is still fresh. + timeToBeatService.refreshInBackground(List.of(to)); + moodService.refreshInBackground(List.of(to)); + } + return result; + } +} 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 c2b367d..ce261dd 100644 --- a/src/main/java/core_service/modules/library/service/ManualGameService.java +++ b/src/main/java/core_service/modules/library/service/ManualGameService.java @@ -9,12 +9,14 @@ 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.GameMergeRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; import core_service.shared.security.RequiresRole; import core_service.shared.security.Role; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.support.TransactionTemplate; @@ -24,9 +26,11 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.function.Consumer; +import java.util.stream.Collectors; import java.util.stream.Stream; /** @@ -52,20 +56,24 @@ public class ManualGameService { private static final int MIN_HITS = 3; private static final int MAX_HITS = 20; + private static final int MAX_CATALOG_HITS = 10; private final IgdbApiService igdbApiService; private final UserGameRepository userGameRepository; private final SteamGameRepository steamGameRepository; + private final GameMergeRepository mergeRepository; private final TimeToBeatService timeToBeatService; private final MoodService moodService; private final TransactionTemplate transaction; public ManualGameService(IgdbApiService igdbApiService, UserGameRepository userGameRepository, - SteamGameRepository steamGameRepository, TimeToBeatService timeToBeatService, - MoodService moodService, PlatformTransactionManager transactionManager) { + SteamGameRepository steamGameRepository, GameMergeRepository mergeRepository, + TimeToBeatService timeToBeatService, MoodService moodService, + PlatformTransactionManager transactionManager) { this.igdbApiService = igdbApiService; this.userGameRepository = userGameRepository; this.steamGameRepository = steamGameRepository; + this.mergeRepository = mergeRepository; this.timeToBeatService = timeToBeatService; this.moodService = moodService; this.transaction = new TransactionTemplate(transactionManager); @@ -78,14 +86,41 @@ public class ManualGameService { if (!igdbApiService.isEnabled()) { return Optional.of(new GameSearchDto(false, List.of())); } - return igdbApiService.searchGames(query).map(found -> withTypoHelp(query, found)).map(games -> { - Set owned = new HashSet<>(userGameRepository.findActiveAppids(userId, - games.stream().flatMap(game -> possibleKeys(game).stream()).toList())); - return new GameSearchDto(true, games.stream() - .map(game -> new GameSearchDto.Hit(key(game), game.igdbId(), game.name(), game.year(), - game.imageUrl(), fittingPlatforms(game), possibleKeys(game).stream().anyMatch(owned::contains))) - .toList()); - }); + return igdbApiService.searchGames(query).map(found -> withTypoHelp(query, found)) + .map(games -> new GameSearchDto(true, hits(userId, query, games))); + } + + // Games already in ToBePlayed first, with their catalog name and image (an auditor's correction included); then + // games added by hand whose name matches (also a duplicate of a game IGDB now knows on Steam, so an auditor can + // merge it); then the rest in IGDB's order. One hit per key. + private List hits(Long userId, String query, List games) { + Map targets = mergeRepository.findTargets(games.stream().flatMap(game -> ownKeys(game).stream()).toList()); + Map catalog = steamGameRepository.findAllById(games.stream().map(game -> key(game, targets)).toList()) + .stream().collect(Collectors.toMap(SteamGame::getAppid, game -> game)); + List addedByOthers = steamGameRepository.findManualByName(UserGameRepository.containsPattern(query), + PageRequest.of(0, MAX_CATALOG_HITS)); + Set owned = new HashSet<>(userGameRepository.findActiveAppids(userId, Stream.concat( + games.stream().flatMap(game -> possibleKeys(game, targets).stream()), + addedByOthers.stream().map(SteamGame::getAppid)).toList())); + + List inCatalog = new ArrayList<>(); + List rest = new ArrayList<>(); + for (IgdbApiService.Game game : games) { + long appid = key(game, targets); + SteamGame entry = catalog.get(appid); + GameSearchDto.Hit hit = new GameSearchDto.Hit(appid, game.igdbId(), entry != null ? entry.getName() : game.name(), + game.year(), entry != null && entry.getImageUrl() != null ? entry.getImageUrl() : game.imageUrl(), + fittingPlatforms(game), possibleKeys(game, targets).stream().anyMatch(owned::contains), entry != null); + (entry != null ? inCatalog : rest).add(hit); + } + Stream others = addedByOthers.stream().map(game -> new GameSearchDto.Hit(game.getAppid(), + GameKeys.igdbId(game.getAppid()), game.getName(), null, game.getImageUrl(), List.of(), + owned.contains(game.getAppid()), true)); + Set seen = new HashSet<>(); + return Stream.of(inCatalog.stream(), others, rest.stream()).flatMap(hits -> hits) + .filter(hit -> seen.add(hit.appid())) + .limit(MAX_HITS) + .toList(); } // Few hits often mean a typo. IGDB's own hits keep their order; the variant hits follow, closest name first. @@ -119,7 +154,8 @@ public class ManualGameService { /** * Adds the game under its Steam appid when IGDB knows it on Steam: then the sync takes it over once the user owns - * it there, and it keeps one row. A game the user removed before comes back with its old status and notes. + * it there, and it keeps one row. A game the user removed before comes back with its old status and notes. A + * game an auditor merged goes to the key it was merged into. */ public AddResult add(Long userId, long igdbId, String platform, GameStatus status, int playtimeMinutes) { if (!igdbApiService.isEnabled()) { @@ -131,39 +167,36 @@ public class ManualGameService { return new AddResult.UnknownGame(); } IgdbApiService.Game game = found.get(); - long appid = key(game); AddResult result; try { - result = addInTransaction(userId, appid, game, platform, status, playtimeMinutes); + result = addInTransaction(userId, game, platform, status, playtimeMinutes); } catch (DataIntegrityViolationException e) { - // A parallel add or sync inserted the row after the check. The second try sees it. - log.info("Add of appid={} for user={} raced another insert, trying again", appid, userId); + // A parallel add, sync or merge changed the rows after the check. The second try sees it. + log.info("Add of igdbId={} for user={} raced another write, trying again", igdbId, userId); try { - result = addInTransaction(userId, appid, game, platform, status, playtimeMinutes); + result = addInTransaction(userId, game, platform, status, playtimeMinutes); } catch (DataIntegrityViolationException again) { return new AddResult.AlreadyInLibrary(); } } - if (result instanceof AddResult.Added) { - timeToBeatService.refreshInBackground(List.of(appid)); - moodService.refreshInBackground(List.of(appid)); + if (result instanceof AddResult.Added added) { + timeToBeatService.refreshInBackground(List.of(added.appid())); + moodService.refreshInBackground(List.of(added.appid())); } return result; } - private AddResult addInTransaction(Long userId, long appid, IgdbApiService.Game game, String platform, - GameStatus status, int playtimeMinutes) { + private AddResult addInTransaction(Long userId, IgdbApiService.Game game, String platform, GameStatus status, + int playtimeMinutes) { return transaction.execute(tx -> { Instant now = Instant.now(); + Map targets = mergeRepository.findTargets(ownKeys(game)); + long appid = key(game, targets); + List possibleKeys = possibleKeys(game, targets); + if (!userGameRepository.findActiveAppids(userId, possibleKeys).isEmpty()) { + return new AddResult.AlreadyInLibrary(); + } Optional existing = userGameRepository.findById(new UserGameId(userId, appid)); - if (existing.isPresent() && existing.get().getRemovedAt() == null) { - return new AddResult.AlreadyInLibrary(); - } - long igdbKey = GameKeys.ofIgdb(game.igdbId()); - if (appid != igdbKey && userGameRepository.findById(new UserGameId(userId, igdbKey)) - .filter(added -> added.getRemovedAt() == null).isPresent()) { - return new AddResult.AlreadyInLibrary(); - } saveCatalogEntry(appid, game, now); if (existing.isPresent()) { UserGame revived = existing.get(); @@ -212,7 +245,8 @@ public class ManualGameService { return ChangeResult.DONE; } - // A Steam row belongs to the sync, which has the better name and art; only a game off Steam takes IGDB's. + // A Steam row belongs to the sync, which has the better name and art; only a game off Steam takes IGDB's, and + // only its own: a merged game must not rename the game it went into. private void saveCatalogEntry(long appid, IgdbApiService.Game game, Instant now) { Optional existing = steamGameRepository.findById(appid); if (existing.isEmpty()) { @@ -221,7 +255,7 @@ public class ManualGameService { created.setImageUrl(game.imageUrl()); } steamGameRepository.save(created); - } else if (!GameKeys.isSteam(appid)) { + } else if (appid == GameKeys.ofIgdb(game.igdbId())) { steamGameRepository.setSourceName(appid, game.name()); steamGameRepository.setImageUrl(appid, game.imageUrl()); existing.get().setLastSeenAt(now); @@ -233,13 +267,22 @@ public class ManualGameService { return game.platforms().stream().filter(platform -> platform.length() <= ManualGameUpdate.MAX_PLATFORM_LENGTH).toList(); } - // IGDB can learn a game's Steam appid after the user added it under minus its IGDB id. - private static List possibleKeys(IgdbApiService.Game game) { - long igdbKey = GameKeys.ofIgdb(game.igdbId()); - return key(game) == igdbKey ? List.of(igdbKey) : List.of(key(game), igdbKey); + // IGDB can learn a game's Steam appid after the user added it under minus its IGDB id. Each key goes through its + // merge, if an auditor merged it. + private static List possibleKeys(IgdbApiService.Game game, Map targets) { + return ownKeys(game).stream().map(own -> targets.getOrDefault(own, own)).distinct().toList(); } - private static long key(IgdbApiService.Game game) { - return game.steamAppid() != null ? game.steamAppid() : GameKeys.ofIgdb(game.igdbId()); + /** + * @param targets {@link GameMergeRepository#findTargets} of the {@link #ownKeys} + */ + private static long key(IgdbApiService.Game game, Map targets) { + return possibleKeys(game, targets).getFirst(); + } + + // The key IGDB gives today first, then minus the IGDB id when the game is on Steam. + private static List ownKeys(IgdbApiService.Game game) { + long igdbKey = GameKeys.ofIgdb(game.igdbId()); + return game.steamAppid() != null ? List.of(game.steamAppid(), igdbKey) : List.of(igdbKey); } } diff --git a/src/main/resources/db/migration/V21__game_merge.sql b/src/main/resources/db/migration/V21__game_merge.sql new file mode 100644 index 0000000..53d3804 --- /dev/null +++ b/src/main/resources/db/migration/V21__game_merge.sql @@ -0,0 +1,24 @@ +-- An auditor's merge of two library keys of the same game: from_appid (always minus an IGDB id) now lives under +-- to_appid, so a later add of the from game lands there. Kept flat: no to_appid is also a from_appid. +CREATE TABLE game_alias ( + from_appid BIGINT PRIMARY KEY, + to_appid BIGINT NOT NULL +); + +-- One row per auditor or admin action in Core, like Auth's admin_audit. No foreign keys: the rows outlive the game +-- rows and the users. detail keeps the names, since the from game's catalog row is deleted. +CREATE TABLE admin_audit ( + id BIGSERIAL PRIMARY KEY, + actor_user_id BIGINT NOT NULL, + action VARCHAR(32) NOT NULL, + appid BIGINT, + target_appid BIGINT, + detail TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- A merge moves user_game rows to another appid; their status history moves with them. +ALTER TABLE user_game_status_change + DROP CONSTRAINT user_game_status_change_user_id_appid_fkey, + ADD CONSTRAINT user_game_status_change_user_id_appid_fkey FOREIGN KEY (user_id, appid) + REFERENCES user_game (user_id, appid) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java b/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java new file mode 100644 index 0000000..a8ae9f1 --- /dev/null +++ b/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java @@ -0,0 +1,313 @@ +package core_service.modules.library.api; + +import core_service.configuration.security.ClientHeaderFilter; +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.repo.UserStatsRepository; +import core_service.modules.library.domain.UserStats; +import core_service.shared.security.Role; +import core_service.shared.test.AbstractIntegrationTest; +import io.jsonwebtoken.Jwts; +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 java.time.Instant; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +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; + +/** + * Merges through the real (non-dev) filter chain, so the role in the token decides. + */ +@AutoConfigureMockMvc +@ActiveProfiles(value = "prod", inheritProfiles = false) +class GameMergeFlowTest 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 JdbcTemplate jdbc; + + @Autowired + private UserStatsRepository userStatsRepository; + + @BeforeEach + void seed() { + jdbc.update("delete from game_alias"); + jdbc.update("delete from admin_audit"); + 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 Jwts.builder() + .subject("auditor@example.com") + .claim("userId", String.valueOf(AUDITOR_ID)) + .claim("role", role.name()) + .expiration(new Date(System.currentTimeMillis() + 60_000)) + .signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA) + .compact(); + } + + private ResultActions merge(Role role, long appid, long otherAppid) throws Exception { + return mockMvc.perform(post("/api/library/games/" + appid + "/merge") + .header("Authorization", "Bearer " + token(role)).header(ClientHeaderFilter.CLIENT_HEADER, "test") + .contentType(MediaType.APPLICATION_JSON).content("{\"otherAppid\": " + otherAppid + "}")); + } + + private UserGame manualRow(long userId, long appid, GameStatus status) { + UserGame game = UserGame.manual(userId, appid, "Switch", status, 600, Instant.now()); + 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 found = jdbc.queryForList("select to_appid from game_alias where from_appid = ?", Long.class, appid); + return found.isEmpty() ? null : found.getFirst(); + } + + /** Each row as "actor action appid targetAppid detail", oldest first. */ + private List audit() { + return jdbc.query("select * from admin_audit order by id", (rs, i) -> rs.getLong("actor_user_id") + " " + + rs.getString("action") + " " + rs.getLong("appid") + " " + rs.getLong("target_appid") + " " + + rs.getString("detail")); + } + + private int logRows(long userId, long appid) { + Integer count = jdbc.queryForObject("select count(*) from user_game_status_change where user_id = ? and appid = ?", + Integer.class, userId, appid); + return count != null ? count : 0; + } + + @Test + void manualIntoSteam_MovesTheRowWithItsHistoryAndDropsTheManualCatalogRows() throws Exception { + UserGame manual = manualRow(AUDITOR_ID, MANUAL, GameStatus.PLAYING); + manual.setNotes("on the couch"); + userGameRepository.save(manual); + userGameRepository.save(new UserGame(OTHER_USER, STEAM, 120, Instant.now(), Instant.now())); + gameMoodsRepository.upsert(MANUAL, new String[]{"ACTION"}, new String[]{"Roguelike"}, false, Instant.now()); + int historyBefore = logRows(AUDITOR_ID, MANUAL); + + merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk()).andExpect(jsonPath("$.appid").value(STEAM)); + + UserGame moved = row(AUDITOR_ID, STEAM); + assertThat(moved.getSource()).isEqualTo(GameSource.MANUAL); + assertThat(moved.getPlatform()).isEqualTo("Switch"); + assertThat(moved.getStatus()).isEqualTo(GameStatus.PLAYING); + assertThat(moved.getNotes()).isEqualTo("on the couch"); + assertThat(moved.getPlaytimeMinutes()).isEqualTo(600); + assertThat(logRows(AUDITOR_ID, STEAM)).isEqualTo(historyBefore); + assertThat(hasRow(AUDITOR_ID, MANUAL)).isFalse(); + assertThat(row(OTHER_USER, STEAM).getPlaytimeMinutes()).isEqualTo(120); + assertThat(steamGameRepository.findById(MANUAL)).isEmpty(); + assertThat(gameMoodsRepository.findById(MANUAL)).isEmpty(); + assertThat(aliasOf(MANUAL)).isEqualTo(STEAM); + assertThat(audit()).containsExactly(AUDITOR_ID + " GAME_MERGE " + MANUAL + " " + STEAM + + " \"Hades (Switch)\" into \"Hades\": 1 moved, 0 combined"); + } + + @Test + void userWithBothRows_KeepsTheSteamRowWithTheNewerHandSetFields() throws Exception { + UserGame steam = new UserGame(AUDITOR_ID, STEAM, 300, Instant.now(), Instant.now()); + steam.setStatus(GameStatus.PLAYING); + steam.setStatusSetByUser(true); + steam.setRating(4); + steam.setPriority(GamePriority.HIGH); + userGameRepository.save(steam); + // Saved later, so its status change by the user is the newer one. + UserGame manual = manualRow(AUDITOR_ID, MANUAL, GameStatus.FINISHED); + manual.setNotes("beat it on Switch"); + userGameRepository.save(manual); + int historyBefore = logRows(AUDITOR_ID, STEAM) + logRows(AUDITOR_ID, MANUAL); + + merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk()); + + UserGame kept = row(AUDITOR_ID, STEAM); + // Both histories, and no entry for the status the merge took over. + assertThat(logRows(AUDITOR_ID, STEAM)).isEqualTo(historyBefore); + assertThat(kept.getStatus()).isEqualTo(GameStatus.FINISHED); + assertThat(kept.isStatusSetByUser()).isTrue(); + assertThat(kept.getNotes()).isEqualTo("beat it on Switch"); + assertThat(kept.getRating()).isEqualTo(4); + assertThat(kept.getPriority()).isEqualTo(GamePriority.HIGH); + assertThat(kept.getSource()).isEqualTo(GameSource.STEAM); + assertThat(kept.getPlatform()).isNull(); + assertThat(kept.getPlaytimeMinutes()).isEqualTo(300); + assertThat(hasRow(AUDITOR_ID, MANUAL)).isFalse(); + assertThat(logRows(AUDITOR_ID, MANUAL)).isZero(); + } + + @Test + void userWithBothRows_SteamStatusChosenLater_KeepsTheSteamStatus() throws Exception { + manualRow(AUDITOR_ID, MANUAL, GameStatus.FINISHED); + UserGame steam = new UserGame(AUDITOR_ID, STEAM, 300, Instant.now(), Instant.now()); + steam.setStatus(GameStatus.DROPPED); + steam.setStatusSetByUser(true); + userGameRepository.save(steam); + + merge(Role.AUDITOR, STEAM, MANUAL).andExpect(status().isOk()).andExpect(jsonPath("$.appid").value(STEAM)); + + assertThat(row(AUDITOR_ID, STEAM).getStatus()).isEqualTo(GameStatus.DROPPED); + assertThat(hasRow(AUDITOR_ID, MANUAL)).isFalse(); + } + + @Test + void userWithBothRows_SteamCopyGone_KeepsTheCopyAddedByHand() throws Exception { + UserGame steam = new UserGame(AUDITOR_ID, STEAM, 300, Instant.now(), Instant.now()); + steam.setRemovedAt(Instant.now()); + userGameRepository.save(steam); + manualRow(AUDITOR_ID, MANUAL, GameStatus.PLAYING); + + merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk()); + + UserGame kept = row(AUDITOR_ID, STEAM); + assertThat(kept.getRemovedAt()).isNull(); + assertThat(kept.getSource()).isEqualTo(GameSource.MANUAL); + assertThat(kept.getPlatform()).isEqualTo("Switch"); + assertThat(kept.getPlaytimeMinutes()).isEqualTo(600); + assertThat(kept.getStatus()).isEqualTo(GameStatus.PLAYING); + } + + @Test + void userWithBothRows_NoHistory_TheStatusSetByHandWins() throws Exception { + userGameRepository.save(new UserGame(AUDITOR_ID, STEAM, 300, Instant.now(), Instant.now())); + manualRow(AUDITOR_ID, MANUAL, GameStatus.FINISHED); + // Rows from before V14 have no status history. + jdbc.update("delete from user_game_status_change where user_id = ?", AUDITOR_ID); + + merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk()); + + UserGame kept = row(AUDITOR_ID, STEAM); + assertThat(kept.getStatus()).isEqualTo(GameStatus.FINISHED); + assertThat(kept.isStatusSetByUser()).isTrue(); + } + + @Test + void userWithBothRows_HiddenSteamCopyGone_KeepsTheCopyAddedByHandShown() throws Exception { + UserGame steam = new UserGame(AUDITOR_ID, STEAM, 300, Instant.now(), Instant.now()); + steam.setHiddenAt(Instant.now()); + steam.setRemovedAt(Instant.now()); + userGameRepository.save(steam); + manualRow(AUDITOR_ID, MANUAL, GameStatus.PLAYING); + + merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk()); + + assertThat(row(AUDITOR_ID, STEAM).getHiddenAt()).isNull(); + } + + @Test + void chain_StaysFlat() throws Exception { + manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG); + + merge(Role.AUDITOR, MANUAL, MANUAL_2).andExpect(status().isOk()).andExpect(jsonPath("$.appid").value(MANUAL_2)); + merge(Role.AUDITOR, MANUAL_2, STEAM).andExpect(status().isOk()).andExpect(jsonPath("$.appid").value(STEAM)); + + assertThat(aliasOf(MANUAL)).isEqualTo(STEAM); + assertThat(aliasOf(MANUAL_2)).isEqualTo(STEAM); + assertThat(hasRow(AUDITOR_ID, STEAM)).isTrue(); + // The audit keeps the first step, which the flat alias no longer shows. + assertThat(audit()).hasSize(2).first().asString().contains(MANUAL + " " + MANUAL_2); + } + + @Test + void badInput_IsRefused() throws Exception { + merge(Role.AUDITOR, STEAM, STEAM_2).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("STEAM_GAMES")); + merge(Role.AUDITOR, MANUAL, MANUAL).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("SAME_GAME")); + merge(Role.AUDITOR, MANUAL, 999_999_999L).andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("GAME_NOT_FOUND")); + mockMvc.perform(post("/api/library/games/" + MANUAL + "/merge") + .header("Authorization", "Bearer " + token(Role.AUDITOR)).header(ClientHeaderFilter.CLIENT_HEADER, "test") + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isBadRequest()); + assertThat(steamGameRepository.findById(MANUAL)).isPresent(); + assertThat(audit()).isEmpty(); + } + + @Test + void roles_OnlyAuditorsAndAdminsMerge() throws Exception { + merge(Role.USER, MANUAL, STEAM).andExpect(status().isForbidden()); + assertThat(steamGameRepository.findById(MANUAL)).isPresent(); + merge(Role.PLATFORM_ADMIN, MANUAL, STEAM).andExpect(status().isOk()); + } + + @Test + void userWithBothRows_FinishedByHandBefore_KeepsTheFinishInItsYear() throws Exception { + manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG); + jdbc.update("update user_game set status = 'FINISHED' where user_id = ? and appid = ?", AUDITOR_ID, MANUAL); + jdbc.update("update user_game_status_change set changed_at = timestamptz '2025-06-01 12:00:00+00' " + + "where user_id = ? and appid = ?", AUDITOR_ID, MANUAL); + // The sync's own row: BACKLOG, not set by the user. + userGameRepository.save(new UserGame(AUDITOR_ID, STEAM, 0, Instant.now(), Instant.now())); + ZoneId zone = ZoneId.of("Europe/Berlin"); + assertThat(userStatsRepository.finishedPerYear(AUDITOR_ID, zone)).containsExactly(new UserStats.YearCount(2025, 1)); + + merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk()); + + assertThat(row(AUDITOR_ID, STEAM).getStatus()).isEqualTo(GameStatus.FINISHED); + 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(); + } +} 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 63f47ae..4daf59a 100644 --- a/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java +++ b/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java @@ -17,6 +17,7 @@ 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.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -25,6 +26,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import static org.hamcrest.Matchers.contains; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -65,12 +67,17 @@ class ManualGameControllerTest extends AbstractIntegrationTest { @Autowired private GameTimeToBeatRepository gameTimeToBeatRepository; + @Autowired + private JdbcTemplate jdbc; + @MockitoBean private IgdbApiService igdbApiService; @BeforeEach void seed() { + jdbc.update("delete from game_alias"); userGameRepository.deleteAll(); + jdbc.update("delete from steam_game where appid in (?, ?, ?, ?)", MERGED_INTO, -1_900_400L, -555_001L, -555_002L); gameTimeToBeatRepository.deleteAll(); when(igdbApiService.isEnabled()).thenReturn(true); when(igdbApiService.getGame(119388)).thenReturn(Optional.of(ZELDA)); @@ -231,8 +238,7 @@ class ManualGameControllerTest extends AbstractIntegrationTest { ZELDA.name(), 2023, null, List.of("Switch"), 2_000_000L)))); mockMvc.perform(get("/api/library/game-search").param("q", "zelda")) - .andExpect(jsonPath("$.results[0].appid").value(2_000_000)) - .andExpect(jsonPath("$.results[0].inLibrary").value(true)); + .andExpect(jsonPath("$.results[?(@.appid == 2000000)].inLibrary", contains(true))); } @Test @@ -359,4 +365,99 @@ class ManualGameControllerTest extends AbstractIntegrationTest { mockMvc.perform(get("/api/library/games/-119388").cookie(login)) .andExpect(status().isOk()); } + + private static final long MERGED_INTO = 1_900_300L; + + private void mergeZeldaIntoSteam() { + steamGameRepository.save(new SteamGame(MERGED_INTO, "Zelda on Steam", "icon", Instant.now())); + // The merge deletes the catalog row of the merged key. + jdbc.update("delete from steam_game where appid = ?", -119388L); + jdbc.update("insert into game_alias (from_appid, to_appid) values (?, ?)", -119388L, MERGED_INTO); + } + + @Test + void add_MergedGame_LandsOnTheGameItWasMergedInto() throws Exception { + mergeZeldaIntoSteam(); + + add(119388, "Switch").andExpect(status().isCreated()).andExpect(jsonPath("$.appid").value(MERGED_INTO)); + + assertEquals(GameSource.MANUAL, stored(MERGED_INTO).getSource()); + assertEquals("Zelda on Steam", steamGameRepository.findById(MERGED_INTO).orElseThrow().getName()); + assertTrue(userGameRepository.findById(new UserGameId(USER_ID, -119388L)).isEmpty()); + add(119388, "Switch").andExpect(status().isConflict()); + } + + @Test + void add_GameMergedIntoAnotherManualGame_KeepsTheTargetName() throws Exception { + steamGameRepository.save(new SteamGame(-1_900_400L, "Zelda TotK", null, Instant.now())); + jdbc.update("insert into game_alias (from_appid, to_appid) values (?, ?)", -119388L, -1_900_400L); + + add(119388, "Switch").andExpect(status().isCreated()).andExpect(jsonPath("$.appid").value(-1_900_400L)); + + assertEquals("Zelda TotK", steamGameRepository.findById(-1_900_400L).orElseThrow().getName()); + } + + @Test + void add_MergedGameIgdbLaterFindsOnSteam_IsStillInTheLibrary() throws Exception { + steamGameRepository.save(new SteamGame(-1_900_400L, "Zelda TotK", null, Instant.now())); + jdbc.update("insert into game_alias (from_appid, to_appid) values (?, ?)", -119388L, -1_900_400L); + userGameRepository.save(UserGame.manual(USER_ID, -1_900_400L, "Switch", GameStatus.BACKLOG, 0, Instant.now())); + when(igdbApiService.getGame(119388)).thenReturn(Optional.of(new IgdbApiService.Game(119388, ZELDA.name(), + 2023, null, List.of("Switch"), 2_000_000L))); + + add(119388, "Switch").andExpect(status().isConflict()); + + assertTrue(userGameRepository.findById(new UserGameId(USER_ID, 2_000_000L)).isEmpty()); + } + + @Test + void search_MergedGame_IsTheTargetAndInTheLibrary() throws Exception { + mergeZeldaIntoSteam(); + userGameRepository.save(new UserGame(USER_ID, MERGED_INTO, 60, Instant.now(), Instant.now())); + when(igdbApiService.searchGames("zelda")).thenReturn(Optional.of(List.of(ZELDA))); + + mockMvc.perform(get("/api/library/game-search").param("q", "zelda")) + .andExpect(jsonPath("$.results.length()").value(1)) + .andExpect(jsonPath("$.results[0].appid").value(MERGED_INTO)) + .andExpect(jsonPath("$.results[0].name").value("Zelda on Steam")) + .andExpect(jsonPath("$.results[0].inLibrary").value(true)); + } + + @Test + void search_SteamGameWithADuplicateAddedByHand_OffersBothKeys() throws Exception { + steamGameRepository.save(new SteamGame(MERGED_INTO, "Zelda on Steam", "icon", Instant.now())); + steamGameRepository.save(new SteamGame(-1_900_400L, "Zelda TotK", null, Instant.now())); + when(igdbApiService.searchGames("zelda totk")).thenReturn(Optional.of(List.of( + new IgdbApiService.Game(1_900_400, "Zelda TotK", 2023, null, List.of("Switch"), MERGED_INTO)))); + + mockMvc.perform(get("/api/library/game-search").param("q", "zelda totk")) + .andExpect(jsonPath("$.results[*].appid", contains((int) MERGED_INTO, -1_900_400))) + .andExpect(jsonPath("$.results[*].inCatalog", contains(true, true))); + } + + @Test + void search_GamesInToBePlayedComeFirst() throws Exception { + steamGameRepository.save(new SteamGame(-555_001L, "Blue Prince (corrected)", null, Instant.now())); + steamGameRepository.save(new SteamGame(-555_002L, "Princess Farmer", null, Instant.now())); + IgdbApiService.Game persia = new IgdbApiService.Game(777, "Prince of Persia", 2008, null, List.of("PS3"), null); + IgdbApiService.Game bluePrince = new IgdbApiService.Game(555_001, "Blue Prince", 2025, + "https://images.igdb.com/igdb/image/upload/t_screenshot_med/bp.jpg", List.of("PS5"), null); + IgdbApiService.Game other = new IgdbApiService.Game(778, "Prince of Persia 2", 2009, null, List.of("PS3"), null); + when(igdbApiService.searchGames("prince")).thenReturn(Optional.of(List.of(persia, bluePrince, other))); + + mockMvc.perform(get("/api/library/game-search").param("q", "prince")) + .andExpect(jsonPath("$.results.length()").value(4)) + .andExpect(jsonPath("$.results[0].appid").value(-555_001)) + .andExpect(jsonPath("$.results[0].name").value("Blue Prince (corrected)")) + .andExpect(jsonPath("$.results[0].imageUrl").value(bluePrince.imageUrl())) + .andExpect(jsonPath("$.results[0].platforms[0]").value("PS5")) + .andExpect(jsonPath("$.results[0].inCatalog").value(true)) + .andExpect(jsonPath("$.results[1].appid").value(-555_002)) + .andExpect(jsonPath("$.results[1].inCatalog").value(true)) + .andExpect(jsonPath("$.results[1].igdbId").value(555_002)) + .andExpect(jsonPath("$.results[1].platforms").isEmpty()) + .andExpect(jsonPath("$.results[2].appid").value(-777)) + .andExpect(jsonPath("$.results[2].inCatalog").value(false)) + .andExpect(jsonPath("$.results[3].appid").value(-778)); + } } 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 6cc0143..5c2584a 100644 --- a/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java +++ b/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java @@ -4,6 +4,7 @@ import core_service.modules.igdb_adapter.service.IgdbApiService; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGameId; +import core_service.modules.library.repo.GameMergeRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; import core_service.shared.test.AbstractUnitTest; @@ -40,6 +41,9 @@ class ManualGameServiceTest extends AbstractUnitTest { @Mock private SteamGameRepository steamGameRepository; + @Mock + private GameMergeRepository mergeRepository; + @Mock private TimeToBeatService timeToBeatService; @@ -53,7 +57,7 @@ class ManualGameServiceTest extends AbstractUnitTest { @BeforeEach void setUp() { - service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, timeToBeatService, + service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, mergeRepository, timeToBeatService, moodService, transactionManager); }