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

+21 -1
View File
@@ -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
@@ -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);
}