From e806670d266ac75b9a913d8627bca3921e28f679 Mon Sep 17 00:00:00 2001 From: lobo Date: Fri, 2 Oct 2026 09:10:56 +0200 Subject: [PATCH] feat(library): add IGDB editions, game links and auditor queue Tables game_edition (IGDB "same game" id per library key), game_link (global sets set by auditors), game_link_pair (queue of IGDB and requested pairs), game_link_request and account_name. Fetch IGDB editions, link and unlink with audit rows, serve the paged auditor queue with request counts. WireMock recordings for the editions query. --- local/wiremock/__files/igdb-editions.json | 29 ++ local/wiremock/__files/igdb-game-14593.json | 17 ++ local/wiremock/__files/igdb-game-search.json | 15 + local/wiremock/mappings/igdb-editions.json | 18 ++ local/wiremock/mappings/igdb-game-14593.json | 18 ++ readme.md | 29 +- .../repo/AccountCleanupRepository.java | 5 +- .../api/dto/response/IgdbResponses.java | 8 + .../igdb_adapter/service/IgdbApiService.java | 78 +++++ .../library/api/LibraryController.java | 77 +++++ .../library/api/dto/CatalogGameDto.java | 3 + .../modules/library/api/dto/EditionDto.java | 10 + .../library/api/dto/GameDetailDto.java | 6 +- .../modules/library/api/dto/GameDto.java | 4 +- .../modules/library/api/dto/LinkPairDto.java | 9 + .../modules/library/api/dto/LinkRequest.java | 8 + .../modules/library/domain/CatalogGame.java | 6 + .../modules/library/domain/Edition.java | 7 + .../modules/library/domain/GameEdition.java | 35 +++ .../modules/library/domain/LinkPair.java | 11 + .../library/repo/AdminAuditRepository.java | 23 ++ .../library/repo/GameEditionRepository.java | 29 ++ .../library/repo/GameLinkRepository.java | 243 ++++++++++++++++ .../library/repo/GameMergeRepository.java | 32 ++- .../library/service/GameDetailService.java | 11 +- .../library/service/GameEditionService.java | 63 ++++ .../library/service/GameLinkService.java | 144 ++++++++++ .../library/service/GameMergeService.java | 8 +- .../library/service/LibraryQueryService.java | 11 +- .../library/service/LibrarySyncWorker.java | 2 + .../library/service/ManualGameService.java | 4 + src/main/resources/application.yml | 1 + .../db/migration/V28__Game_Links.sql | 57 ++++ .../configuration/LocalMocksParseTest.java | 1 + .../repo/AccountCleanupRepositoryTest.java | 5 +- .../service/IgdbApiServiceTest.java | 53 +++- .../modules/library/api/GameLinkFlowTest.java | 270 ++++++++++++++++++ .../library/api/GameMergeFlowTest.java | 49 ++++ .../library/repo/CountedUserGameViewTest.java | 127 ++++++++ .../library/repo/GameLinkRepositoryTest.java | 270 ++++++++++++++++++ .../service/GameEditionServiceTest.java | 94 ++++++ .../service/LibrarySyncWorkerTest.java | 4 + .../service/ManualGameServiceTest.java | 5 +- 43 files changed, 1877 insertions(+), 22 deletions(-) create mode 100644 local/wiremock/__files/igdb-editions.json create mode 100644 local/wiremock/__files/igdb-game-14593.json create mode 100644 local/wiremock/mappings/igdb-editions.json create mode 100644 local/wiremock/mappings/igdb-game-14593.json create mode 100644 src/main/java/core_service/modules/library/api/dto/CatalogGameDto.java create mode 100644 src/main/java/core_service/modules/library/api/dto/EditionDto.java create mode 100644 src/main/java/core_service/modules/library/api/dto/LinkPairDto.java create mode 100644 src/main/java/core_service/modules/library/api/dto/LinkRequest.java create mode 100644 src/main/java/core_service/modules/library/domain/CatalogGame.java create mode 100644 src/main/java/core_service/modules/library/domain/Edition.java create mode 100644 src/main/java/core_service/modules/library/domain/GameEdition.java create mode 100644 src/main/java/core_service/modules/library/domain/LinkPair.java create mode 100644 src/main/java/core_service/modules/library/repo/AdminAuditRepository.java create mode 100644 src/main/java/core_service/modules/library/repo/GameEditionRepository.java create mode 100644 src/main/java/core_service/modules/library/repo/GameLinkRepository.java create mode 100644 src/main/java/core_service/modules/library/service/GameEditionService.java create mode 100644 src/main/java/core_service/modules/library/service/GameLinkService.java create mode 100644 src/main/resources/db/migration/V28__Game_Links.sql create mode 100644 src/test/java/core_service/modules/library/api/GameLinkFlowTest.java create mode 100644 src/test/java/core_service/modules/library/repo/CountedUserGameViewTest.java create mode 100644 src/test/java/core_service/modules/library/repo/GameLinkRepositoryTest.java create mode 100644 src/test/java/core_service/modules/library/service/GameEditionServiceTest.java diff --git a/local/wiremock/__files/igdb-editions.json b/local/wiremock/__files/igdb-editions.json new file mode 100644 index 0000000..ebb9b4c --- /dev/null +++ b/local/wiremock/__files/igdb-editions.json @@ -0,0 +1,29 @@ +[ + { + "id": 72 + }, + { + "id": 113112 + }, + { + "id": 17000 + }, + { + "id": 7046 + }, + { + "id": 26226 + }, + { + "id": 14593 + }, + { + "id": 233 + }, + { + "id": 133236 + }, + { + "id": 119388 + } +] diff --git a/local/wiremock/__files/igdb-game-14593.json b/local/wiremock/__files/igdb-game-14593.json new file mode 100644 index 0000000..9efe41e --- /dev/null +++ b/local/wiremock/__files/igdb-game-14593.json @@ -0,0 +1,17 @@ +[ + { + "id": 14593, + "name": "Hollow Knight", + "first_release_date": 1487980800, + "cover": { + "image_id": "co1r7f" + }, + "platforms": [ + { + "name": "PlayStation 5", + "abbreviation": "PS5" + } + ], + "total_rating_count": 1100 + } +] diff --git a/local/wiremock/__files/igdb-game-search.json b/local/wiremock/__files/igdb-game-search.json index 94c9823..840a4d0 100644 --- a/local/wiremock/__files/igdb-game-search.json +++ b/local/wiremock/__files/igdb-game-search.json @@ -63,5 +63,20 @@ "uid": "504230" } ] + }, + { + "id": 14593, + "name": "Hollow Knight", + "first_release_date": 1487980800, + "cover": { + "image_id": "co1r7f" + }, + "platforms": [ + { + "name": "PlayStation 5", + "abbreviation": "PS5" + } + ], + "total_rating_count": 1100 } ] diff --git a/local/wiremock/mappings/igdb-editions.json b/local/wiremock/mappings/igdb-editions.json new file mode 100644 index 0000000..c273463 --- /dev/null +++ b/local/wiremock/mappings/igdb-editions.json @@ -0,0 +1,18 @@ +{ + "request": { + "method": "POST", + "urlPath": "/igdb/games", + "bodyPatterns": [ + { + "contains": "fields version_parent" + } + ] + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "igdb-editions.json" + } +} diff --git a/local/wiremock/mappings/igdb-game-14593.json b/local/wiremock/mappings/igdb-game-14593.json new file mode 100644 index 0000000..2a7afd5 --- /dev/null +++ b/local/wiremock/mappings/igdb-game-14593.json @@ -0,0 +1,18 @@ +{ + "request": { + "method": "POST", + "urlPath": "/igdb/games", + "bodyPatterns": [ + { + "contains": "where id = 14593;" + } + ] + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "igdb-game-14593.json" + } +} diff --git a/readme.md b/readme.md index 7b414b9..d58a706 100644 --- a/readme.md +++ b/readme.md @@ -67,6 +67,10 @@ To Be Played is split into three repositories: backgrounds (V27 widens `reward_equipped.slot`); a `BUY` row proves ownership, `reward_equipped` holds what is worn. A platform admin owns every item (the role of the login token, or the `role` claim of the profile ticket for visitors). +- **Editions and links**: editions, remasters and ports of one game stay separate library rows, but an AUDITOR can + link their keys as one game for every user (see "Editions of one game"). IGDB suggests pairs, users can ask for + one, and one queue holds both. Every link, unlink and dismissal goes to `admin_audit` + (`GAME_LINK`, `GAME_UNLINK`, `GAME_LINK_DISMISS`). - **Wishlist**: synced from Steam with store price and discount. - **Broken image repair**: the frontend reports a Steam cover or achievement icon that does not load. At 01:30 (Europe/Berlin) a job checks up to 200 reports. A cover that loads again is left alone; else the store's hashed @@ -131,6 +135,10 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | 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 | +| `POST`, `DELETE` | `/api/library/games/{appid}/link` (POST `{otherAppid}`, `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED`; DELETE takes the game out of its set, `204`, `404 NOT_LINKED`) | JWT, AUDITOR | +| `GET` | `/api/library/link-queue?page=&size=` (`PageDto` of `{a, b, igdb, requests, createdAt}`: both games, most requested first; size 20, 40 or 80) | JWT, AUDITOR | +| `DELETE` | `/api/library/link-queue/{appid}/{otherAppid}` (dismiss; `204`, `404 PAIR_NOT_OPEN`) | JWT, AUDITOR | +| `POST` | `/api/library/games/{appid}/link-requests` (`{otherAppid}`, both in the caller's library; `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED` / `PAIR_DISMISSED`, `429 TOO_MANY_REQUESTS`) | 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}/hidden` (`{hidden}`; `409 MANUAL_GAME` for a game added by hand) | JWT | @@ -319,13 +327,30 @@ since the sync would bring both back. In one transaction: 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. + deleted. So are its queue pairs, its requests and its `game_edition` row; its set row goes to the kept key (see + "Editions of one game"). - `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). +### Editions of one game + +IGDB tells which library keys are one game: `game_edition` (V28) holds a "same game" key per library key, refreshed +like `game_mood` (stale check, nightly sync, add, detail view; `app.library.edition-ttl-days`). The key is +`version_parent` for an edition, else `parent_game` when `game_type` is 9 (remaster) or 11 (port), else the game's own +IGDB id; remakes (8) keep their own id. `null` when IGDB does not know the game. + +- `game_link(appid, set_id)`: the keys an auditor linked, for every user. A set is every row with one `set_id`. + An unlink takes one key out; a set of one is dropped. A merge puts the kept key in the merged key's set; when both have a set, the sets join. +- `game_link_pair` is the auditor queue (`OPEN`, `DISMISSED`, `LINKED`). IGDB pairs are inserted when the queue is + read; a row in any state keeps the pair out for good. An unlink dismisses the pairs of that key with the former + members. `game_link_request` holds who asked for a pair (the queue shows only the count). A user asks for a pair of + two games of their own library; at most 20 open requests per user (a per-user advisory lock makes the cap hold for parallel calls), a dismissed pair answers `409 PAIR_DISMISSED`. +- The game detail has `linked` and `editions` (the user's other listed games of the set); a game row has `editions` + (size of the set in the user's library, `null` below 2). + ### Deleted accounts Auth drives it (see Auth's readme). `POST /internal/accounts/deleted` adds a tombstone in `deleted_user`: the user @@ -434,6 +459,8 @@ What the `local` profile does: - The browser still loads game pictures from the Steam and IGDB image servers; offline they stay gray. - "Sign in through Steam" still opens the real Steam page (no key needed). The dev user is already linked. - A request to WireMock that has no recording answers 404 and shows up in Core's log. +- To see an IGDB link suggestion: add Hollow Knight by hand (game search, PS5). It shares IGDB's "same game" key with the + Steam copy, so the link queue (auditor) lists the pair with the IGDB badge. To change the recorded answers, edit the JSON in `local/wiremock/mappings` (which request) and `local/wiremock/__files` (the answer), then restart Core (it restarts the container). `LocalMocksParseTest` checks diff --git a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java index 4aaa513..e779518 100644 --- a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java +++ b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java @@ -22,11 +22,12 @@ public class AccountCleanupRepository { // Children before user_game (user_game_status_change would also go by its cascade), the tombstones last. static final List TABLES = List.of("user_game_status_change", "user_game_achievement", "user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", - "user_steam_sync", "user_game", "game_import", "reward_ledger", "reward_equipped", "blocked_user", "deleted_user"); + "user_steam_sync", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "reward_equipped", + "blocked_user", "deleted_user"); // Every user with data has a row in one of these; deleteRows then removes the child rows too. private static final List ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game", - "game_import", "blocked_user", "deleted_user"); + "game_import", "blocked_user", "deleted_user", "account_name"); private final NamedParameterJdbcTemplate jdbc; diff --git a/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java b/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java index e06ef6e..d3c3fed 100644 --- a/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java +++ b/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java @@ -52,6 +52,14 @@ public final class IgdbResponses { @JsonProperty("game_modes") java.util.List gameModes ) {} + @JsonIgnoreProperties(ignoreUnknown = true) + public record Edition( + @JsonProperty("id") Long id, + @JsonProperty("version_parent") Long versionParent, + @JsonProperty("parent_game") Long parentGame, + @JsonProperty("game_type") Integer gameType + ) {} + @JsonIgnoreProperties(ignoreUnknown = true) public record Named(@JsonProperty("name") String name) {} diff --git a/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java b/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java index 5664d20..144769c 100644 --- a/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java +++ b/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java @@ -48,6 +48,11 @@ public class IgdbApiService { */ public record GameTags(List genres, List themes, List modes) {} + /** + * @param sameGameId the IGDB game all editions, remasters and ports of one game share, see {@link #sameGameId} + */ + public record Edition(long igdbId, long sameGameId) {} + /** * One IGDB game as the library needs it. * @@ -65,6 +70,8 @@ public class IgdbApiService { private static final String E_READER = "e-Reader / Card-e Reader"; // IGDB game types a user can own and play on their own: main game, standalone expansion, season, remake, // remaster, expanded game, port. Leaves out DLC, bundles, mods, episodes, packs and updates. + // Remaster and port belong to the game they come from. A remake (8) is another game and is not in this set. + private static final Set SAME_GAME_TYPES = Set.of(9, 11); private static final String PLAYABLE_GAME_TYPES = "(0,4,7,8,9,10,11)"; private static final String GAME_FIELDS = "fields name,first_release_date,cover.image_id,artworks.image_id," + "screenshots.image_id,platforms.name,platforms.abbreviation,external_games.external_game_source," @@ -176,6 +183,60 @@ public class IgdbApiService { } } + /** + * @return the IGDB game and its "same game" key per Steam app; an app linked to several games takes the lowest + * game id, and an app IGDB does not link is missing. Empty when the adapter is off or a request failed. + */ + public Optional> getEditions(Collection steamAppids) { + if (!isEnabled() || steamAppids.isEmpty()) { + return Optional.empty(); + } + try { + List appids = List.copyOf(steamAppids); + Map result = new HashMap<>(); + for (int from = 0; from < appids.size(); from += BATCH) { + Map> steamByIgdb = steamAppidsByIgdbGame(appids.subList(from, Math.min(from + BATCH, appids.size()))); + Map editions = editionsOf(steamByIgdb.keySet()); + steamByIgdb.keySet().stream().sorted().filter(editions::containsKey).forEach(igdbId -> + steamByIgdb.get(igdbId).forEach(appid -> result.putIfAbsent(appid, editions.get(igdbId)))); + } + return Optional.of(result); + } catch (Exception e) { + log.error("Error communicating with IGDB for editions", e); + return Optional.empty(); + } + } + + /** + * Like {@link #getEditions}, but for IGDB game ids. + */ + public Optional> getEditionsByGameIds(Collection igdbIds) { + if (!isEnabled() || igdbIds.isEmpty()) { + return Optional.empty(); + } + try { + List ids = List.copyOf(igdbIds); + Map result = new HashMap<>(); + for (int from = 0; from < ids.size(); from += BATCH) { + result.putAll(editionsOf(ids.subList(from, Math.min(from + BATCH, ids.size())))); + } + return Optional.of(result); + } catch (Exception e) { + log.error("Error communicating with IGDB for editions", e); + return Optional.empty(); + } + } + + static long sameGameId(IgdbResponses.Edition game) { + if (game.versionParent() != null) { + return game.versionParent(); + } + if (game.parentGame() != null && game.gameType() != null && SAME_GAME_TYPES.contains(game.gameType())) { + return game.parentGame(); + } + return game.id(); + } + /** * @return the first release years of the Steam apps IGDB links to a game; an app linked to several games (say a * remaster) gets all their years, an app without a known year is missing. Empty when the adapter is off or a @@ -365,6 +426,23 @@ public class IgdbApiService { return result; } + private Map editionsOf(Collection igdbIds) { + if (igdbIds.isEmpty()) { + return Map.of(); + } + String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(",")); + IgdbResponses.Edition[] games = query("/games", + "fields version_parent,parent_game,game_type; where id = (" + gameIds + "); limit " + LIMIT + ";", + IgdbResponses.Edition[].class); + Map result = new HashMap<>(); + for (IgdbResponses.Edition game : games) { + if (game.id() != null && igdbIds.contains(game.id())) { + result.put(game.id(), new Edition(game.id(), sameGameId(game))); + } + } + return result; + } + private Map yearsOf(Collection igdbIds) { if (igdbIds.isEmpty()) { return Map.of(); diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index dd6c9f2..4d42e90 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -5,11 +5,14 @@ import core_service.modules.library.api.dto.AchievementSummaryDto; import core_service.modules.library.api.dto.ActivityDto; import core_service.modules.library.api.dto.BacklogPatch; import core_service.modules.library.api.dto.BrokenImageReport; +import core_service.modules.library.api.dto.CatalogGameDto; import core_service.modules.library.api.dto.CorrectionDto; 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.LinkPairDto; +import core_service.modules.library.api.dto.LinkRequest; import core_service.modules.library.api.dto.MergeRequest; import core_service.modules.library.api.dto.MergedGameDto; import core_service.modules.library.api.dto.PageDto; @@ -19,12 +22,15 @@ import core_service.modules.library.api.dto.SteamProfileDto; import core_service.modules.library.api.dto.SyncStatusDto; import core_service.modules.library.api.dto.UserStatsDto; import core_service.modules.library.api.dto.WishlistDto; +import core_service.modules.library.domain.CatalogGame; import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.LinkPair; import core_service.modules.library.domain.Mood; 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.GameLinkService; import core_service.modules.library.service.GameMergeService; import core_service.modules.library.service.ImageRepairService; import core_service.modules.library.service.InvalidCorrectionException; @@ -83,6 +89,7 @@ public class LibraryController { private final UserStatsService userStatsService; private final GameCorrectionService gameCorrectionService; private final GameMergeService gameMergeService; + private final GameLinkService gameLinkService; @GetMapping("/games-count") public ResponseEntity getGamesCount(@AuthenticationPrincipal UserPrincipal principal) { @@ -206,6 +213,76 @@ public class LibraryController { }; } + /** + * An auditor links two keys as one game, for every user. + */ + @PostMapping("/games/{appid}/link") + public ResponseEntity linkGames(@AuthenticationPrincipal UserPrincipal principal, + @PathVariable("appid") Long appid, @Valid @RequestBody LinkRequest request) { + return switch (gameLinkService.link(appid, request.otherAppid(), principal.getIdAsLong())) { + case LINKED -> ResponseEntity.noContent().build(); + case NOT_FOUND -> unknownGame(); + case SAME_GAME -> sameGame(); + case ALREADY_LINKED -> alreadyLinked(); + }; + } + + @DeleteMapping("/games/{appid}/link") + public ResponseEntity unlinkGame(@AuthenticationPrincipal UserPrincipal principal, + @PathVariable("appid") Long appid) { + return gameLinkService.unlink(appid, principal.getIdAsLong()) ? ResponseEntity.noContent().build() + : ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("NOT_LINKED", "The game is not linked")); + } + + @GetMapping("/link-queue") + public ResponseEntity> getLinkQueue(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE; + return ResponseEntity.ok(PageDto.of(gameLinkService.queue(PageRequest.of(Math.max(0, page), safeSize)).map(LibraryController::toDto))); + } + + @DeleteMapping("/link-queue/{appid}/{otherAppid}") + public ResponseEntity dismissLink(@AuthenticationPrincipal UserPrincipal principal, + @PathVariable("appid") Long appid, @PathVariable("otherAppid") Long otherAppid) { + return gameLinkService.dismiss(appid, otherAppid, principal.getIdAsLong()) ? ResponseEntity.noContent().build() + : ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("PAIR_NOT_OPEN", "The pair is not in the queue")); + } + + /** + * A user asks an auditor to link two games of their own library. + */ + @PostMapping("/games/{appid}/link-requests") + public ResponseEntity requestLink(@AuthenticationPrincipal UserPrincipal principal, + @PathVariable("appid") Long appid, @Valid @RequestBody LinkRequest request) { + return switch (gameLinkService.request(principal.getIdAsLong(), appid, request.otherAppid())) { + case DONE -> ResponseEntity.noContent().build(); + case NOT_FOUND -> unknownGame(); + case SAME_GAME -> sameGame(); + case ALREADY_LINKED -> alreadyLinked(); + case DISMISSED -> ResponseEntity.status(HttpStatus.CONFLICT) + .body(new ErrorResponse("PAIR_DISMISSED", "An auditor decided these are different games")); + case TOO_MANY -> ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .body(new ErrorResponse("TOO_MANY_REQUESTS", "At most 20 open link requests")); + }; + } + + private static LinkPairDto toDto(LinkPair pair) { + return new LinkPairDto(toDto(pair.a()), toDto(pair.b()), pair.igdb(), pair.requests(), pair.createdAt()); + } + + private static CatalogGameDto toDto(CatalogGame game) { + return new CatalogGameDto(game.appid(), game.name(), game.imageUrl()); + } + + private static ResponseEntity sameGame() { + return ResponseEntity.badRequest().body(new ErrorResponse("SAME_GAME", "A game cannot be linked with itself")); + } + + private static ResponseEntity alreadyLinked() { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(new ErrorResponse("ALREADY_LINKED", "The games are already linked")); + } + private static ResponseEntity correctionAnswer(GameCorrectionService.Result result) { return result == GameCorrectionService.Result.DONE ? ResponseEntity.noContent().build() : unknownGame(); } diff --git a/src/main/java/core_service/modules/library/api/dto/CatalogGameDto.java b/src/main/java/core_service/modules/library/api/dto/CatalogGameDto.java new file mode 100644 index 0000000..668d161 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/CatalogGameDto.java @@ -0,0 +1,3 @@ +package core_service.modules.library.api.dto; + +public record CatalogGameDto(long appid, String name, String imageUrl) {} diff --git a/src/main/java/core_service/modules/library/api/dto/EditionDto.java b/src/main/java/core_service/modules/library/api/dto/EditionDto.java new file mode 100644 index 0000000..5d71379 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/EditionDto.java @@ -0,0 +1,10 @@ +package core_service.modules.library.api.dto; + +import core_service.modules.library.domain.GameSource; +import core_service.modules.library.domain.GameStatus; + +/** + * Another edition of a game that the user owns, from the game's linked set. + */ +public record EditionDto(long appid, String name, String platform, GameSource source, GameStatus status, + String imageUrl) {} diff --git a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java index 679b55c..b01fdac 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java @@ -43,7 +43,11 @@ public record GameDetailDto( // IGDB genre names List genres, // the fields an auditor corrected - List corrections + List corrections, + // an auditor linked the game with others; for every user + boolean linked, + // the user's other games of that set, empty when none + List editions ) { /** diff --git a/src/main/java/core_service/modules/library/api/dto/GameDto.java b/src/main/java/core_service/modules/library/api/dto/GameDto.java index 4d8d708..630cda9 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDto.java @@ -11,6 +11,7 @@ import core_service.modules.library.domain.GameStatus; * @param platform where the user owns a game added by hand, e.g. "PS5"; null for Steam games * @param imageUrl a game added by hand from IGDB, or a repaired Steam header; null means the fixed Steam CDN header * @param hidden the user hid the game; it shows only under the library's hidden filter + * @param editions how many games of the user are in this game's linked set, null below 2 */ public record GameDto( Long appid, @@ -26,5 +27,6 @@ public record GameDto( GameSource source, String platform, String imageUrl, - boolean hidden + boolean hidden, + Integer editions ) {} diff --git a/src/main/java/core_service/modules/library/api/dto/LinkPairDto.java b/src/main/java/core_service/modules/library/api/dto/LinkPairDto.java new file mode 100644 index 0000000..ec1cb09 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/LinkPairDto.java @@ -0,0 +1,9 @@ +package core_service.modules.library.api.dto; + +import java.time.Instant; + +/** + * @param igdb IGDB suggests the pair + * @param requests how many users asked for it + */ +public record LinkPairDto(CatalogGameDto a, CatalogGameDto b, boolean igdb, long requests, Instant createdAt) {} diff --git a/src/main/java/core_service/modules/library/api/dto/LinkRequest.java b/src/main/java/core_service/modules/library/api/dto/LinkRequest.java new file mode 100644 index 0000000..c6d3248 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/LinkRequest.java @@ -0,0 +1,8 @@ +package core_service.modules.library.api.dto; + +import jakarta.validation.constraints.NotNull; + +/** + * @param otherAppid the key of the other edition of the same game + */ +public record LinkRequest(@NotNull Long otherAppid) {} diff --git a/src/main/java/core_service/modules/library/domain/CatalogGame.java b/src/main/java/core_service/modules/library/domain/CatalogGame.java new file mode 100644 index 0000000..a48a7fc --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/CatalogGame.java @@ -0,0 +1,6 @@ +package core_service.modules.library.domain; + +/** + * A key of the shared catalog, as the link queue shows it. + */ +public record CatalogGame(long appid, String name, String imageUrl) {} diff --git a/src/main/java/core_service/modules/library/domain/Edition.java b/src/main/java/core_service/modules/library/domain/Edition.java new file mode 100644 index 0000000..0cdbf7f --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/Edition.java @@ -0,0 +1,7 @@ +package core_service.modules.library.domain; + +/** + * A listed row of the user's own library that is another edition of a game. + */ +public record Edition(long appid, String name, String platform, GameSource source, GameStatus status, + String imageUrl) {} diff --git a/src/main/java/core_service/modules/library/domain/GameEdition.java b/src/main/java/core_service/modules/library/domain/GameEdition.java new file mode 100644 index 0000000..f5a2099 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/GameEdition.java @@ -0,0 +1,35 @@ +package core_service.modules.library.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +/** + * IGDB's "same game" key of one library key (V28): editions, remasters and ports of a game share it. + */ +@Entity +@Table(name = "game_edition") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class GameEdition { + + @Id + @Column(name = "appid") + private Long appid; + + @Column(name = "igdb_id") + private Long igdbId; + + /** Null when IGDB does not know the game. */ + @Column(name = "same_game_id") + private Long sameGameId; + + @Column(name = "checked_at", nullable = false) + private Instant checkedAt; +} diff --git a/src/main/java/core_service/modules/library/domain/LinkPair.java b/src/main/java/core_service/modules/library/domain/LinkPair.java new file mode 100644 index 0000000..8ad29a2 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/LinkPair.java @@ -0,0 +1,11 @@ +package core_service.modules.library.domain; + +import java.time.Instant; + +/** + * Two keys waiting in the auditor queue. + * + * @param igdb IGDB suggests the pair + * @param requests how many users asked for it + */ +public record LinkPair(CatalogGame a, CatalogGame b, boolean igdb, long requests, Instant createdAt) {} diff --git a/src/main/java/core_service/modules/library/repo/AdminAuditRepository.java b/src/main/java/core_service/modules/library/repo/AdminAuditRepository.java new file mode 100644 index 0000000..c058c92 --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/AdminAuditRepository.java @@ -0,0 +1,23 @@ +package core_service.modules.library.repo; + +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +/** + * The audit log of what auditors changed for every user (V21). + */ +@Repository +@RequiredArgsConstructor +public class AdminAuditRepository { + + private final NamedParameterJdbcTemplate jdbc; + + public void write(long actorUserId, String action, Long appid, Long targetAppid, String detail) { + jdbc.update("insert into admin_audit (actor_user_id, action, appid, target_appid, detail) " + + "values (:actor, :action, :appid, :target, :detail)", + new MapSqlParameterSource("actor", actorUserId).addValue("action", action) + .addValue("appid", appid).addValue("target", targetAppid).addValue("detail", detail)); + } +} diff --git a/src/main/java/core_service/modules/library/repo/GameEditionRepository.java b/src/main/java/core_service/modules/library/repo/GameEditionRepository.java new file mode 100644 index 0000000..f801a86 --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/GameEditionRepository.java @@ -0,0 +1,29 @@ +package core_service.modules.library.repo; + +import core_service.modules.library.domain.GameEdition; +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.time.Instant; +import java.util.Collection; +import java.util.Set; + +public interface GameEditionRepository extends JpaRepository { + + @Query("select e.appid from GameEdition e where e.appid in :appids and e.checkedAt > :since") + Set findFreshAppids(@Param("appids") Collection appids, @Param("since") Instant since); + + // An upsert, not save(): two users' syncs can store the same new game at the same time. + @Modifying + @Transactional + @Query(value = "insert into game_edition (appid, igdb_id, same_game_id, checked_at) " + + "values (:appid, :igdbId, :sameGameId, :checkedAt) " + + "on conflict (appid) do update set igdb_id = excluded.igdb_id, " + + "same_game_id = excluded.same_game_id, checked_at = excluded.checked_at", + nativeQuery = true) + void upsert(@Param("appid") Long appid, @Param("igdbId") Long igdbId, @Param("sameGameId") Long sameGameId, + @Param("checkedAt") Instant checkedAt); +} diff --git a/src/main/java/core_service/modules/library/repo/GameLinkRepository.java b/src/main/java/core_service/modules/library/repo/GameLinkRepository.java new file mode 100644 index 0000000..c354e60 --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/GameLinkRepository.java @@ -0,0 +1,243 @@ +package core_service.modules.library.repo; + +import core_service.modules.library.domain.CatalogGame; +import core_service.modules.library.domain.Edition; +import core_service.modules.library.domain.GameSource; +import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.LinkPair; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.RowCallbackHandler; +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.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * The global game sets and the auditor queue (V28). Plain SQL: sets span keys and users. Every pair is stored as + * (least, greatest). + */ +@Repository +@RequiredArgsConstructor +public class GameLinkRepository { + + private static final String UPSERT_PAIR = "insert into game_link_pair (appid_a, appid_b, state) " + + "values (least(:a, :b), greatest(:a, :b), :state) on conflict (appid_a, appid_b) do update set state = :state"; + + private final NamedParameterJdbcTemplate jdbc; + + /** + * Locks the catalog rows of both keys until the transaction ends, so two link changes of one game do not cross. + * + * @return the name per key found + */ + public Map lockGames(long appid, long otherAppid) { + Map names = new HashMap<>(); + jdbc.query("select appid, name from steam_game where appid in (:a, :b) order by appid for update", + keys(appid, otherAppid), rs -> { names.put(rs.getLong("appid"), rs.getString("name")); }); + return names; + } + + public Map names(Collection appids) { + Map names = new LinkedHashMap<>(); + jdbc.query("select appid, name from steam_game where appid in (:appids) order by name", + new MapSqlParameterSource("appids", appids), rs -> { names.put(rs.getLong("appid"), rs.getString("name")); }); + return names; + } + + public Optional setOf(long appid) { + return jdbc.query("select set_id from game_link where appid = :a", new MapSqlParameterSource("a", appid), + (rs, row) -> rs.getLong("set_id")).stream().findFirst(); + } + + /** + * Puts both keys in one set, joining two sets when each key has one, and marks the pair LINKED. + * + * @return the set id + */ + public long link(long appid, long otherAppid) { + Optional first = setOf(appid); + Optional second = setOf(otherAppid); + long setId; + if (first.isPresent() && second.isPresent()) { + setId = first.get(); + jdbc.update("update game_link set set_id = :keep where set_id = :other", + new MapSqlParameterSource("keep", setId).addValue("other", second.get())); + } else if (first.isPresent() || second.isPresent()) { + setId = first.orElseGet(second::get); + jdbc.update("insert into game_link (appid, set_id) values (:a, :set)", + new MapSqlParameterSource("a", first.isPresent() ? otherAppid : appid).addValue("set", setId)); + } else { + Long next = jdbc.queryForObject("select nextval('game_link_set_seq')", Map.of(), Long.class); + setId = next == null ? 0 : next; + jdbc.update("insert into game_link (appid, set_id) values (:a, :set), (:b, :set)", + keys(appid, otherAppid).addValue("set", setId)); + } + jdbc.update(UPSERT_PAIR, keys(appid, otherAppid).addValue("state", "LINKED")); + // Other open pairs inside the set are decided too. + jdbc.update(""" + update game_link_pair set state = 'LINKED' where state = 'OPEN' + and appid_a in (select appid from game_link where set_id = :set) + and appid_b in (select appid from game_link where set_id = :set) + """, new MapSqlParameterSource("set", setId)); + return setId; + } + + /** + * Takes the key out of its set (a set of one is dropped) and dismisses its pairs with the former members, so a + * wrong IGDB suggestion does not come back. + * + * @return the former members without the key; empty when it was in no set + */ + public List unlink(long appid) { + Optional setId = setOf(appid); + if (setId.isEmpty()) { + return List.of(); + } + List members = jdbc.queryForList("select appid from game_link where set_id = :set and appid <> :a " + + "order by appid", new MapSqlParameterSource("set", setId.get()).addValue("a", appid), Long.class); + jdbc.update("delete from game_link where appid = :a", new MapSqlParameterSource("a", appid)); + if (members.size() == 1) { + jdbc.update("delete from game_link where appid = :a", new MapSqlParameterSource("a", members.get(0))); + } + for (Long member : members) { + jdbc.update(UPSERT_PAIR, keys(appid, member).addValue("state", "DISMISSED")); + } + return members; + } + + /** + * Queues every pair of keys that share an IGDB "same game" key and are not in one set. A row that exists, in + * any state, blocks the pair; an open pair a user asked for first is marked as IGDB's too. + */ + public int addIgdbPairs() { + return jdbc.update(""" + insert into game_link_pair (appid_a, appid_b, state, igdb) + select e1.appid, e2.appid, 'OPEN', true + from game_edition e1 + join game_edition e2 on e2.same_game_id = e1.same_game_id and e1.appid < e2.appid + where not exists (select 1 from game_link l1 join game_link l2 on l2.set_id = l1.set_id + where l1.appid = e1.appid and l2.appid = e2.appid) + on conflict (appid_a, appid_b) do update set igdb = true + where game_link_pair.state = 'OPEN' and not game_link_pair.igdb + """, Map.of()); + } + + /** + * Holds until the end of the transaction, so one user's link requests run one after the other. + */ + public void lockRequests(long userId) { + jdbc.query("select pg_advisory_xact_lock(hashtextextended(:key, 0))", + new MapSqlParameterSource("key", "game_link_request:" + userId), (RowCallbackHandler) rs -> { }); + } + + public long countOpenPairs() { + Long count = jdbc.queryForObject("select count(*) from game_link_pair where state = 'OPEN'", Map.of(), Long.class); + return count == null ? 0 : count; + } + + public List openPairs(int offset, int limit) { + return jdbc.query(""" + select p.appid_a, ga.name as name_a, ga.image_url as image_a, + p.appid_b, gb.name as name_b, gb.image_url as image_b, p.igdb, p.created_at, + (select count(*) from game_link_request r + where r.appid_a = p.appid_a and r.appid_b = p.appid_b) as requests + from game_link_pair p + join steam_game ga on ga.appid = p.appid_a + join steam_game gb on gb.appid = p.appid_b + where p.state = 'OPEN' + order by requests desc, p.created_at, p.appid_a, p.appid_b + limit :limit offset :offset + """, new MapSqlParameterSource("limit", limit).addValue("offset", offset), (rs, row) -> new LinkPair( + new CatalogGame(rs.getLong("appid_a"), rs.getString("name_a"), rs.getString("image_a")), + new CatalogGame(rs.getLong("appid_b"), rs.getString("name_b"), rs.getString("image_b")), + rs.getBoolean("igdb"), rs.getLong("requests"), rs.getTimestamp("created_at").toInstant())); + } + + /** + * @return OPEN, DISMISSED or LINKED; null when the pair has no row + */ + public String pairState(long appid, long otherAppid) { + return jdbc.query("select state from game_link_pair where appid_a = least(:a, :b) and appid_b = greatest(:a, :b)", + keys(appid, otherAppid), (rs, row) -> rs.getString("state")).stream().findFirst().orElse(null); + } + + /** + * Queues the pair when it has no row and records the request. + * + * @return false when the user asked for this pair before + */ + public boolean addRequest(long userId, long appid, long otherAppid) { + MapSqlParameterSource params = keys(appid, otherAppid).addValue("user", userId); + jdbc.update("insert into game_link_pair (appid_a, appid_b, state) values (least(:a, :b), greatest(:a, :b), 'OPEN') " + + "on conflict do nothing", params); + return jdbc.update("insert into game_link_request (user_id, appid_a, appid_b) " + + "values (:user, least(:a, :b), greatest(:a, :b)) on conflict do nothing", params) == 1; + } + + public int countOpenRequests(long userId) { + Integer count = jdbc.queryForObject(""" + select count(*) from game_link_request r + join game_link_pair p on p.appid_a = r.appid_a and p.appid_b = r.appid_b + where r.user_id = :user and p.state = 'OPEN' + """, new MapSqlParameterSource("user", userId), Integer.class); + return count == null ? 0 : count; + } + + /** + * @return false when the pair is not OPEN + */ + public boolean dismiss(long appid, long otherAppid) { + return jdbc.update("update game_link_pair set state = 'DISMISSED' where appid_a = least(:a, :b) " + + "and appid_b = greatest(:a, :b) and state = 'OPEN'", keys(appid, otherAppid)) == 1; + } + + /** + * The other listed games of the user in the set of {@code appid}. + */ + public List linkedEditions(long userId, long appid) { + return jdbc.query(""" + select ug.appid, g.name, ug.platform, ug.source, ug.status, g.image_url + from game_link me + join game_link o on o.set_id = me.set_id and o.appid <> me.appid + join listed_user_game ug on ug.user_id = :user and ug.appid = o.appid + join steam_game g on g.appid = ug.appid + where me.appid = :appid + order by g.name, ug.appid + """, new MapSqlParameterSource("user", userId).addValue("appid", appid), + (rs, row) -> new Edition(rs.getLong("appid"), rs.getString("name"), rs.getString("platform"), + GameSource.valueOf(rs.getString("source")), GameStatus.valueOf(rs.getString("status")), + rs.getString("image_url"))); + } + + /** + * @return per given key in a set, how many listed games of the user are in that set (the key itself included); + * only counts of 2 and more + */ + public Map editionCounts(long userId, Collection appids) { + Map counts = new HashMap<>(); + if (appids.isEmpty()) { + return counts; + } + jdbc.query(""" + select l.appid, count(*) as owned + from game_link l + join game_link o on o.set_id = l.set_id + join listed_user_game ug on ug.user_id = :user and ug.appid = o.appid + where l.appid in (:appids) + group by l.appid + having count(*) >= 2 + """, new MapSqlParameterSource("user", userId).addValue("appids", appids), + rs -> { counts.put(rs.getLong("appid"), rs.getInt("owned")); }); + return counts; + } + + private static MapSqlParameterSource keys(long appid, long otherAppid) { + return new MapSqlParameterSource("a", appid).addValue("b", otherAppid); + } +} diff --git a/src/main/java/core_service/modules/library/repo/GameMergeRepository.java b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java index f06786b..e6e80be 100644 --- a/src/main/java/core_service/modules/library/repo/GameMergeRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java @@ -10,6 +10,7 @@ import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; /** * Merges one library key into another (V21). Plain SQL: a merge rewrites the key of rows in many tables at once. @@ -20,7 +21,7 @@ public class GameMergeRepository { // The shared rows of a game; steam_game last, since user_game refers to it. private static final List CATALOG_TABLES = List.of("game_mood", "game_speedrun", "game_time_to_beat", - "game_correction", "broken_image", "steam_game"); + "game_correction", "broken_image", "game_edition", "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 @@ -94,6 +95,9 @@ public class GameMergeRepository { and not exists (select 1 from reward_ledger o where o.user_id = l.user_id and o.kind = l.kind and o.ref = cast(:to as text)) """, keys); + mergeSets(from, to, keys); + jdbc.update("delete from game_link_pair where :from in (appid_a, appid_b)", keys); + jdbc.update("delete from game_link_request where :from in (appid_a, appid_b)", keys); for (String table : CATALOG_TABLES) { jdbc.update("delete from " + table + " where appid = :from", keys); } @@ -103,11 +107,27 @@ public class GameMergeRepository { 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)); + // The kept key takes the set of the merged one; when both have a set, the sets join. A set left with one key goes. + private void mergeSets(long from, long to, MapSqlParameterSource keys) { + Optional fromSet = setOf(from); + if (fromSet.isEmpty()) { + return; + } + Optional toSet = setOf(to); + long keep = toSet.orElse(fromSet.get()); + keys.addValue("keep", keep).addValue("fromSet", fromSet.get()); + jdbc.update("update game_link set set_id = :keep where set_id = :fromSet", keys); + jdbc.update("delete from game_link where appid = :from", keys); + if (toSet.isEmpty()) { + jdbc.update("insert into game_link (appid, set_id) values (:to, :keep)", keys); + } + jdbc.update("delete from game_link where set_id = :keep and (select count(*) from game_link where set_id = :keep) = 1", + keys); + } + + private Optional setOf(long appid) { + return jdbc.query("select set_id from game_link where appid = :a", new MapSqlParameterSource("a", appid), + (rs, row) -> rs.getLong("set_id")).stream().findFirst(); } /** diff --git a/src/main/java/core_service/modules/library/service/GameDetailService.java b/src/main/java/core_service/modules/library/service/GameDetailService.java index cc7a91a..7d69e6c 100644 --- a/src/main/java/core_service/modules/library/service/GameDetailService.java +++ b/src/main/java/core_service/modules/library/service/GameDetailService.java @@ -2,6 +2,7 @@ package core_service.modules.library.service; import core_service.modules.library.api.dto.AchievementDto; import core_service.modules.library.api.dto.CorrectionMarkDto; +import core_service.modules.library.api.dto.EditionDto; import core_service.modules.library.api.dto.FinishedHintDto; import core_service.modules.library.api.dto.GameDetailDto; import core_service.modules.library.domain.AchievementProgress; @@ -19,6 +20,7 @@ import core_service.modules.library.domain.UserGameAchievementMeta; import core_service.modules.library.domain.UserGameId; import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; import core_service.modules.library.repo.GameCorrectionRepository; +import core_service.modules.library.repo.GameLinkRepository; import core_service.modules.library.repo.GameMoodsRepository; import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.GameAchievementSchemaRepository; @@ -58,10 +60,12 @@ public class GameDetailService { private final GameTimeToBeatRepository gameTimeToBeatRepository; private final TimeToBeatService timeToBeatService; private final MoodService moodService; + private final GameEditionService gameEditionService; private final GameSpeedrunRepository gameSpeedrunRepository; private final SpeedrunService speedrunService; private final GameMoodsRepository gameMoodsRepository; private final GameCorrectionRepository gameCorrectionRepository; + private final GameLinkRepository gameLinkRepository; @Value("${app.library.achievement-ttl-hours:1}") private long achievementTtlHours; @@ -96,6 +100,7 @@ public class GameDetailService { if (!hidden) { // Only backlog games are checked in bulk; any other game gets its link on a later visit. speedrunService.refreshInBackground(List.of(appid)); + gameEditionService.refreshInBackground(List.of(appid)); } if (userGame.getSource() == GameSource.MANUAL) { // Users without a Steam ID never sync, so the time to beat of their games is asked again here. @@ -217,7 +222,11 @@ public class GameDetailService { moods.map(GameMoods::getEndless).orElse(null), moods.map(GameMoods::getGenres).orElse(List.of()), gameCorrectionRepository.findByAppidOrderByField(userGame.getAppid()).stream() - .map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList() + .map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList(), + gameLinkRepository.setOf(userGame.getAppid()).isPresent(), + gameLinkRepository.linkedEditions(userGame.getUserId(), userGame.getAppid()).stream() + .map(e -> new EditionDto(e.appid(), e.name(), e.platform(), e.source(), e.status(), e.imageUrl())) + .toList() ); } } diff --git a/src/main/java/core_service/modules/library/service/GameEditionService.java b/src/main/java/core_service/modules/library/service/GameEditionService.java new file mode 100644 index 0000000..0d6f0cc --- /dev/null +++ b/src/main/java/core_service/modules/library/service/GameEditionService.java @@ -0,0 +1,63 @@ +package core_service.modules.library.service; + +import core_service.modules.igdb_adapter.service.IgdbApiService; +import core_service.modules.library.repo.GameEditionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.task.TaskExecutor; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Collection; +import java.util.Map; +import java.util.Set; + +/** + * Stores IGDB's "same game" key per library key, the same way {@link MoodService} stores moods: only for games + * without a fresh row, and nothing when IGDB fails. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class GameEditionService { + + private final IgdbApiService igdbApiService; + private final GameEditionRepository gameEditionRepository; + private final TaskExecutor applicationTaskExecutor; + + @Value("${app.library.edition-ttl-days:30}") + private long ttlDays; + + public void refresh(Collection appids) { + if (!igdbApiService.isEnabled() || appids.isEmpty()) return; + Set stale = stale(appids); + if (stale.isEmpty()) return; + + IgdbLookup.Answer answer = IgdbLookup.byAppid(stale, igdbApiService::getEditions, + igdbApiService::getEditionsByGameIds); + Map editions = answer.found(); + if (answer.answered().size() < stale.size()) { + log.warn("Editions of {} games not refreshed, IGDB did not answer", stale.size() - answer.answered().size()); + } + Instant now = Instant.now(); + for (Long appid : answer.answered()) { + IgdbApiService.Edition edition = editions.get(appid); + gameEditionRepository.upsert(appid, edition == null ? null : edition.igdbId(), + edition == null ? null : edition.sameGameId(), now); + } + } + + /** + * Same as {@link #refresh}, but the request does not wait for IGDB, and an error does not fail it. + */ + public void refreshInBackground(Collection appids) { + if (!igdbApiService.isEnabled()) return; + Refreshes.staleInBackground(applicationTaskExecutor, "Editions of " + appids, () -> appids, this::stale, + this::refresh); + } + + private Set stale(Collection appids) { + return Refreshes.stale(appids, gameEditionRepository::findFreshAppids, ttlDays); + } +} diff --git a/src/main/java/core_service/modules/library/service/GameLinkService.java b/src/main/java/core_service/modules/library/service/GameLinkService.java new file mode 100644 index 0000000..57dab3d --- /dev/null +++ b/src/main/java/core_service/modules/library/service/GameLinkService.java @@ -0,0 +1,144 @@ +package core_service.modules.library.service; + +import core_service.modules.library.domain.LinkPair; +import core_service.modules.library.repo.AdminAuditRepository; +import core_service.modules.library.repo.GameLinkRepository; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * An auditor links keys of one game for every user, from IGDB's suggestions and the users' requests; a user can + * ask for a link. Every change is written to the audit log. + */ +@Service +@RequiresRole(Role.AUDITOR) +@Slf4j +public class GameLinkService { + + static final int MAX_OPEN_REQUESTS = 20; + + public enum LinkResult { LINKED, NOT_FOUND, SAME_GAME, ALREADY_LINKED } + + public enum RequestResult { DONE, NOT_FOUND, SAME_GAME, ALREADY_LINKED, DISMISSED, TOO_MANY } + + private final GameLinkRepository linkRepository; + private final AdminAuditRepository adminAuditRepository; + private final UserGameRepository userGameRepository; + private final TransactionTemplate transaction; + + public GameLinkService(GameLinkRepository linkRepository, AdminAuditRepository adminAuditRepository, + UserGameRepository userGameRepository, PlatformTransactionManager transactionManager) { + this.linkRepository = linkRepository; + this.adminAuditRepository = adminAuditRepository; + this.userGameRepository = userGameRepository; + this.transaction = new TransactionTemplate(transactionManager); + } + + public LinkResult link(long appid, long otherAppid, long auditorId) { + if (appid == otherAppid) { + return LinkResult.SAME_GAME; + } + LinkResult result = transaction.execute(tx -> { + Map names = linkRepository.lockGames(appid, otherAppid); + if (names.size() < 2) { + return LinkResult.NOT_FOUND; + } + if (inOneSet(appid, otherAppid)) { + return LinkResult.ALREADY_LINKED; + } + linkRepository.link(appid, otherAppid); + adminAuditRepository.write(auditorId, "GAME_LINK", appid, otherAppid, + "\"" + names.get(appid) + "\" with \"" + names.get(otherAppid) + "\""); + return LinkResult.LINKED; + }); + if (result == LinkResult.LINKED) { + log.info("Auditor {} linked appid={} with appid={}", auditorId, appid, otherAppid); + } + return result; + } + + /** + * @return false when the game is in no set + */ + public boolean unlink(long appid, long auditorId) { + boolean unlinked = Boolean.TRUE.equals(transaction.execute(tx -> { + List former = linkRepository.unlink(appid); + if (former.isEmpty()) { + return false; + } + adminAuditRepository.write(auditorId, "GAME_UNLINK", appid, null, "Was linked with " + + linkRepository.names(former).values().stream().map(name -> "\"" + name + "\"") + .collect(Collectors.joining(", "))); + return true; + })); + if (unlinked) { + log.info("Auditor {} unlinked appid={}", auditorId, appid); + } + return unlinked; + } + + public Page queue(Pageable pageable) { + linkRepository.addIgdbPairs(); + return new PageImpl<>(linkRepository.openPairs((int) pageable.getOffset(), pageable.getPageSize()), pageable, + linkRepository.countOpenPairs()); + } + + /** + * @return false when the pair is not open + */ + public boolean dismiss(long appid, long otherAppid, long auditorId) { + Boolean done = transaction.execute(tx -> { + if (!linkRepository.dismiss(appid, otherAppid)) { + return false; + } + adminAuditRepository.write(auditorId, "GAME_LINK_DISMISS", appid, otherAppid, null); + return true; + }); + return Boolean.TRUE.equals(done); + } + + // The method marker wins over the class marker. + @RequiresRole(Role.USER) + public RequestResult request(long userId, long appid, long otherAppid) { + if (appid == otherAppid) { + return RequestResult.SAME_GAME; + } + // The lock makes the cap hold for parallel calls of one user. + return transaction.execute(tx -> { + linkRepository.lockRequests(userId); + if (userGameRepository.findActiveAppids(userId, List.of(appid, otherAppid)).size() < 2) { + return RequestResult.NOT_FOUND; + } + String state = linkRepository.pairState(appid, otherAppid); + if ("LINKED".equals(state) || inOneSet(appid, otherAppid)) { + return RequestResult.ALREADY_LINKED; + } + if ("DISMISSED".equals(state)) { + return RequestResult.DISMISSED; + } + if (linkRepository.countOpenRequests(userId) >= MAX_OPEN_REQUESTS) { + return RequestResult.TOO_MANY; + } + linkRepository.addRequest(userId, appid, otherAppid); + return RequestResult.DONE; + }); + } + + private boolean inOneSet(long appid, long otherAppid) { + Optional set = linkRepository.setOf(appid); + return set.isPresent() && set.equals(linkRepository.setOf(otherAppid)); + } +} diff --git a/src/main/java/core_service/modules/library/service/GameMergeService.java b/src/main/java/core_service/modules/library/service/GameMergeService.java index c031c9b..ff553fe 100644 --- a/src/main/java/core_service/modules/library/service/GameMergeService.java +++ b/src/main/java/core_service/modules/library/service/GameMergeService.java @@ -2,6 +2,7 @@ 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.AdminAuditRepository; import core_service.modules.library.repo.GameMergeRepository; import core_service.shared.security.RequiresRole; import core_service.shared.security.Role; @@ -33,13 +34,16 @@ public class GameMergeService { } private final GameMergeRepository mergeRepository; + private final AdminAuditRepository adminAuditRepository; private final TimeToBeatService timeToBeatService; private final MoodService moodService; private final TransactionTemplate transaction; - public GameMergeService(GameMergeRepository mergeRepository, TimeToBeatService timeToBeatService, + public GameMergeService(GameMergeRepository mergeRepository, AdminAuditRepository adminAuditRepository, + TimeToBeatService timeToBeatService, MoodService moodService, PlatformTransactionManager transactionManager) { this.mergeRepository = mergeRepository; + this.adminAuditRepository = adminAuditRepository; this.timeToBeatService = timeToBeatService; this.moodService = moodService; this.transaction = new TransactionTemplate(transactionManager); @@ -64,7 +68,7 @@ public class GameMergeService { return new Result.NotFound(); } MergeCounts counts = mergeRepository.merge(from, to); - mergeRepository.auditMerge(auditorId, from, to, + adminAuditRepository.write(auditorId, "GAME_MERGE", from, to, "\"" + names.get(from) + "\" into \"" + names.get(to) + "\": " + counts.moved() + " moved, " + counts.combined() + " combined"); return new Result.Merged(to); diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index f55090d..321f8c8 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -22,6 +22,7 @@ import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameAchievementMeta; import core_service.modules.library.domain.UserSteamSync; import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; +import core_service.modules.library.repo.GameLinkRepository; import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.PlaytimeDayRepository; import core_service.modules.library.repo.SteamGameRepository; @@ -76,6 +77,7 @@ public class LibraryQueryService { private final UserStatsService userStatsService; private final MoodService moodService; private final SpeedrunService speedrunService; + private final GameLinkRepository gameLinkRepository; @Value("${app.library.zone:Europe/Berlin}") private ZoneId zone; @@ -121,9 +123,10 @@ public class LibraryQueryService { Map unlocks = userAchievementMetaRepository .findByUserIdAndAppidIn(games.getFirst().getUserId(), appids).stream() .collect(Collectors.toMap(UserGameAchievementMeta::getAppid, m -> m)); + Map editions = gameLinkRepository.editionCounts(games.getFirst().getUserId(), appids); return games.stream() .map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()), - schemas.get(ug.getAppid()), unlocks.get(ug.getAppid()))) + schemas.get(ug.getAppid()), unlocks.get(ug.getAppid()), editions.get(ug.getAppid()))) .toList(); } @@ -267,7 +270,8 @@ public class LibraryQueryService { } private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time, - GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks) { + GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks, + Integer editions) { Integer achievementsTotal = ug.getSource() == GameSource.STEAM && schema != null && schema.isHasSchema() && schema.getAchievementsTotal() > 0 ? schema.getAchievementsTotal() : null; Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible() @@ -286,7 +290,8 @@ public class LibraryQueryService { ug.getSource(), ug.getPlatform(), meta != null ? meta.getImageUrl() : null, - ug.getHiddenAt() != null); + ug.getHiddenAt() != null, + editions); } public Optional getSyncStatus(Long userId) { diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java index 88b174c..602cca7 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -60,6 +60,7 @@ public class LibrarySyncWorker { private final WishlistGameRepository wishlistGameRepository; private final TimeToBeatService timeToBeatService; private final MoodService moodService; + private final GameEditionService gameEditionService; private final ApplicationEventPublisher eventPublisher; private final TransactionTemplate transaction; @@ -139,6 +140,7 @@ public class LibrarySyncWorker { igdbAppids.removeAll(new HashSet<>(userGameRepository.findHiddenAppids(userId))); timeToBeatService.refresh(igdbAppids); moodService.refresh(igdbAppids); + gameEditionService.refresh(igdbAppids); finalStatus = Status.OK; } catch (Exception e) { log.error("Sync failed for user {}", userId, e); diff --git a/src/main/java/core_service/modules/library/service/ManualGameService.java b/src/main/java/core_service/modules/library/service/ManualGameService.java index c0e1f1e..17f1248 100644 --- a/src/main/java/core_service/modules/library/service/ManualGameService.java +++ b/src/main/java/core_service/modules/library/service/ManualGameService.java @@ -66,11 +66,13 @@ public class ManualGameService { private final GameMergeRepository mergeRepository; private final TimeToBeatService timeToBeatService; private final MoodService moodService; + private final GameEditionService gameEditionService; private final TransactionTemplate transaction; public ManualGameService(IgdbApiService igdbApiService, UserGameRepository userGameRepository, SteamGameRepository steamGameRepository, GameMergeRepository mergeRepository, TimeToBeatService timeToBeatService, MoodService moodService, + GameEditionService gameEditionService, PlatformTransactionManager transactionManager) { this.igdbApiService = igdbApiService; this.userGameRepository = userGameRepository; @@ -78,6 +80,7 @@ public class ManualGameService { this.mergeRepository = mergeRepository; this.timeToBeatService = timeToBeatService; this.moodService = moodService; + this.gameEditionService = gameEditionService; this.transaction = new TransactionTemplate(transactionManager); } @@ -169,6 +172,7 @@ public class ManualGameService { if (result instanceof AddResult.Added added) { timeToBeatService.refreshInBackground(List.of(added.appid())); moodService.refreshInBackground(List.of(added.appid())); + gameEditionService.refreshInBackground(List.of(added.appid())); } return result; } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f49f8f8..222691c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -62,6 +62,7 @@ app: achievement-ttl-hours: 1 schema-ttl-days: 30 speedrun-ttl-days: 30 + edition-ttl-days: 30 # Reported broken pictures, repaired after the nightly sync. image-repair-cron: "0 30 1 * * *" image-repair-batch: 200 diff --git a/src/main/resources/db/migration/V28__Game_Links.sql b/src/main/resources/db/migration/V28__Game_Links.sql new file mode 100644 index 0000000..a422a93 --- /dev/null +++ b/src/main/resources/db/migration/V28__Game_Links.sql @@ -0,0 +1,57 @@ +-- IGDB's "same game" key per library key (ROADMAP item 13 batch 2), shared by all users and refreshed like game_mood. +-- same_game_id: version_parent of an edition, parent_game of a remaster or port, else the game's own IGDB id; +-- null when IGDB does not know the game. Remakes keep their own id: they are other games. +CREATE TABLE game_edition ( + appid BIGINT PRIMARY KEY, + igdb_id BIGINT, + same_game_id BIGINT, + checked_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX game_edition_same_game_idx ON game_edition (same_game_id); +-- The keys an auditor linked as one game, for every user; a set is every row with one set_id. Who and when is in +-- admin_audit (GAME_LINK, GAME_UNLINK, GAME_LINK_DISMISS). A merge (V21) moves the merged key's row to the kept key. +CREATE SEQUENCE game_link_set_seq; +CREATE TABLE game_link ( + appid BIGINT PRIMARY KEY, + set_id BIGINT NOT NULL +); +CREATE INDEX game_link_set_idx ON game_link (set_id); +-- The auditor queue: a pair IGDB suggests or a user asked for. DISMISSED and LINKED rows keep the pair out of the +-- queue for good. appid_a < appid_b. +CREATE TABLE game_link_pair ( + appid_a BIGINT NOT NULL, + appid_b BIGINT NOT NULL, + state VARCHAR(16) NOT NULL, + igdb BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (appid_a, appid_b), + CHECK (appid_a < appid_b) +); +-- Who asked for a pair; the queue shows only the count. Goes with the account (item 16). +CREATE TABLE game_link_request ( + user_id BIGINT NOT NULL, + appid_a BIGINT NOT NULL, + appid_b BIGINT NOT NULL, + PRIMARY KEY (user_id, appid_a, appid_b) +); +-- The display name Auth pushes with the account state, for the audit log page. Goes with the account (item 16). +CREATE TABLE account_name ( + user_id BIGINT PRIMARY KEY, + display_name VARCHAR(100) NOT NULL +); +-- The games a user's numbers count: listed games, and of a linked set the user owns more than once only its most +-- advanced edition (COMPLETED > FINISHED > PLAYING > DROPPED > BACKLOG, then more playtime, then the older row by +-- first_seen_at, so a key added today does not beat the Steam row, then the lower appid). +-- Same column rule as listed_user_game: a migration that changes a column of user_game drops both views first, +-- this one before listed_user_game. +CREATE VIEW counted_user_game AS +SELECT ug.* FROM listed_user_game ug +WHERE NOT EXISTS ( + SELECT 1 FROM game_link l + JOIN game_link o ON o.set_id = l.set_id AND o.appid <> l.appid + JOIN listed_user_game og ON og.user_id = ug.user_id AND og.appid = o.appid + WHERE l.appid = ug.appid + AND (CASE og.status WHEN 'COMPLETED' THEN 4 WHEN 'FINISHED' THEN 3 WHEN 'PLAYING' THEN 2 WHEN 'DROPPED' THEN 1 ELSE 0 END, + og.playtime_minutes, -extract(epoch from og.first_seen_at), -og.appid) + > (CASE ug.status WHEN 'COMPLETED' THEN 4 WHEN 'FINISHED' THEN 3 WHEN 'PLAYING' THEN 2 WHEN 'DROPPED' THEN 1 ELSE 0 END, + ug.playtime_minutes, -extract(epoch from ug.first_seen_at), -ug.appid)); diff --git a/src/test/java/core_service/configuration/LocalMocksParseTest.java b/src/test/java/core_service/configuration/LocalMocksParseTest.java index ec032cb..05f8ab3 100644 --- a/src/test/java/core_service/configuration/LocalMocksParseTest.java +++ b/src/test/java/core_service/configuration/LocalMocksParseTest.java @@ -45,6 +45,7 @@ class LocalMocksParseTest extends AbstractUnitTest { Map.entry("igdb-external-games", IgdbResponses.ExternalGame[].class), Map.entry("igdb-time-to-beats", IgdbResponses.TimeToBeat[].class), Map.entry("igdb-tags", IgdbResponses.GameTags[].class), + Map.entry("igdb-editions", IgdbResponses.Edition[].class), Map.entry("igdb-game-", IgdbResponses.Game[].class), Map.entry("speedrun-games-", SpeedrunResponses.Games.class), Map.entry("speedrun-runs-", SpeedrunResponses.Runs.class)); diff --git a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java index cb3b710..8093980 100644 --- a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java +++ b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java @@ -17,7 +17,8 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest { private static final List USER_TABLES = List.of("user_game_status_change", "user_game_achievement", "user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", - "user_steam_sync", "user_game", "game_import", "reward_ledger", "reward_equipped"); + "user_steam_sync", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", + "reward_equipped"); @Autowired private AccountCleanupRepository repository; @@ -42,6 +43,8 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest { jdbc.update(""" insert into user_steam_sync (user_id, steam_id, user_seen_at) values (?, '76561198000000001', now())""", userId); + jdbc.update("insert into game_link_request (user_id, appid_a, appid_b) values (?, 220, 620)", userId); + jdbc.update("insert into account_name (user_id, display_name) values (?, 'lobo')", userId); jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'SAVED', 0)", userId); jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', 20, now())", userId); jdbc.update("insert into reward_equipped (user_id, slot, item_code) values (?, 'BADGE', 'BADGE_PILE_SLAYER')", userId); diff --git a/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java b/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java index 57fa15e..ac963c1 100644 --- a/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java +++ b/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java @@ -109,9 +109,6 @@ class IgdbApiServiceTest extends AbstractUnitTest { Map.entry(220L, new IgdbApiService.GameTags(List.of(), List.of(), List.of()))); } - // REVIEW-FINDING: one Steam app linked to two IGDB games (main entry + edition) keeps the tags of whichever game - // the HashMap yields first and drops the other's. Here game 72 has no tags and game 233 is Horror; 72 sits in the - // earlier bucket, so 620 is stored with no mood. Time to beat picks the most-voted entry; tags should be the union. @Test void getGameTags_SteamAppLinkedToTwoIgdbGames_KeepsTheTagsOfBoth() { expectToken(); @@ -201,6 +198,56 @@ class IgdbApiServiceTest extends AbstractUnitTest { igdb.verify(); } + @Test + void getEditions_ResolvesEditionsRemastersPortsAndSameGame() { + expectToken(); + igdb.expect(requestTo(containsString("/external_games"))) + .andRespond(withSuccess("[{\"game\": 11, \"uid\": \"1\"}, {\"game\": 12, \"uid\": \"2\"}," + + " {\"game\": 13, \"uid\": \"3\"}, {\"game\": 14, \"uid\": \"4\"}," + + " {\"game\": 15, \"uid\": \"5\"}, {\"game\": 99, \"uid\": \"6\"}," + + " {\"game\": 22, \"uid\": \"7\"}, {\"game\": 21, \"uid\": \"7\"}]", + MediaType.APPLICATION_JSON)); + igdb.expect(requestTo(containsString("/games"))) + .andExpect(content().string(containsString("fields version_parent,parent_game,game_type;"))) + .andRespond(withSuccess("[{\"id\": 11, \"version_parent\": 10, \"game_type\": 0}," + + " {\"id\": 12, \"parent_game\": 10, \"game_type\": 11}," + + " {\"id\": 13, \"parent_game\": 10, \"game_type\": 9}," + + " {\"id\": 14, \"parent_game\": 10, \"game_type\": 8}," + + " {\"id\": 15, \"game_type\": 0}," + + " {\"id\": 22, \"game_type\": 0}, {\"id\": 21, \"game_type\": 0}]", + MediaType.APPLICATION_JSON)); + + Map editions = service.getEditions(List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L)).orElseThrow(); + + assertThat(editions).containsOnlyKeys(1L, 2L, 3L, 4L, 5L, 7L); + assertThat(editions.get(1L)).isEqualTo(new IgdbApiService.Edition(11, 10)); + assertThat(editions.get(2L).sameGameId()).isEqualTo(10); + assertThat(editions.get(3L).sameGameId()).isEqualTo(10); + assertThat(editions.get(4L).sameGameId()).isEqualTo(14); + assertThat(editions.get(5L).sameGameId()).isEqualTo(15); + assertThat(editions.get(7L).igdbId()).isEqualTo(21); + } + + @Test + void getEditionsByGameIds_KeepsOnlyTheAskedGames() { + expectToken(); + igdb.expect(requestTo(containsString("/games"))) + .andExpect(content().string(containsString("id = (1025)"))) + .andRespond(withSuccess("[{\"id\": 1025, \"game_type\": 0}, {\"id\": 999, \"version_parent\": 1}]", + MediaType.APPLICATION_JSON)); + + assertThat(service.getEditionsByGameIds(List.of(1025L)).orElseThrow()) + .containsOnly(Map.entry(1025L, new IgdbApiService.Edition(1025, 1025))); + } + + @Test + void getEditions_IgdbError_IsEmpty() { + expectToken(); + igdb.expect(requestTo(containsString("/external_games"))).andRespond(withServerError()); + + assertThat(service.getEditions(List.of(1L))).isEmpty(); + } + @Test void getGameTagsByGameIds_AsksTheGamesDirectly() { expectToken(); diff --git a/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java b/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java new file mode 100644 index 0000000..73fa0ff --- /dev/null +++ b/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java @@ -0,0 +1,270 @@ +package core_service.modules.library.api; + +import core_service.configuration.security.ClientHeaderFilter; +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.repo.GameLinkRepository; +import core_service.modules.library.repo.SteamGameRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.shared.security.Role; +import core_service.shared.test.AbstractIntegrationTest; +import core_service.shared.test.ContractTokens; +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.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.BrokenBarrierException; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.Mockito.doAnswer; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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; + +/** + * Links and requests through the real (non-dev) filter chain, so the role in the token decides. + */ +@AutoConfigureMockMvc +@ActiveProfiles(value = "prod", inheritProfiles = false) +class GameLinkFlowTest extends AbstractIntegrationTest { + + private static final long AUDITOR_ID = 5003L; + private static final long USER_A = 5001L; + private static final long USER_B = 5002L; + private static final long GAME = -1_910_007L; + private static final long GAME_2 = -1_910_008L; + private static final long FILLER = -1_920_000L; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private UserGameRepository userGameRepository; + + @MockitoSpyBean + private GameLinkRepository linkRepository; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + void seed() { + for (String table : List.of("game_link_request", "game_link_pair", "game_link", "game_edition", "admin_audit")) { + jdbc.update("delete from " + table); + } + userGameRepository.deleteAll(); + jdbc.update("delete from steam_game where appid in (?, ?) or appid <= ?", GAME, GAME_2, FILLER); + steamGameRepository.save(new SteamGame(GAME, "Hades (Switch)", null, Instant.now())); + steamGameRepository.save(new SteamGame(GAME_2, "Hades (PS5)", null, Instant.now())); + for (long user : List.of(USER_A, USER_B)) { + own(user, GAME); + own(user, GAME_2); + } + jdbc.update("insert into game_edition (appid, igdb_id, same_game_id, checked_at) values (?, 7, 7, now()), (?, 8, 7, now())", + GAME, GAME_2); + } + + private void own(long userId, long appid) { + userGameRepository.save(UserGame.manual(userId, appid, "Switch", GameStatus.BACKLOG, 0, Instant.now())); + } + + private MockHttpServletRequestBuilder as(MockHttpServletRequestBuilder request, long userId, Role role) { + return request.header("Authorization", "Bearer " + ContractTokens.loginToken(userId, role)) + .header(ClientHeaderFilter.CLIENT_HEADER, "test"); + } + + private ResultActions body(MockHttpServletRequestBuilder request, long userId, Role role, String json) throws Exception { + return mockMvc.perform(as(request, userId, role).contentType(MediaType.APPLICATION_JSON).content(json)); + } + + private ResultActions linkAs(Role role, long appid, long otherAppid) throws Exception { + return body(post("/api/library/games/" + appid + "/link"), AUDITOR_ID, role, "{\"otherAppid\": " + otherAppid + "}"); + } + + private ResultActions requestAs(long userId, long appid, long otherAppid) throws Exception { + return body(post("/api/library/games/" + appid + "/link-requests"), userId, Role.USER, + "{\"otherAppid\": " + otherAppid + "}"); + } + + private ResultActions queue() throws Exception { + return mockMvc.perform(as(get("/api/library/link-queue"), AUDITOR_ID, Role.AUDITOR)); + } + + private List audit() { + return jdbc.query("select * from admin_audit order by id", (rs, i) -> rs.getString("action") + " " + + rs.getString("detail")); + } + + @Test + void queue_ShowsTheIgdbPair() throws Exception { + queue().andExpect(status().isOk()).andExpect(jsonPath("$.content", hasSize(1))) + .andExpect(jsonPath("$.content[0].igdb").value(true)) + .andExpect(jsonPath("$.content[0].requests").value(0)) + .andExpect(jsonPath("$.content[0].a.name").value("Hades (PS5)")); + } + + @Test + void roles_AUserCannotUseTheAuditorCalls() throws Exception { + mockMvc.perform(as(get("/api/library/link-queue"), USER_A, Role.USER)).andExpect(status().isForbidden()); + linkAs(Role.USER, GAME, GAME_2).andExpect(status().isForbidden()); + mockMvc.perform(as(delete("/api/library/games/" + GAME + "/link"), USER_A, Role.USER)).andExpect(status().isForbidden()); + mockMvc.perform(as(delete("/api/library/link-queue/" + GAME + "/" + GAME_2), USER_A, Role.USER)) + .andExpect(status().isForbidden()); + assertThat(audit()).isEmpty(); + } + + @Test + void requests_AreCountedPerUserAndPair() throws Exception { + requestAs(USER_A, GAME, GAME_2).andExpect(status().isNoContent()); + requestAs(USER_B, GAME_2, GAME).andExpect(status().isNoContent()); + requestAs(USER_A, GAME, GAME_2).andExpect(status().isNoContent()); + + queue().andExpect(jsonPath("$.content", hasSize(1))).andExpect(jsonPath("$.content[0].requests").value(2)); + } + + @Test + void requests_BadInput_IsRefused() throws Exception { + requestAs(USER_A, GAME, GAME).andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("SAME_GAME")); + requestAs(USER_A, GAME, -1_910_999L).andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("GAME_NOT_FOUND")); + requestAs(AUDITOR_ID, GAME, GAME_2).andExpect(status().isNotFound()); + assertThat(jdbc.queryForObject("select count(*) from game_link_request", Integer.class)).isZero(); + } + + @Test + void requests_The21stOpenOneIsTooMany() throws Exception { + for (int i = 0; i < 21; i++) { + steamGameRepository.save(new SteamGame(FILLER - i, "Filler " + i, null, Instant.now())); + own(USER_A, FILLER - i); + } + for (int i = 0; i < 20; i++) { + requestAs(USER_A, GAME, FILLER - i).andExpect(status().isNoContent()); + } + + requestAs(USER_A, GAME, FILLER - 20).andExpect(status().isTooManyRequests()) + .andExpect(jsonPath("$.code").value("TOO_MANY_REQUESTS")); + } + + @Test + void link_ChangesEveryUsersNumbersAndShowsTheEditions() throws Exception { + requestAs(USER_A, GAME, GAME_2).andExpect(status().isNoContent()); + + linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent()); + + queue().andExpect(jsonPath("$.content", hasSize(0))); + assertThat(audit()).containsExactly("GAME_LINK \"Hades (Switch)\" with \"Hades (PS5)\""); + for (long user : List.of(USER_A, USER_B)) { + mockMvc.perform(as(get("/api/library/games/" + GAME), user, Role.USER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.linked").value(true)) + .andExpect(jsonPath("$.editions", hasSize(1))) + .andExpect(jsonPath("$.editions[0].appid").value(GAME_2)); + mockMvc.perform(as(get("/api/library/games"), user, Role.USER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.content[*].editions", hasItem(2))); + } + requestAs(USER_A, GAME, GAME_2).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("ALREADY_LINKED")); + linkAs(Role.AUDITOR, GAME_2, GAME).andExpect(status().isConflict()); + linkAs(Role.AUDITOR, GAME, GAME).andExpect(status().isBadRequest()); + linkAs(Role.AUDITOR, GAME, -1_910_999L).andExpect(status().isNotFound()); + } + + @Test + void unlink_DismissesThePairForGood() throws Exception { + linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent()); + + mockMvc.perform(as(delete("/api/library/games/" + GAME + "/link"), AUDITOR_ID, Role.AUDITOR)) + .andExpect(status().isNoContent()); + + assertThat(audit()).hasSize(2).last().asString().startsWith("GAME_UNLINK").contains("Hades (PS5)"); + queue().andExpect(jsonPath("$.content", hasSize(0))); + queue().andExpect(jsonPath("$.content", hasSize(0))); + requestAs(USER_A, GAME, GAME_2).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("PAIR_DISMISSED")); + assertThat(jdbc.queryForObject("select count(*) from game_link_request", Integer.class)).isZero(); + mockMvc.perform(as(delete("/api/library/games/" + GAME + "/link"), AUDITOR_ID, Role.AUDITOR)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("NOT_LINKED")); + } + + @Test + void dismiss_WritesTheAuditRowAndAnswers404TheSecondTime() throws Exception { + queue().andExpect(jsonPath("$.content", hasSize(1))); + + mockMvc.perform(as(delete("/api/library/link-queue/" + GAME_2 + "/" + GAME), AUDITOR_ID, Role.AUDITOR)) + .andExpect(status().isNoContent()); + mockMvc.perform(as(delete("/api/library/link-queue/" + GAME_2 + "/" + GAME), AUDITOR_ID, Role.AUDITOR)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("PAIR_NOT_OPEN")); + + assertThat(audit()).containsExactly("GAME_LINK_DISMISS null"); + queue().andExpect(jsonPath("$.content", hasSize(0))); + } + + // Without the per-user lock every call counts 19 before any call inserts (the barrier holds the count until all + // calls have counted); with it the first call waits alone, the barrier times out, and the calls run one by one. + @Test + void requests_ParallelCallsCannotPassTheCap() throws Exception { + int parallel = 12; + for (int i = 0; i < 19 + parallel; i++) { + steamGameRepository.save(new SteamGame(FILLER - i, "Filler " + i, null, Instant.now())); + own(USER_A, FILLER - i); + } + for (int i = 0; i < 19; i++) { + requestAs(USER_A, GAME, FILLER - i).andExpect(status().isNoContent()); + } + CyclicBarrier counted = new CyclicBarrier(parallel); + doAnswer(call -> { + Object count = call.callRealMethod(); + try { + counted.await(500, TimeUnit.MILLISECONDS); + } catch (TimeoutException | BrokenBarrierException e) { + // The other calls wait for the lock: the barrier is broken, the calls go on one by one. + } + return count; + }).when(linkRepository).countOpenRequests(USER_A); + ExecutorService pool = Executors.newFixedThreadPool(parallel); + try { + List> calls = new ArrayList<>(); + for (int i = 0; i < parallel; i++) { + long other = FILLER - 19 - i; + calls.add(pool.submit(() -> requestAs(USER_A, GAME, other))); + } + for (Future call : calls) { + call.get(); + } + } finally { + pool.shutdownNow(); + } + + assertThat(jdbc.queryForObject("select count(*) from game_link_request where user_id = ?", Integer.class, USER_A)) + .isEqualTo(20); + } + + @Test + void queue_AnotherPageIsEmptyAndKeepsTheTotal() throws Exception { + mockMvc.perform(as(get("/api/library/link-queue?page=1"), AUDITOR_ID, Role.AUDITOR)).andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(0))).andExpect(jsonPath("$.totalElements").value(1)) + .andExpect(jsonPath("$.page").value(1)); + } +} diff --git a/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java b/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java index 3032d9a..8558900 100644 --- a/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java +++ b/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java @@ -161,6 +161,55 @@ class GameMergeFlowTest extends AbstractIntegrationTest { + " \"Hades (Switch)\" into \"Hades\": 1 moved, 0 combined"); } + @Test + void merge_MovesTheSetRowAndDropsThePairsRequestsAndEditionOfTheMergedKey() throws Exception { + manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG); + jdbc.update("delete from game_link"); + jdbc.update("delete from game_link_pair"); + jdbc.update("delete from game_link_request"); + jdbc.update("delete from game_edition"); + jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", MANUAL, MANUAL_2); + jdbc.update("insert into game_link_pair (appid_a, appid_b, state) values (?, ?, 'OPEN')", MANUAL_2, STEAM_2); + jdbc.update("insert into game_link_pair (appid_a, appid_b, state) values (?, ?, 'OPEN')", MANUAL_2, MANUAL); + jdbc.update("insert into game_link_request (user_id, appid_a, appid_b) values (?, ?, ?)", OTHER_USER, MANUAL_2, STEAM_2); + jdbc.update("insert into game_edition (appid, same_game_id, checked_at) values (?, 1, now())", MANUAL); + + merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk()); + + assertThat(jdbc.queryForList("select appid from game_link order by appid", Long.class)).containsExactly(MANUAL_2, STEAM); + assertThat(jdbc.queryForObject("select count(*) from game_link_pair where ? in (appid_a, appid_b)", Integer.class, MANUAL)) + .isZero(); + assertThat(jdbc.queryForObject("select count(*) from game_link_pair", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("select count(*) from game_link_request", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("select count(*) from game_edition", Integer.class)).isZero(); + assertThat(audit()).hasSize(1).first().asString().contains("GAME_MERGE"); + } + + @Test + void merge_IntoAKeyWithItsOwnSet_JoinsBothSets() throws Exception { + manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG); + jdbc.update("delete from game_link"); + jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1), (?, 2), (?, 2)", + MANUAL, MANUAL_2, STEAM, STEAM_2); + + merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk()); + + assertThat(jdbc.queryForList("select appid from game_link order by appid", Long.class)) + .containsExactly(MANUAL_2, STEAM, STEAM_2); + assertThat(jdbc.queryForObject("select count(distinct set_id) from game_link", Integer.class)).isEqualTo(1); + } + + @Test + void merge_TwoKeysOfOneSet_DropsTheSetThatHasOneKeyLeft() throws Exception { + manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG); + jdbc.update("delete from game_link"); + jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", MANUAL, STEAM); + + merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk()); + + assertThat(jdbc.queryForObject("select count(*) from game_link", Integer.class)).isZero(); + } + @Test void paidGameRows_MoveToTheKeptKey_UnlessTheUserHoldsTheKindThereAlready() throws Exception { manualRow(AUDITOR_ID, MANUAL, GameStatus.FINISHED); diff --git a/src/test/java/core_service/modules/library/repo/CountedUserGameViewTest.java b/src/test/java/core_service/modules/library/repo/CountedUserGameViewTest.java new file mode 100644 index 0000000..0f20174 --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/CountedUserGameViewTest.java @@ -0,0 +1,127 @@ +package core_service.modules.library.repo; + +import core_service.shared.test.AbstractPersistenceTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class CountedUserGameViewTest extends AbstractPersistenceTest { + + private static final String COLUMNS = "select column_name from information_schema.columns " + + "where table_schema = 'public' and table_name = ? order by ordinal_position"; + private static final long USER = 1L; + private static final long OTHER_USER = 2L; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + void seed() { + jdbc.update("delete from game_link"); + jdbc.update("delete from user_game"); + for (long appid : List.of(-10L, 10L, 20L, 30L, 40L)) { + jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid); + } + } + + private void set(long appid, long setId) { + jdbc.update("insert into game_link (appid, set_id) values (?, ?)", appid, setId); + } + + private void own(long userId, long appid, String status, int playtime) { + jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, ?, ?)", + userId, appid, status, playtime); + } + + private List counted(long userId) { + return jdbc.queryForList("select appid from counted_user_game where user_id = ? order by appid", Long.class, userId); + } + + @Test + void view_HasEveryColumnOfUserGame() { + assertEquals(jdbc.queryForList(COLUMNS, String.class, "user_game"), + jdbc.queryForList(COLUMNS, String.class, "counted_user_game")); + } + + @Test + void set_CountsTheMostAdvancedEdition() { + set(10, 1); + set(20, 1); + own(USER, 10, "BACKLOG", 0); + own(USER, 20, "COMPLETED", 0); + + assertThat(counted(USER)).containsExactly(20L); + } + + @Test + void set_AllBacklog_CountsTheOneWithMorePlaytimeOnce() { + set(10, 1); + set(20, 1); + own(USER, 10, "BACKLOG", 5); + own(USER, 20, "BACKLOG", 50); + + assertThat(counted(USER)).containsExactly(20L); + } + + @Test + void set_FullTieOnStatusAndPlaytime_CountsTheOlderRow() { + set(-10, 1); + set(20, 1); + own(USER, -10, "BACKLOG", 0); + own(USER, 20, "BACKLOG", 0); + jdbc.update("update user_game set first_seen_at = now() where appid = -10"); + jdbc.update("update user_game set first_seen_at = now() - interval '3 years' where appid = 20"); + + assertThat(counted(USER)).containsExactly(20L); + } + + @Test + void set_FullTieAlsoOnTime_CountsTheLowerKey() { + set(10, 1); + set(20, 1); + own(USER, 10, "BACKLOG", 0); + own(USER, 20, "BACKLOG", 0); + jdbc.update("update user_game set first_seen_at = '2025-01-01T00:00:00Z'"); + + assertThat(counted(USER)).containsExactly(10L); + } + + @Test + void set_DroppedBeatsBacklog() { + set(10, 1); + set(20, 1); + own(USER, 10, "BACKLOG", 0); + own(USER, 20, "DROPPED", 0); + + assertThat(counted(USER)).containsExactly(20L); + } + + @Test + void set_HiddenSiblingDoesNotWin() { + set(10, 1); + set(20, 1); + own(USER, 10, "BACKLOG", 0); + own(USER, 20, "COMPLETED", 0); + jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = 20", USER); + + assertThat(counted(USER)).containsExactly(10L); + } + + @Test + void keyInASetTheUserOwnsOnce_IsCounted_AndOtherUsersRowsHaveNoEffect() { + set(10, 1); + set(20, 1); + own(USER, 10, "BACKLOG", 0); + own(OTHER_USER, 20, "COMPLETED", 0); + own(USER, 30, "BACKLOG", 0); + + assertThat(counted(USER)).containsExactly(10L, 30L); + assertThat(counted(OTHER_USER)).containsExactly(20L); + } +} diff --git a/src/test/java/core_service/modules/library/repo/GameLinkRepositoryTest.java b/src/test/java/core_service/modules/library/repo/GameLinkRepositoryTest.java new file mode 100644 index 0000000..8322a6f --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/GameLinkRepositoryTest.java @@ -0,0 +1,270 @@ +package core_service.modules.library.repo; + +import core_service.modules.library.domain.Edition; +import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.LinkPair; +import core_service.shared.test.AbstractPersistenceTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +@Import(GameLinkRepository.class) +class GameLinkRepositoryTest extends AbstractPersistenceTest { + + private static final long USER = 1L; + private static final long OTHER_USER = 2L; + + @Autowired + private GameLinkRepository repository; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + void seed() { + for (String table : List.of("game_link_request", "game_link_pair", "game_link", "game_edition", "user_game")) { + jdbc.update("delete from " + table); + } + for (long appid : List.of(100L, 200L, -7L, -8L, 300L)) { + jdbc.update("insert into steam_game (appid, name, image_url) values (?, ?, ?) on conflict do nothing", + appid, "Game " + appid, "img" + appid); + } + edition(100, 10); + edition(-7, 10); + edition(200, 10); + edition(-8, 11); + } + + private void edition(long appid, long sameGame) { + jdbc.update("insert into game_edition (appid, igdb_id, same_game_id, checked_at) values (?, ?, ?, now())", + appid, Math.abs(appid), sameGame); + } + + private void own(long userId, long appid, String status) { + jdbc.update("insert into user_game (user_id, appid, status) values (?, ?, ?)", userId, appid, status); + } + + private List pairs(String state) { + return jdbc.queryForList("select appid_a || ':' || appid_b from game_link_pair where state = ? order by 1", + String.class, state); + } + + @Test + void addIgdbPairs_InsertsEachPairOnce() { + assertThat(repository.addIgdbPairs()).isEqualTo(3); + assertThat(repository.addIgdbPairs()).isZero(); + + assertThat(pairs("OPEN")).containsExactlyInAnyOrder("-7:100", "-7:200", "100:200"); + } + + @Test + void addIgdbPairs_SkipsKeysOfOneSet() { + repository.link(100, 200); + + repository.addIgdbPairs(); + + assertThat(pairs("OPEN")).containsExactlyInAnyOrder("-7:100", "-7:200"); + } + + @Test + void addIgdbPairs_DoesNotBringBackADismissedPair() { + repository.addIgdbPairs(); + repository.dismiss(100, 200); + + repository.addIgdbPairs(); + + assertThat(pairs("DISMISSED")).containsExactly("100:200"); + assertThat(pairs("OPEN")).doesNotContain("100:200"); + } + + @Test + void link_MakesExtendsAndMergesSets() { + long first = repository.link(100, 200); + assertThat(repository.setOf(100)).contains(first); + assertThat(repository.setOf(200)).contains(first); + + assertThat(repository.link(200, -7)).isEqualTo(first); + assertThat(repository.setOf(-7)).contains(first); + + long second = repository.link(-8, 300); + assertThat(second).isNotEqualTo(first); + long merged = repository.link(-8, 100); + + assertThat(jdbc.queryForList("select distinct set_id from game_link", Long.class)).containsExactly(merged); + assertThat(jdbc.queryForObject("select count(*) from game_link", Integer.class)).isEqualTo(5); + assertThat(pairs("LINKED")).contains("100:200", "-8:300", "-8:100"); + } + + @Test + void link_DecidesTheOtherOpenPairsInsideTheSet() { + repository.addIgdbPairs(); + + repository.link(-7, 100); + repository.link(100, 200); + + assertThat(pairs("OPEN")).isEmpty(); + assertThat(pairs("LINKED")).containsExactlyInAnyOrder("-7:100", "100:200", "-7:200"); + } + + @Test + void unlink_RemovesTheKeyDismissesItsPairsAndDropsASetOfOne() { + repository.link(100, 200); + repository.link(100, -7); + + assertThat(repository.unlink(-7)).containsExactly(100L, 200L); + + assertThat(repository.setOf(-7)).isEmpty(); + assertThat(repository.setOf(100)).isPresent(); + assertThat(pairs("DISMISSED")).containsExactlyInAnyOrder("-7:100", "-7:200"); + + assertThat(repository.unlink(100)).containsExactly(200L); + assertThat(repository.setOf(200)).isEmpty(); + assertThat(repository.unlink(100)).isEmpty(); + } + + @Test + void dismiss_OnlyAnOpenPair() { + repository.addIgdbPairs(); + + assertThat(repository.dismiss(200, 100)).isTrue(); + assertThat(repository.dismiss(100, 200)).isFalse(); + assertThat(repository.dismiss(100, 300)).isFalse(); + } + + @Test + void addRequest_CreatesThePairOnceAndOneRequestPerUser() { + assertThat(repository.addRequest(USER, 300, 100)).isTrue(); + assertThat(repository.addRequest(USER, 100, 300)).isFalse(); + assertThat(repository.addRequest(OTHER_USER, 100, 300)).isTrue(); + + assertThat(pairs("OPEN")).containsExactly("100:300"); + assertThat(jdbc.queryForObject("select count(*) from game_link_request", Integer.class)).isEqualTo(2); + assertThat(repository.pairState(300, 100)).isEqualTo("OPEN"); + assertThat(repository.pairState(100, 200)).isNull(); + } + + @Test + void addIgdbPairs_MarksAnOpenRequestedPairAsIgdb() { + repository.addRequest(USER, 100, 200); + + repository.addIgdbPairs(); + + assertThat(repository.openPairs(0, 10)).filteredOn(pair -> pair.a().appid() == 100 && pair.b().appid() == 200) + .singleElement().extracting(LinkPair::igdb).isEqualTo(true); + } + + @Test + void countOpenRequests_IgnoresPairsThatAreNotOpen() { + repository.addRequest(USER, 100, 300); + repository.addRequest(USER, 100, 200); + repository.addRequest(USER, -7, 300); + repository.link(100, 300); + repository.dismiss(100, 200); + + assertThat(repository.countOpenRequests(USER)).isEqualTo(1); + assertThat(repository.countOpenRequests(OTHER_USER)).isZero(); + } + + @Test + void openPairs_CarryNamesImagesRequestsAndTheOrder() { + repository.addIgdbPairs(); + repository.addRequest(USER, 100, 300); + repository.addRequest(USER, 200, 300); + repository.addRequest(OTHER_USER, 200, 300); + repository.link(-7, 100); + + List pairs = repository.openPairs(0, 10); + + assertThat(pairs).hasSize(4); + assertThat(pairs.getFirst().requests()).isEqualTo(2); + assertThat(pairs.getFirst().igdb()).isFalse(); + assertThat(pairs.getFirst().a().appid()).isEqualTo(200); + assertThat(pairs.getFirst().b().name()).isEqualTo("Game 300"); + assertThat(pairs.getFirst().b().imageUrl()).isEqualTo("img300"); + assertThat(pairs.get(1).requests()).isEqualTo(1); + assertThat(pairs.get(2).igdb()).isTrue(); + assertThat(pairs.get(2).requests()).isZero(); + assertThat(pairs.get(3).igdb()).isTrue(); + } + + @Test + void openPairs_PageWithoutGapsAndCountOnlyOpenPairs() { + repository.addIgdbPairs(); + + assertThat(repository.countOpenPairs()).isEqualTo(3); + List first = repository.openPairs(0, 2); + List second = repository.openPairs(2, 2); + assertThat(first).hasSize(2); + assertThat(second).hasSize(1); + assertThat(Stream.concat(first.stream(), second.stream()) + .map(pair -> pair.a().appid() + ":" + pair.b().appid())) + .containsExactlyInAnyOrder("-7:100", "-7:200", "100:200"); + + repository.dismiss(100, 200); + + assertThat(repository.countOpenPairs()).isEqualTo(2); + } + + @Test + void addIgdbPairs_KeepsADismissedPairDismissed() { + repository.addRequest(USER, 100, 200); + repository.dismiss(100, 200); + + repository.addIgdbPairs(); + + assertThat(pairs("DISMISSED")).containsExactly("100:200"); + assertThat(jdbc.queryForObject("select igdb from game_link_pair where appid_a = 100 and appid_b = 200", + Boolean.class)).isFalse(); + } + + @Test + void linkedEditions_ListsTheOtherListedRowsOfTheUserOnly() { + repository.link(100, 200); + repository.link(100, -7); + repository.link(100, -8); + own(USER, 100, "PLAYING"); + own(USER, 200, "BACKLOG"); + own(USER, -7, "FINISHED"); + own(USER, -8, "BACKLOG"); + jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = -8", USER); + own(OTHER_USER, 200, "COMPLETED"); + + List editions = repository.linkedEditions(USER, 100); + + assertThat(editions).extracting(Edition::appid).containsExactly(-7L, 200L); + assertThat(editions.getFirst().status()).isEqualTo(GameStatus.FINISHED); + assertThat(editions.getFirst().name()).isEqualTo("Game -7"); + } + + @Test + void linkedEditions_RemovedRowIsLeftOut() { + repository.link(100, 200); + own(USER, 100, "PLAYING"); + own(USER, 200, "BACKLOG"); + jdbc.update("update user_game set removed_at = now() where user_id = ? and appid = 200", USER); + + assertThat(repository.linkedEditions(USER, 100)).isEmpty(); + } + + @Test + void editionCounts_GivesTwoForBothKeysAndNothingForASingleOwnedKey() { + repository.link(100, 200); + repository.link(100, -7); + own(USER, 100, "PLAYING"); + own(USER, 200, "BACKLOG"); + own(USER, -7, "BACKLOG"); + own(OTHER_USER, 100, "BACKLOG"); + + assertThat(repository.editionCounts(USER, List.of(100L, 200L, 300L))).isEqualTo(Map.of(100L, 3, 200L, 3)); + assertThat(repository.editionCounts(OTHER_USER, List.of(100L))).isEmpty(); + assertThat(repository.editionCounts(USER, List.of())).isEmpty(); + } +} diff --git a/src/test/java/core_service/modules/library/service/GameEditionServiceTest.java b/src/test/java/core_service/modules/library/service/GameEditionServiceTest.java new file mode 100644 index 0000000..3d25436 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/GameEditionServiceTest.java @@ -0,0 +1,94 @@ +package core_service.modules.library.service; + +import core_service.modules.igdb_adapter.service.IgdbApiService; +import core_service.modules.library.repo.GameEditionRepository; +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.springframework.core.task.SyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class GameEditionServiceTest extends AbstractUnitTest { + + @Mock + private IgdbApiService igdbApiService; + + @Mock + private GameEditionRepository gameEditionRepository; + + @Spy + private TaskExecutor applicationTaskExecutor = new SyncTaskExecutor(); + + @InjectMocks + private GameEditionService service; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "ttlDays", 30L); + } + + @Test + void refresh_StoresStaleGamesOnly_AndNullsForAnAnsweredUnknownGame() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(gameEditionRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of(220L)); + when(igdbApiService.getEditions(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of( + 620L, new IgdbApiService.Edition(72, 10)))); + + service.refresh(List.of(220L, 620L, 700L)); + + verify(gameEditionRepository).upsert(eq(620L), eq(72L), eq(10L), any(Instant.class)); + verify(gameEditionRepository).upsert(eq(700L), isNull(), isNull(), any(Instant.class)); + verify(gameEditionRepository, never()).upsert(eq(220L), any(), any(), any()); + } + + @Test + void refresh_GamesOffSteam_AreAskedByIgdbIdAndStoredUnderTheirKey() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(gameEditionRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of()); + when(igdbApiService.getEditionsByGameIds(Set.of(119388L))).thenReturn(Optional.of(Map.of( + 119388L, new IgdbApiService.Edition(119388, 5)))); + + service.refresh(List.of(-119388L)); + + verify(gameEditionRepository).upsert(eq(-119388L), eq(119388L), eq(5L), any(Instant.class)); + } + + @Test + void refresh_IgdbFailed_StoresNothingSoTheGamesAreAskedAgain() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(gameEditionRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of()); + when(igdbApiService.getEditions(any())).thenReturn(Optional.empty()); + + service.refresh(List.of(620L)); + + verify(gameEditionRepository, never()).upsert(any(), any(), any(), any()); + } + + @Test + void refresh_IgdbOff_DoesNothing() { + when(igdbApiService.isEnabled()).thenReturn(false); + + service.refresh(List.of(620L)); + + verifyNoInteractions(gameEditionRepository); + } +} diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java index 2258aba..90e70b3 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -120,6 +120,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { @Mock private MoodService moodService; + @Mock + private GameEditionService gameEditionService; + @Mock private ApplicationEventPublisher eventPublisher; @@ -526,6 +529,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { verify(timeToBeatService).refresh(List.of(220L, -1025L)); verify(moodService).refresh(List.of(220L, -1025L)); + verify(gameEditionService).refresh(List.of(220L, -1025L)); } @Test diff --git a/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java b/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java index 5c2584a..501629e 100644 --- a/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java +++ b/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java @@ -50,6 +50,9 @@ class ManualGameServiceTest extends AbstractUnitTest { @Mock private MoodService moodService; + @Mock + private GameEditionService gameEditionService; + @Mock private PlatformTransactionManager transactionManager; @@ -58,7 +61,7 @@ class ManualGameServiceTest extends AbstractUnitTest { @BeforeEach void setUp() { service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, mergeRepository, timeToBeatService, - moodService, transactionManager); + moodService, gameEditionService, transactionManager); } @Test