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

@@ -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