feat(library): let auditors merge duplicate games
Build, Test & Push Core Service / quality-check (push) Successful in 1m3s
Build, Test & Push Core Service / docker (push) Successful in 1m26s

An auditor merges a game added by hand into its Steam game or another
game added by hand (V21). Every user's row moves; a user with both rows
keeps one, with the status, rating and notes set by hand. game_alias
sends later adds to the kept key, and admin_audit logs who merged what.
The game search shows games already in ToBePlayed first.
This commit is contained in:
lobo committed 2026-09-28 11:25:45 +02:00
1 parent 3c2f40036d
commit 5a089b2082
14 files changed
+804 -44

No files matched your search

@@ -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) {
@@ -10,7 +10,8 @@ public record GameSearchDto(boolean available, List<Hit> 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<String> platforms,
boolean inLibrary) {}
boolean inLibrary, boolean inCatalog) {}
}
@@ -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) {}
@@ -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) {}
@@ -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) {}
@@ -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<String> 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<Long, String> lockGames(long appid, long otherAppid) {
Map<Long, String> 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<Long, Long> findTargets(Collection<Long> appids) {
Map<Long, Long> 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;
}
}
@@ -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<SteamGame, Long> {
// Only for a name from Steam or IGDB: an auditor's name stays.
@@ -22,4 +25,12 @@ public interface SteamGameRepository extends JpaRepository<SteamGame, Long> {
@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<SteamGame> findManualByName(@Param("namePattern") String namePattern, Pageable pageable);
}
@@ -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<Long, String> 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;
}
}
@@ -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<Long> 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<GameSearchDto.Hit> hits(Long userId, String query, List<IgdbApiService.Game> games) {
Map<Long, Long> targets = mergeRepository.findTargets(games.stream().flatMap(game -> ownKeys(game).stream()).toList());
Map<Long, SteamGame> catalog = steamGameRepository.findAllById(games.stream().map(game -> key(game, targets)).toList())
.stream().collect(Collectors.toMap(SteamGame::getAppid, game -> game));
List<SteamGame> addedByOthers = steamGameRepository.findManualByName(UserGameRepository.containsPattern(query),
PageRequest.of(0, MAX_CATALOG_HITS));
Set<Long> owned = new HashSet<>(userGameRepository.findActiveAppids(userId, Stream.concat(
games.stream().flatMap(game -> possibleKeys(game, targets).stream()),
addedByOthers.stream().map(SteamGame::getAppid)).toList()));
List<GameSearchDto.Hit> inCatalog = new ArrayList<>();
List<GameSearchDto.Hit> 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<GameSearchDto.Hit> 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<Long> 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<Long, Long> targets = mergeRepository.findTargets(ownKeys(game));
long appid = key(game, targets);
List<Long> possibleKeys = possibleKeys(game, targets);
if (!userGameRepository.findActiveAppids(userId, possibleKeys).isEmpty()) {
return new AddResult.AlreadyInLibrary();
}
Optional<UserGame> 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<SteamGame> 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<Long> 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<Long> possibleKeys(IgdbApiService.Game game, Map<Long, Long> 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<Long, Long> 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<Long> ownKeys(IgdbApiService.Game game) {
long igdbKey = GameKeys.ofIgdb(game.igdbId());
return game.steamAppid() != null ? List.of(game.steamAppid(), igdbKey) : List.of(igdbKey);
}
}
@@ -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;
@@ -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<Long> 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<String> 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();
}
}
@@ -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));
}
}
@@ -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);
}