Public Access
feat(library): let auditors merge duplicate games
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:
1 parent
3c2f40036d
commit
5a089b2082
14 files changed
+804
-44
No files matched your search
@@ -96,6 +96,7 @@ method calling another service's method that needs more.
|
|||||||
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
|
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
|
||||||
| `PUT` | `/api/library/games/{appid}/backlog` | 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 |
|
| `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}/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}/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 |
|
| `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
|
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.
|
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
|
### Deleted accounts
|
||||||
|
|
||||||
Auth drives it (see Auth's readme). `POST /internal/accounts/deleted` adds a tombstone in `deleted_user`: the user
|
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`)
|
`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
|
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
|
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
|
## 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.GameDto;
|
||||||
import core_service.modules.library.api.dto.HiddenUpdateDto;
|
import core_service.modules.library.api.dto.HiddenUpdateDto;
|
||||||
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
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.PageDto;
|
||||||
import core_service.modules.library.api.dto.PickerDto;
|
import core_service.modules.library.api.dto.PickerDto;
|
||||||
import core_service.modules.library.api.dto.StatusUpdateDto;
|
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.BacklogService;
|
||||||
import core_service.modules.library.service.GameCorrectionService;
|
import core_service.modules.library.service.GameCorrectionService;
|
||||||
import core_service.modules.library.service.GameDetailService;
|
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.ImageRepairService;
|
||||||
import core_service.modules.library.service.InvalidCorrectionException;
|
import core_service.modules.library.service.InvalidCorrectionException;
|
||||||
import core_service.modules.library.service.LibraryQueryService;
|
import core_service.modules.library.service.LibraryQueryService;
|
||||||
@@ -75,6 +78,7 @@ public class LibraryController {
|
|||||||
private final ImageRepairService imageRepairService;
|
private final ImageRepairService imageRepairService;
|
||||||
private final UserStatsService userStatsService;
|
private final UserStatsService userStatsService;
|
||||||
private final GameCorrectionService gameCorrectionService;
|
private final GameCorrectionService gameCorrectionService;
|
||||||
|
private final GameMergeService gameMergeService;
|
||||||
|
|
||||||
@GetMapping("/games-count")
|
@GetMapping("/games-count")
|
||||||
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
|
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) {
|
private static ResponseEntity<?> correctionAnswer(GameCorrectionService.Result result) {
|
||||||
return result == GameCorrectionService.Result.DONE ? ResponseEntity.noContent().build()
|
return result == GameCorrectionService.Result.DONE ? ResponseEntity.noContent().build() : unknownGame();
|
||||||
: ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("code", "GAME_NOT_FOUND", "message", "Unknown game"));
|
}
|
||||||
|
|
||||||
|
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) {
|
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 appid the library key the game gets, see GameKeys
|
||||||
* @param inLibrary the user has this game already, from Steam or by hand
|
* @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,
|
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;
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
import core_service.modules.library.domain.SteamGame;
|
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.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Modifying;
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public interface SteamGameRepository extends JpaRepository<SteamGame, Long> {
|
public interface SteamGameRepository extends JpaRepository<SteamGame, Long> {
|
||||||
|
|
||||||
// Only for a name from Steam or IGDB: an auditor's name stays.
|
// 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 "
|
@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)")
|
+ "(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);
|
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.SteamGame;
|
||||||
import core_service.modules.library.domain.UserGame;
|
import core_service.modules.library.domain.UserGame;
|
||||||
import core_service.modules.library.domain.UserGameId;
|
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.SteamGameRepository;
|
||||||
import core_service.modules.library.repo.UserGameRepository;
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
import core_service.shared.security.RequiresRole;
|
import core_service.shared.security.RequiresRole;
|
||||||
import core_service.shared.security.Role;
|
import core_service.shared.security.Role;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.PlatformTransactionManager;
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
import org.springframework.transaction.support.TransactionTemplate;
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
@@ -24,9 +26,11 @@ import java.util.ArrayList;
|
|||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,20 +56,24 @@ public class ManualGameService {
|
|||||||
|
|
||||||
private static final int MIN_HITS = 3;
|
private static final int MIN_HITS = 3;
|
||||||
private static final int MAX_HITS = 20;
|
private static final int MAX_HITS = 20;
|
||||||
|
private static final int MAX_CATALOG_HITS = 10;
|
||||||
|
|
||||||
private final IgdbApiService igdbApiService;
|
private final IgdbApiService igdbApiService;
|
||||||
private final UserGameRepository userGameRepository;
|
private final UserGameRepository userGameRepository;
|
||||||
private final SteamGameRepository steamGameRepository;
|
private final SteamGameRepository steamGameRepository;
|
||||||
|
private final GameMergeRepository mergeRepository;
|
||||||
private final TimeToBeatService timeToBeatService;
|
private final TimeToBeatService timeToBeatService;
|
||||||
private final MoodService moodService;
|
private final MoodService moodService;
|
||||||
private final TransactionTemplate transaction;
|
private final TransactionTemplate transaction;
|
||||||
|
|
||||||
public ManualGameService(IgdbApiService igdbApiService, UserGameRepository userGameRepository,
|
public ManualGameService(IgdbApiService igdbApiService, UserGameRepository userGameRepository,
|
||||||
SteamGameRepository steamGameRepository, TimeToBeatService timeToBeatService,
|
SteamGameRepository steamGameRepository, GameMergeRepository mergeRepository,
|
||||||
MoodService moodService, PlatformTransactionManager transactionManager) {
|
TimeToBeatService timeToBeatService, MoodService moodService,
|
||||||
|
PlatformTransactionManager transactionManager) {
|
||||||
this.igdbApiService = igdbApiService;
|
this.igdbApiService = igdbApiService;
|
||||||
this.userGameRepository = userGameRepository;
|
this.userGameRepository = userGameRepository;
|
||||||
this.steamGameRepository = steamGameRepository;
|
this.steamGameRepository = steamGameRepository;
|
||||||
|
this.mergeRepository = mergeRepository;
|
||||||
this.timeToBeatService = timeToBeatService;
|
this.timeToBeatService = timeToBeatService;
|
||||||
this.moodService = moodService;
|
this.moodService = moodService;
|
||||||
this.transaction = new TransactionTemplate(transactionManager);
|
this.transaction = new TransactionTemplate(transactionManager);
|
||||||
@@ -78,14 +86,41 @@ public class ManualGameService {
|
|||||||
if (!igdbApiService.isEnabled()) {
|
if (!igdbApiService.isEnabled()) {
|
||||||
return Optional.of(new GameSearchDto(false, List.of()));
|
return Optional.of(new GameSearchDto(false, List.of()));
|
||||||
}
|
}
|
||||||
return igdbApiService.searchGames(query).map(found -> withTypoHelp(query, found)).map(games -> {
|
return igdbApiService.searchGames(query).map(found -> withTypoHelp(query, found))
|
||||||
Set<Long> owned = new HashSet<>(userGameRepository.findActiveAppids(userId,
|
.map(games -> new GameSearchDto(true, hits(userId, query, games)));
|
||||||
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(),
|
// Games already in ToBePlayed first, with their catalog name and image (an auditor's correction included); then
|
||||||
game.imageUrl(), fittingPlatforms(game), possibleKeys(game).stream().anyMatch(owned::contains)))
|
// games added by hand whose name matches (also a duplicate of a game IGDB now knows on Steam, so an auditor can
|
||||||
.toList());
|
// 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.
|
// 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
|
* 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) {
|
public AddResult add(Long userId, long igdbId, String platform, GameStatus status, int playtimeMinutes) {
|
||||||
if (!igdbApiService.isEnabled()) {
|
if (!igdbApiService.isEnabled()) {
|
||||||
@@ -131,39 +167,36 @@ public class ManualGameService {
|
|||||||
return new AddResult.UnknownGame();
|
return new AddResult.UnknownGame();
|
||||||
}
|
}
|
||||||
IgdbApiService.Game game = found.get();
|
IgdbApiService.Game game = found.get();
|
||||||
long appid = key(game);
|
|
||||||
AddResult result;
|
AddResult result;
|
||||||
try {
|
try {
|
||||||
result = addInTransaction(userId, appid, game, platform, status, playtimeMinutes);
|
result = addInTransaction(userId, game, platform, status, playtimeMinutes);
|
||||||
} catch (DataIntegrityViolationException e) {
|
} catch (DataIntegrityViolationException e) {
|
||||||
// A parallel add or sync inserted the row after the check. The second try sees it.
|
// A parallel add, sync or merge changed the rows after the check. The second try sees it.
|
||||||
log.info("Add of appid={} for user={} raced another insert, trying again", appid, userId);
|
log.info("Add of igdbId={} for user={} raced another write, trying again", igdbId, userId);
|
||||||
try {
|
try {
|
||||||
result = addInTransaction(userId, appid, game, platform, status, playtimeMinutes);
|
result = addInTransaction(userId, game, platform, status, playtimeMinutes);
|
||||||
} catch (DataIntegrityViolationException again) {
|
} catch (DataIntegrityViolationException again) {
|
||||||
return new AddResult.AlreadyInLibrary();
|
return new AddResult.AlreadyInLibrary();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (result instanceof AddResult.Added) {
|
if (result instanceof AddResult.Added added) {
|
||||||
timeToBeatService.refreshInBackground(List.of(appid));
|
timeToBeatService.refreshInBackground(List.of(added.appid()));
|
||||||
moodService.refreshInBackground(List.of(appid));
|
moodService.refreshInBackground(List.of(added.appid()));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private AddResult addInTransaction(Long userId, long appid, IgdbApiService.Game game, String platform,
|
private AddResult addInTransaction(Long userId, IgdbApiService.Game game, String platform, GameStatus status,
|
||||||
GameStatus status, int playtimeMinutes) {
|
int playtimeMinutes) {
|
||||||
return transaction.execute(tx -> {
|
return transaction.execute(tx -> {
|
||||||
Instant now = Instant.now();
|
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));
|
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);
|
saveCatalogEntry(appid, game, now);
|
||||||
if (existing.isPresent()) {
|
if (existing.isPresent()) {
|
||||||
UserGame revived = existing.get();
|
UserGame revived = existing.get();
|
||||||
@@ -212,7 +245,8 @@ public class ManualGameService {
|
|||||||
return ChangeResult.DONE;
|
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) {
|
private void saveCatalogEntry(long appid, IgdbApiService.Game game, Instant now) {
|
||||||
Optional<SteamGame> existing = steamGameRepository.findById(appid);
|
Optional<SteamGame> existing = steamGameRepository.findById(appid);
|
||||||
if (existing.isEmpty()) {
|
if (existing.isEmpty()) {
|
||||||
@@ -221,7 +255,7 @@ public class ManualGameService {
|
|||||||
created.setImageUrl(game.imageUrl());
|
created.setImageUrl(game.imageUrl());
|
||||||
}
|
}
|
||||||
steamGameRepository.save(created);
|
steamGameRepository.save(created);
|
||||||
} else if (!GameKeys.isSteam(appid)) {
|
} else if (appid == GameKeys.ofIgdb(game.igdbId())) {
|
||||||
steamGameRepository.setSourceName(appid, game.name());
|
steamGameRepository.setSourceName(appid, game.name());
|
||||||
steamGameRepository.setImageUrl(appid, game.imageUrl());
|
steamGameRepository.setImageUrl(appid, game.imageUrl());
|
||||||
existing.get().setLastSeenAt(now);
|
existing.get().setLastSeenAt(now);
|
||||||
@@ -233,13 +267,22 @@ public class ManualGameService {
|
|||||||
return game.platforms().stream().filter(platform -> platform.length() <= ManualGameUpdate.MAX_PLATFORM_LENGTH).toList();
|
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.
|
// IGDB can learn a game's Steam appid after the user added it under minus its IGDB id. Each key goes through its
|
||||||
private static List<Long> possibleKeys(IgdbApiService.Game game) {
|
// merge, if an auditor merged it.
|
||||||
long igdbKey = GameKeys.ofIgdb(game.igdbId());
|
private static List<Long> possibleKeys(IgdbApiService.Game game, Map<Long, Long> targets) {
|
||||||
return key(game) == igdbKey ? List.of(igdbKey) : List.of(key(game), igdbKey);
|
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.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
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.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
@@ -65,12 +67,17 @@ class ManualGameControllerTest extends AbstractIntegrationTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private GameTimeToBeatRepository gameTimeToBeatRepository;
|
private GameTimeToBeatRepository gameTimeToBeatRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
@MockitoBean
|
@MockitoBean
|
||||||
private IgdbApiService igdbApiService;
|
private IgdbApiService igdbApiService;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void seed() {
|
void seed() {
|
||||||
|
jdbc.update("delete from game_alias");
|
||||||
userGameRepository.deleteAll();
|
userGameRepository.deleteAll();
|
||||||
|
jdbc.update("delete from steam_game where appid in (?, ?, ?, ?)", MERGED_INTO, -1_900_400L, -555_001L, -555_002L);
|
||||||
gameTimeToBeatRepository.deleteAll();
|
gameTimeToBeatRepository.deleteAll();
|
||||||
when(igdbApiService.isEnabled()).thenReturn(true);
|
when(igdbApiService.isEnabled()).thenReturn(true);
|
||||||
when(igdbApiService.getGame(119388)).thenReturn(Optional.of(ZELDA));
|
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))));
|
ZELDA.name(), 2023, null, List.of("Switch"), 2_000_000L))));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/library/game-search").param("q", "zelda"))
|
mockMvc.perform(get("/api/library/game-search").param("q", "zelda"))
|
||||||
.andExpect(jsonPath("$.results[0].appid").value(2_000_000))
|
.andExpect(jsonPath("$.results[?(@.appid == 2000000)].inLibrary", contains(true)));
|
||||||
.andExpect(jsonPath("$.results[0].inLibrary").value(true));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -359,4 +365,99 @@ class ManualGameControllerTest extends AbstractIntegrationTest {
|
|||||||
mockMvc.perform(get("/api/library/games/-119388").cookie(login))
|
mockMvc.perform(get("/api/library/games/-119388").cookie(login))
|
||||||
.andExpect(status().isOk());
|
.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.GameStatus;
|
||||||
import core_service.modules.library.domain.SteamGame;
|
import core_service.modules.library.domain.SteamGame;
|
||||||
import core_service.modules.library.domain.UserGameId;
|
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.SteamGameRepository;
|
||||||
import core_service.modules.library.repo.UserGameRepository;
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
import core_service.shared.test.AbstractUnitTest;
|
import core_service.shared.test.AbstractUnitTest;
|
||||||
@@ -40,6 +41,9 @@ class ManualGameServiceTest extends AbstractUnitTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private SteamGameRepository steamGameRepository;
|
private SteamGameRepository steamGameRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private GameMergeRepository mergeRepository;
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private TimeToBeatService timeToBeatService;
|
private TimeToBeatService timeToBeatService;
|
||||||
|
|
||||||
@@ -53,7 +57,7 @@ class ManualGameServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, timeToBeatService,
|
service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, mergeRepository, timeToBeatService,
|
||||||
moodService, transactionManager);
|
moodService, transactionManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user