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.
This commit is contained in:
lobo committed 2026-10-02 09:10:56 +02:00
1 parent a12ed20705
commit e806670d26
43 files changed
+1877 -22

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
[
{
"id": 72
},
{
"id": 113112
},
{
"id": 17000
},
{
"id": 7046
},
{
"id": 26226
},
{
"id": 14593
},
{
"id": 233
},
{
"id": 133236
},
{
"id": 119388
}
]
@@ -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
}
]
@@ -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
}
]
@@ -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"
}
}
@@ -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"
}
}
+28 -1
View File
@@ -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
@@ -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<String> 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<String> 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;
@@ -52,6 +52,14 @@ public final class IgdbResponses {
@JsonProperty("game_modes") java.util.List<Named> 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) {}
@@ -48,6 +48,11 @@ public class IgdbApiService {
*/
public record GameTags(List<String> genres, List<String> themes, List<String> 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<Integer> 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<Map<Long, Edition>> getEditions(Collection<Long> steamAppids) {
if (!isEnabled() || steamAppids.isEmpty()) {
return Optional.empty();
}
try {
List<Long> appids = List.copyOf(steamAppids);
Map<Long, Edition> result = new HashMap<>();
for (int from = 0; from < appids.size(); from += BATCH) {
Map<Long, List<Long>> steamByIgdb = steamAppidsByIgdbGame(appids.subList(from, Math.min(from + BATCH, appids.size())));
Map<Long, Edition> 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<Map<Long, Edition>> getEditionsByGameIds(Collection<Long> igdbIds) {
if (!isEnabled() || igdbIds.isEmpty()) {
return Optional.empty();
}
try {
List<Long> ids = List.copyOf(igdbIds);
Map<Long, Edition> 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<Long, Edition> editionsOf(Collection<Long> 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<Long, Edition> 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<Long, Integer> yearsOf(Collection<Long> igdbIds) {
if (igdbIds.isEmpty()) {
return Map.of();
@@ -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<PageDto<LinkPairDto>> 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();
}
@@ -0,0 +1,3 @@
package core_service.modules.library.api.dto;
public record CatalogGameDto(long appid, String name, String imageUrl) {}
@@ -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) {}
@@ -43,7 +43,11 @@ public record GameDetailDto(
// IGDB genre names
List<String> genres,
// the fields an auditor corrected
List<CorrectionMarkDto> corrections
List<CorrectionMarkDto> 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<EditionDto> editions
) {
/**
@@ -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
) {}
@@ -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) {}
@@ -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) {}
@@ -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) {}
@@ -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) {}
@@ -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;
}
@@ -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) {}
@@ -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));
}
}
@@ -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<GameEdition, Long> {
@Query("select e.appid from GameEdition e where e.appid in :appids and e.checkedAt > :since")
Set<Long> findFreshAppids(@Param("appids") Collection<Long> 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);
}
@@ -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<Long, String> lockGames(long appid, long otherAppid) {
Map<Long, String> names = new HashMap<>();
jdbc.query("select appid, name from steam_game where appid in (:a, :b) order by appid for update",
keys(appid, otherAppid), rs -> { names.put(rs.getLong("appid"), rs.getString("name")); });
return names;
}
public Map<Long, String> names(Collection<Long> appids) {
Map<Long, String> 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<Long> 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<Long> first = setOf(appid);
Optional<Long> 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<Long> unlink(long appid) {
Optional<Long> setId = setOf(appid);
if (setId.isEmpty()) {
return List.of();
}
List<Long> 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<LinkPair> 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<Edition> 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<Long, Integer> editionCounts(long userId, Collection<Long> appids) {
Map<Long, Integer> 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);
}
}
@@ -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<String> 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<Long> fromSet = setOf(from);
if (fromSet.isEmpty()) {
return;
}
Optional<Long> 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<Long> 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();
}
/**
@@ -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()
);
}
}
@@ -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<Long> appids) {
if (!igdbApiService.isEnabled() || appids.isEmpty()) return;
Set<Long> stale = stale(appids);
if (stale.isEmpty()) return;
IgdbLookup.Answer<IgdbApiService.Edition> answer = IgdbLookup.byAppid(stale, igdbApiService::getEditions,
igdbApiService::getEditionsByGameIds);
Map<Long, IgdbApiService.Edition> 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<Long> appids) {
if (!igdbApiService.isEnabled()) return;
Refreshes.staleInBackground(applicationTaskExecutor, "Editions of " + appids, () -> appids, this::stale,
this::refresh);
}
private Set<Long> stale(Collection<Long> appids) {
return Refreshes.stale(appids, gameEditionRepository::findFreshAppids, ttlDays);
}
}
@@ -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<Long, String> 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<Long> 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<LinkPair> 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<Long> set = linkRepository.setOf(appid);
return set.isPresent() && set.equals(linkRepository.setOf(otherAppid));
}
}
@@ -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);
@@ -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<Long, UserGameAchievementMeta> unlocks = userAchievementMetaRepository
.findByUserIdAndAppidIn(games.getFirst().getUserId(), appids).stream()
.collect(Collectors.toMap(UserGameAchievementMeta::getAppid, m -> m));
Map<Long, Integer> 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<UserSteamSync> getSyncStatus(Long userId) {
@@ -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);
@@ -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;
}
+1
View File
@@ -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
@@ -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));
@@ -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));
@@ -17,7 +17,8 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
private static final List<String> 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);
@@ -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<Long, IgdbApiService.Edition> 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();
@@ -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<String> 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<Future<ResultActions>> 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<ResultActions> 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));
}
}
@@ -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);
@@ -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<Long> 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);
}
}
@@ -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<String> 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<LinkPair> 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<LinkPair> first = repository.openPairs(0, 2);
List<LinkPair> 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<Edition> 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();
}
}
@@ -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);
}
}
@@ -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
@@ -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