refactor(library): own IgdbGame record, one owned rule, layer rules

Stored import candidates keep their JSON shape, so READY imports from before still read.
This commit is contained in:
lobo committed 2026-09-30 09:37:16 +02:00
1 parent 1abc03d2c1
commit c1472c7d59
9 files changed
+126 -51

No files matched your search

+6 -1
View File
@@ -194,6 +194,10 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
`speedrun_adapter`. `speedrun_adapter`.
- No cycles between `modules`, `shared` and `configuration`. - No cycles between `modules`, `shared` and `configuration`.
- `api` never uses `repo`, and `repo` never uses `api`. - `api` never uses `repo`, and `repo` never uses `api`.
- `domain` never uses `api`, `service`, `repo` or an adapter: the import stores IGDB hits as the own record
`IgdbGame`, so a change of the adapter's `IgdbApiService.Game` cannot break stored imports.
- `service` never uses `api`, except the adapters' response DTOs and a fixed list of 8 services that still build
their module's response DTOs. The list only gets shorter.
- Every class in `repo` ends with `Repository`. - Every class in `repo` ends with `Repository`.
- `shared` never uses `modules`, and `modules` never use `configuration`. - `shared` never uses `modules`, and `modules` never use `configuration`.
- A `*Service` class in a module's `service` package has `@Service`. A `*Controller` class in `api` has - A `*Service` class in a module's `service` package has `@Service`. A `*Controller` class in `api` has
@@ -397,7 +401,8 @@ Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Ne
## Roadmap ## Roadmap
- Sale alerts for wishlist games. - Sale alerts for wishlist games.
- Move `steam_adapter` DTOs out of `api`, then turn on strict module independence in ArchUnit. - Move the adapter DTOs out of `api`, then turn on strict module independence in ArchUnit and drop the adapter
exception of the "`service` never uses `api`" rule.
## License ## License
@@ -1,7 +1,5 @@
package core_service.modules.library.domain; package core_service.modules.library.domain;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
@@ -46,7 +44,7 @@ public record GameImport(long id, long userId, Source source, Status status, int
* @param exact the first candidate has the line's name (case and punctuation ignored) * @param exact the first candidate has the line's name (case and punctuation ignored)
* @param candidates best first, at most five * @param candidates best first, at most five
*/ */
public record Row(Line line, boolean searchFailed, boolean exact, List<IgdbApiService.Game> candidates) {} public record Row(Line line, boolean searchFailed, boolean exact, List<IgdbGame> candidates) {}
/** /**
* The user's pick for a line in the review. * The user's pick for a line in the review.
@@ -0,0 +1,9 @@
package core_service.modules.library.domain;
import java.util.List;
/**
* An IGDB game as the library needs it, and the stored shape of an import candidate (V22 {@code candidates}): the
* component names are the JSON keys of rows already stored.
*/
public record IgdbGame(long igdbId, String name, Integer year, String imageUrl, List<String> platforms, Long steamAppid) {}
@@ -1,6 +1,6 @@
package core_service.modules.library.repo; package core_service.modules.library.repo;
import core_service.modules.igdb_adapter.service.IgdbApiService; import core_service.modules.library.domain.IgdbGame;
import core_service.modules.library.domain.GameImport; import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -189,11 +189,11 @@ public class GameImportRepository {
new MapSqlParameterSource("id", importId), (rs, row) -> new GameImport.Row(line(rs), new MapSqlParameterSource("id", importId), (rs, row) -> new GameImport.Row(line(rs),
rs.getBoolean("search_failed"), rs.getBoolean("exact"), rs.getBoolean("search_failed"), rs.getBoolean("exact"),
jsonMapper.readValue(rs.getString("candidates"), jsonMapper.readValue(rs.getString("candidates"),
jsonMapper.getTypeFactory().constructCollectionType(List.class, IgdbApiService.Game.class)))); jsonMapper.getTypeFactory().constructCollectionType(List.class, IgdbGame.class))));
} }
public void saveMatch(long importId, int lineNo, boolean searchFailed, boolean exact, public void saveMatch(long importId, int lineNo, boolean searchFailed, boolean exact,
List<IgdbApiService.Game> candidates) { List<IgdbGame> candidates) {
jdbc.update("update game_import_row set matched_at = now(), search_failed = :failed, exact = :exact, " jdbc.update("update game_import_row set matched_at = now(), search_failed = :failed, exact = :exact, "
+ "candidates = cast(:candidates as jsonb) where import_id = :id and line_no = :line", + "candidates = cast(:candidates as jsonb) where import_id = :id and line_no = :line",
new MapSqlParameterSource("id", importId).addValue("line", lineNo).addValue("failed", searchFailed) new MapSqlParameterSource("id", importId).addValue("line", lineNo).addValue("failed", searchFailed)
@@ -1,7 +1,7 @@
package core_service.modules.library.service; package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameImport; import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.IgdbGame;
import core_service.modules.library.repo.GameImportRepository; import core_service.modules.library.repo.GameImportRepository;
import jakarta.annotation.PreDestroy; import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -49,7 +49,6 @@ public class GameImportWorker {
private final GameImportRepository importRepository; private final GameImportRepository importRepository;
private final ManualGameService manualGameService; private final ManualGameService manualGameService;
private final IgdbApiService igdbApiService;
private final TimeToBeatService timeToBeatService; private final TimeToBeatService timeToBeatService;
private final MoodService moodService; private final MoodService moodService;
private final Duration stallTime; private final Duration stallTime;
@@ -59,11 +58,10 @@ public class GameImportWorker {
private final Lane saving; private final Lane saving;
public GameImportWorker(GameImportRepository importRepository, ManualGameService manualGameService, public GameImportWorker(GameImportRepository importRepository, ManualGameService manualGameService,
IgdbApiService igdbApiService, TimeToBeatService timeToBeatService, MoodService moodService, TimeToBeatService timeToBeatService, MoodService moodService,
@Value("${app.library.import-stall-minutes:5}") long stallMinutes) { @Value("${app.library.import-stall-minutes:5}") long stallMinutes) {
this.importRepository = importRepository; this.importRepository = importRepository;
this.manualGameService = manualGameService; this.manualGameService = manualGameService;
this.igdbApiService = igdbApiService;
this.timeToBeatService = timeToBeatService; this.timeToBeatService = timeToBeatService;
this.moodService = moodService; this.moodService = moodService;
this.stallTime = Duration.ofMinutes(stallMinutes); this.stallTime = Duration.ofMinutes(stallMinutes);
@@ -140,11 +138,11 @@ public class GameImportWorker {
Map<Integer, GameImport.Row> rows = importRepository.findRows(importId).stream() Map<Integer, GameImport.Row> rows = importRepository.findRows(importId).stream()
.collect(Collectors.toMap(row -> row.line().lineNo(), row -> row)); .collect(Collectors.toMap(row -> row.line().lineNo(), row -> row));
for (GameImport.Choice choice : choices) { for (GameImport.Choice choice : choices) {
IgdbApiService.Game game = rows.get(choice.lineNo()).candidates().stream() IgdbGame game = rows.get(choice.lineNo()).candidates().stream()
.filter(candidate -> candidate.igdbId() == choice.igdbId()).findFirst() .filter(candidate -> candidate.igdbId() == choice.igdbId()).findFirst()
// A game from a new search in the review. IGDB knew it minutes ago, so no answer is an IGDB // A game from a new search in the review. IGDB knew it minutes ago, so no answer is an IGDB
// failure: the try stops and the line waits for the next one. // failure: the try stops and the line waits for the next one.
.or(() -> igdbApiService.getGame(choice.igdbId())) .or(() -> manualGameService.findGame(choice.igdbId()))
.orElseThrow(() -> new IllegalStateException("IGDB did not answer for game " + choice.igdbId())); .orElseThrow(() -> new IllegalStateException("IGDB did not answer for game " + choice.igdbId()));
if (Thread.currentThread().isInterrupted()) { if (Thread.currentThread().isInterrupted()) {
return; return;
@@ -170,11 +168,11 @@ public class GameImportWorker {
importRepository.markSaved(importId); importRepository.markSaved(importId);
} }
private record Match(boolean searchFailed, boolean exact, List<IgdbApiService.Game> candidates) {} private record Match(boolean searchFailed, boolean exact, List<IgdbGame> candidates) {}
private Match match(GameImport.Line line) { private Match match(GameImport.Line line) {
if (line.igdbId() != null) { if (line.igdbId() != null) {
Optional<IgdbApiService.Game> game = igdbApiService.getGame(line.igdbId()); Optional<IgdbGame> game = manualGameService.findGame(line.igdbId());
return new Match(game.isEmpty(), game.isPresent(), game.stream().toList()); return new Match(game.isEmpty(), game.isPresent(), game.stream().toList());
} }
return manualGameService.findGames(line.title()) return manualGameService.findGames(line.title())
@@ -185,9 +183,9 @@ public class GameImportWorker {
} }
// Same name first, among them one on the line's platform; else IGDB's order. // Same name first, among them one on the line's platform; else IGDB's order.
static List<IgdbApiService.Game> rank(GameImport.Line line, List<IgdbApiService.Game> found) { static List<IgdbGame> rank(GameImport.Line line, List<IgdbGame> found) {
Comparator<IgdbApiService.Game> order = Comparator Comparator<IgdbGame> order = Comparator
.comparing((IgdbApiService.Game game) -> !sameName(game.name(), line.title())) .comparing((IgdbGame game) -> !sameName(game.name(), line.title()))
.thenComparing(game -> line.platform() == null .thenComparing(game -> line.platform() == null
|| game.platforms().stream().noneMatch(line.platform()::equalsIgnoreCase)); || game.platforms().stream().noneMatch(line.platform()::equalsIgnoreCase));
return found.stream().sorted(order).limit(MAX_CANDIDATES).toList(); return found.stream().sorted(order).limit(MAX_CANDIDATES).toList();
@@ -4,6 +4,7 @@ import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.api.dto.GameSearchDto; import core_service.modules.library.api.dto.GameSearchDto;
import core_service.modules.library.api.dto.ManualGameUpdate; import core_service.modules.library.api.dto.ManualGameUpdate;
import core_service.modules.library.domain.GameKeys; import core_service.modules.library.domain.GameKeys;
import core_service.modules.library.domain.IgdbGame;
import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
@@ -93,24 +94,22 @@ public class ManualGameService {
// Games already in ToBePlayed first, with their catalog name and image (an auditor's correction included); then // Games already in ToBePlayed first, with their catalog name and image (an auditor's correction included); then
// games added by hand whose name matches (also a duplicate of a game IGDB now knows on Steam, so an auditor can // games added by hand whose name matches (also a duplicate of a game IGDB now knows on Steam, so an auditor can
// merge it); then the rest in IGDB's order. One hit per key. // merge it); then the rest in IGDB's order. One hit per key.
private List<GameSearchDto.Hit> hits(Long userId, String query, List<IgdbApiService.Game> games) { private List<GameSearchDto.Hit> hits(Long userId, String query, List<IgdbGame> games) {
Map<Long, Long> targets = mergeRepository.findTargets(games.stream().flatMap(game -> ownKeys(game).stream()).toList()); Map<Long, Long> targets = mergeRepository.findTargets(games.stream().flatMap(game -> ownKeys(game).stream()).toList());
Map<Long, SteamGame> catalog = steamGameRepository.findAllById(games.stream().map(game -> key(game, targets)).toList()) Map<Long, SteamGame> catalog = steamGameRepository.findAllById(games.stream().map(game -> key(game, targets)).toList())
.stream().collect(Collectors.toMap(SteamGame::getAppid, game -> game)); .stream().collect(Collectors.toMap(SteamGame::getAppid, game -> game));
List<SteamGame> addedByOthers = steamGameRepository.findManualByName(UserGameRepository.containsPattern(query), List<SteamGame> addedByOthers = steamGameRepository.findManualByName(UserGameRepository.containsPattern(query),
PageRequest.of(0, MAX_CATALOG_HITS)); PageRequest.of(0, MAX_CATALOG_HITS));
Set<Long> owned = new HashSet<>(userGameRepository.findActiveAppids(userId, Stream.concat( Set<Long> owned = ownedAppids(userId, games, targets, addedByOthers.stream().map(SteamGame::getAppid).toList());
games.stream().flatMap(game -> possibleKeys(game, targets).stream()),
addedByOthers.stream().map(SteamGame::getAppid)).toList()));
List<GameSearchDto.Hit> inCatalog = new ArrayList<>(); List<GameSearchDto.Hit> inCatalog = new ArrayList<>();
List<GameSearchDto.Hit> rest = new ArrayList<>(); List<GameSearchDto.Hit> rest = new ArrayList<>();
for (IgdbApiService.Game game : games) { for (IgdbGame game : games) {
long appid = key(game, targets); long appid = key(game, targets);
SteamGame entry = catalog.get(appid); SteamGame entry = catalog.get(appid);
GameSearchDto.Hit hit = new GameSearchDto.Hit(appid, game.igdbId(), entry != null ? entry.getName() : game.name(), GameSearchDto.Hit hit = new GameSearchDto.Hit(appid, game.igdbId(), entry != null ? entry.getName() : game.name(),
game.year(), entry != null && entry.getImageUrl() != null ? entry.getImageUrl() : game.imageUrl(), game.year(), entry != null && entry.getImageUrl() != null ? entry.getImageUrl() : game.imageUrl(),
fittingPlatforms(game), possibleKeys(game, targets).stream().anyMatch(owned::contains), entry != null); fittingPlatforms(game), isOwned(game, targets, owned), entry != null);
(entry != null ? inCatalog : rest).add(hit); (entry != null ? inCatalog : rest).add(hit);
} }
Stream<GameSearchDto.Hit> others = addedByOthers.stream().map(game -> new GameSearchDto.Hit(game.getAppid(), Stream<GameSearchDto.Hit> others = addedByOthers.stream().map(game -> new GameSearchDto.Hit(game.getAppid(),
@@ -125,7 +124,7 @@ public class ManualGameService {
// Few hits often mean a typo. IGDB's own hits keep their order; the variant hits follow, closest name first. // Few hits often mean a typo. IGDB's own hits keep their order; the variant hits follow, closest name first.
// The variants go through the same cache, and one that fails adds nothing. // The variants go through the same cache, and one that fails adds nothing.
private List<IgdbApiService.Game> withTypoHelp(String query, List<IgdbApiService.Game> found) { private List<IgdbGame> withTypoHelp(String query, List<IgdbGame> found) {
if (found.size() >= MIN_HITS) { if (found.size() >= MIN_HITS) {
return found; return found;
} }
@@ -137,12 +136,12 @@ public class ManualGameService {
Set<Long> seen = new HashSet<>(); Set<Long> seen = new HashSet<>();
found.forEach(game -> seen.add(game.igdbId())); found.forEach(game -> seen.add(game.igdbId()));
List<IgdbApiService.Game> extra = new ArrayList<>(); List<IgdbGame> extra = new ArrayList<>();
for (String variant : variants) { for (String variant : variants) {
if (found.size() + extra.size() >= MIN_HITS) { if (found.size() + extra.size() >= MIN_HITS) {
break; break;
} }
igdbApiService.searchGames(variant).orElse(List.of()).stream() igdbApiService.searchGames(variant).orElse(List.of()).stream().map(ManualGameService::toIgdbGame)
.filter(game -> seen.add(game.igdbId())) .filter(game -> seen.add(game.igdbId()))
.forEach(extra::add); .forEach(extra::add);
} }
@@ -162,7 +161,7 @@ public class ManualGameService {
return new AddResult.SearchUnavailable(); return new AddResult.SearchUnavailable();
} }
// Outside the transaction: no database connection waits for IGDB. // Outside the transaction: no database connection waits for IGDB.
Optional<IgdbApiService.Game> found = igdbApiService.getGame(igdbId); Optional<IgdbGame> found = findGame(igdbId);
if (found.isEmpty()) { if (found.isEmpty()) {
return new AddResult.UnknownGame(); return new AddResult.UnknownGame();
} }
@@ -181,7 +180,7 @@ public class ManualGameService {
* @param importId the import that adds the game, so its undo can remove it; null for a single add. A removed * @param importId the import that adds the game, so its undo can remove it; null for a single add. A removed
* row that comes back keeps no import id. * row that comes back keeps no import id.
*/ */
public AddResult addGame(Long userId, IgdbApiService.Game game, String platform, GameStatus status, public AddResult addGame(Long userId, IgdbGame game, String platform, GameStatus status,
int playtimeMinutes, Long importId) { int playtimeMinutes, Long importId) {
AddResult result; AddResult result;
try { try {
@@ -201,35 +200,34 @@ public class ManualGameService {
/** /**
* IGDB's hits for a title, with the same typo help as {@link #search}; empty when IGDB failed or is off. * IGDB's hits for a title, with the same typo help as {@link #search}; empty when IGDB failed or is off.
*/ */
public Optional<List<IgdbApiService.Game>> findGames(String query) { public Optional<List<IgdbGame>> findGames(String query) {
if (!igdbApiService.isEnabled()) { if (!igdbApiService.isEnabled()) {
return Optional.empty(); return Optional.empty();
} }
return igdbApiService.searchGames(query).map(found -> withTypoHelp(query, found)); return igdbApiService.searchGames(query)
.map(found -> withTypoHelp(query, found.stream().map(ManualGameService::toIgdbGame).toList()));
} }
/** /**
* The IGDB ids of the games the user already has, under any of their keys (a merge included). * The IGDB ids of the games the user already has, under any of their keys (a merge included).
*/ */
public Set<Long> ownedIgdbIds(Long userId, Collection<IgdbApiService.Game> games) { public Set<Long> ownedIgdbIds(Long userId, Collection<IgdbGame> games) {
if (games.isEmpty()) { if (games.isEmpty()) {
return Set.of(); return Set.of();
} }
Map<Long, Long> targets = mergeRepository.findTargets(games.stream().flatMap(game -> ownKeys(game).stream()).toList()); Map<Long, Long> targets = mergeRepository.findTargets(games.stream().flatMap(game -> ownKeys(game).stream()).toList());
Set<Long> owned = new HashSet<>(userGameRepository.findActiveAppids(userId, Set<Long> owned = ownedAppids(userId, games, targets, List.of());
games.stream().flatMap(game -> possibleKeys(game, targets).stream()).distinct().toList())); return games.stream().filter(game -> isOwned(game, targets, owned))
return games.stream().filter(game -> possibleKeys(game, targets).stream().anyMatch(owned::contains)) .map(IgdbGame::igdbId).collect(Collectors.toSet());
.map(IgdbApiService.Game::igdbId).collect(Collectors.toSet());
} }
private AddResult addInTransaction(Long userId, IgdbApiService.Game game, String platform, GameStatus status, private AddResult addInTransaction(Long userId, IgdbGame game, String platform, GameStatus status,
int playtimeMinutes, Long importId) { int playtimeMinutes, Long importId) {
return transaction.execute(tx -> { return transaction.execute(tx -> {
Instant now = Instant.now(); Instant now = Instant.now();
Map<Long, Long> targets = mergeRepository.findTargets(ownKeys(game)); Map<Long, Long> targets = mergeRepository.findTargets(ownKeys(game));
long appid = key(game, targets); long appid = key(game, targets);
List<Long> possibleKeys = possibleKeys(game, targets); if (!ownedAppids(userId, List.of(game), targets, List.of()).isEmpty()) {
if (!userGameRepository.findActiveAppids(userId, possibleKeys).isEmpty()) {
return new AddResult.AlreadyInLibrary(); return new AddResult.AlreadyInLibrary();
} }
Optional<UserGame> existing = userGameRepository.findById(new UserGameId(userId, appid)); Optional<UserGame> existing = userGameRepository.findById(new UserGameId(userId, appid));
@@ -287,7 +285,7 @@ public class ManualGameService {
// A Steam row belongs to the sync, which has the better name and art; only a game off Steam takes IGDB's, and // A Steam row belongs to the sync, which has the better name and art; only a game off Steam takes IGDB's, and
// only its own: a merged game must not rename the game it went into. // only its own: a merged game must not rename the game it went into.
private void saveCatalogEntry(long appid, IgdbApiService.Game game, Instant now) { private void saveCatalogEntry(long appid, IgdbGame game, Instant now) {
Optional<SteamGame> existing = steamGameRepository.findById(appid); Optional<SteamGame> existing = steamGameRepository.findById(appid);
if (existing.isEmpty()) { if (existing.isEmpty()) {
String imageUrl = GameKeys.isSteam(appid) ? null : game.imageUrl(); String imageUrl = GameKeys.isSteam(appid) ? null : game.imageUrl();
@@ -300,25 +298,47 @@ public class ManualGameService {
} }
// A longer name would not fit the column; with no platform left, the user types one. // A longer name would not fit the column; with no platform left, the user types one.
static List<String> fittingPlatforms(IgdbApiService.Game game) { static List<String> fittingPlatforms(IgdbGame game) {
return game.platforms().stream().filter(platform -> platform.length() <= ManualGameUpdate.MAX_PLATFORM_LENGTH).toList(); return game.platforms().stream().filter(platform -> platform.length() <= ManualGameUpdate.MAX_PLATFORM_LENGTH).toList();
} }
/**
* One game by its IGDB id; empty when IGDB does not know it or failed.
*/
public Optional<IgdbGame> findGame(long igdbId) {
return igdbApiService.getGame(igdbId).map(ManualGameService::toIgdbGame);
}
private static IgdbGame toIgdbGame(IgdbApiService.Game game) {
return new IgdbGame(game.igdbId(), game.name(), game.year(), game.imageUrl(), game.platforms(), game.steamAppid());
}
// IGDB can learn a game's Steam appid after the user added it under minus its IGDB id. Each key goes through its // IGDB can learn a game's Steam appid after the user added it under minus its IGDB id. Each key goes through its
// merge, if an auditor merged it. // merge, if an auditor merged it.
private static List<Long> possibleKeys(IgdbApiService.Game game, Map<Long, Long> targets) { private static List<Long> possibleKeys(IgdbGame game, Map<Long, Long> targets) {
return ownKeys(game).stream().map(own -> targets.getOrDefault(own, own)).distinct().toList(); return ownKeys(game).stream().map(own -> targets.getOrDefault(own, own)).distinct().toList();
} }
private Set<Long> ownedAppids(Long userId, Collection<IgdbGame> games, Map<Long, Long> targets,
Collection<Long> extraAppids) {
return new HashSet<>(userGameRepository.findActiveAppids(userId, Stream.concat(
games.stream().flatMap(game -> possibleKeys(game, targets).stream()), extraAppids.stream())
.distinct().toList()));
}
private static boolean isOwned(IgdbGame game, Map<Long, Long> targets, Set<Long> ownedAppids) {
return possibleKeys(game, targets).stream().anyMatch(ownedAppids::contains);
}
/** /**
* @param targets {@link GameMergeRepository#findTargets} of the {@link #ownKeys} * @param targets {@link GameMergeRepository#findTargets} of the {@link #ownKeys}
*/ */
private static long key(IgdbApiService.Game game, Map<Long, Long> targets) { private static long key(IgdbGame game, Map<Long, Long> targets) {
return possibleKeys(game, targets).getFirst(); return possibleKeys(game, targets).getFirst();
} }
// The key IGDB gives today first, then minus the IGDB id when the game is on Steam. // The key IGDB gives today first, then minus the IGDB id when the game is on Steam.
private static List<Long> ownKeys(IgdbApiService.Game game) { private static List<Long> ownKeys(IgdbGame game) {
long igdbKey = GameKeys.ofIgdb(game.igdbId()); long igdbKey = GameKeys.ofIgdb(game.igdbId());
return game.steamAppid() != null ? List.of(game.steamAppid(), igdbKey) : List.of(igdbKey); return game.steamAppid() != null ? List.of(game.steamAppid(), igdbKey) : List.of(igdbKey);
} }
@@ -1,6 +1,7 @@
package core_service.architecture; package core_service.architecture;
import core_service.shared.test.AbstractArchitectureTest; import core_service.shared.test.AbstractArchitectureTest;
import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaClass; import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.importer.ImportOption; import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.AnalyzeClasses;
@@ -59,6 +60,33 @@ class ArchitectureTest extends AbstractArchitectureTest {
.should().dependOnClassesThat().resideInAPackage("..api..") .should().dependOnClassesThat().resideInAPackage("..api..")
.because("Persistence layer must be independent of the API layer."); .because("Persistence layer must be independent of the API layer.");
@ArchTest
static final ArchRule domain_should_not_depend_on_other_layers = noClasses()
.that().resideInAPackage("core_service.modules..domain..")
.should().dependOnClassesThat().resideInAnyPackage("..api..", "..service..", "..repo..",
"core_service.modules.steam_adapter..", "core_service.modules.igdb_adapter..",
"core_service.modules.speedrun_adapter..")
.because("Entities and stored shapes must not change when a controller, service or external API changes.");
// The listed services build their module's response DTOs; the list only gets shorter.
@ArchTest
static final ArchRule services_should_not_depend_on_apis = noClasses()
.that().resideInAPackage("core_service.modules..service..")
.and().doNotHaveFullyQualifiedName("core_service.modules.library.service.BacklogService")
.and().doNotHaveFullyQualifiedName("core_service.modules.library.service.GameDetailService")
.and().doNotHaveFullyQualifiedName("core_service.modules.library.service.GameImportService")
.and().doNotHaveFullyQualifiedName("core_service.modules.library.service.LibraryQueryService")
.and().doNotHaveFullyQualifiedName("core_service.modules.library.service.ManualGameService")
.and().doNotHaveFullyQualifiedName("core_service.modules.library.service.SteamProfileService")
.and().doNotHaveFullyQualifiedName("core_service.modules.library.service.UserStatsService")
.and().doNotHaveFullyQualifiedName("core_service.modules.stats.service.PublicStatsService")
.should().dependOnClassesThat(JavaClass.Predicates.resideInAPackage("..api..")
.and(DescribedPredicate.not(JavaClass.Predicates.resideInAnyPackage(
"core_service.modules.steam_adapter.api.dto.response..",
"core_service.modules.igdb_adapter.api.dto.response..",
"core_service.modules.speedrun_adapter.api.dto.response.."))))
.because("Services must not depend on the HTTP layer; the adapters' response DTOs are the external APIs' shapes.");
@ArchTest @ArchTest
static final ArchRule repos_should_be_interfaces_named_repository = classes() static final ArchRule repos_should_be_interfaces_named_repository = classes()
.that().resideInAPackage("..repo..") .that().resideInAPackage("..repo..")
@@ -146,6 +146,23 @@ class GameImportControllerTest extends AbstractIntegrationTest {
USER_ID, appid); USER_ID, appid);
} }
@Test
void get_ReadyImportStoredBeforeIgdbGame_ReadsItsCandidates() throws Exception {
long id = insertImport(USER_ID, "READY", "total = 1");
// The JSON the import wrote with IgdbApiService.Game, before the own record IgdbGame.
jdbc.update("insert into game_import_row (import_id, line_no, title, matched_at, exact, candidates) "
+ "values (?, 1, 'Zelda', now(), true, cast(? as jsonb))", id,
"[{\"igdbId\":119388,\"name\":\"Zelda: Tears of the Kingdom\",\"year\":2023,"
+ "\"imageUrl\":null,\"platforms\":[\"Switch\"],\"steamAppid\":null}]");
mockMvc.perform(get("/api/library/imports/" + id))
.andExpect(status().isOk())
.andExpect(jsonPath("$.rows[0].candidates[0].igdbId").value(119388))
.andExpect(jsonPath("$.rows[0].candidates[0].name").value("Zelda: Tears of the Kingdom"))
.andExpect(jsonPath("$.rows[0].candidates[0].year").value(2023))
.andExpect(jsonPath("$.rows[0].candidates[0].platforms[0]").value("Switch"));
}
@Test @Test
void create_MatchesInTheBackground_SameNameFirstAndMarksGamesInTheLibrary() throws Exception { void create_MatchesInTheBackground_SameNameFirstAndMarksGamesInTheLibrary() throws Exception {
jdbc.update("insert into steam_game (appid, name) values (1245620, 'ELDEN RING') on conflict do nothing"); jdbc.update("insert into steam_game (appid, name) values (1245620, 'ELDEN RING') on conflict do nothing");
@@ -1,6 +1,6 @@
package core_service.modules.library.service; package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService; import core_service.modules.library.domain.IgdbGame;
import core_service.modules.library.domain.GameImport; import core_service.modules.library.domain.GameImport;
import core_service.modules.library.repo.GameImportRepository; import core_service.modules.library.repo.GameImportRepository;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -20,8 +20,8 @@ import static org.mockito.Mockito.when;
class GameImportWorkerTest { class GameImportWorkerTest {
private static IgdbApiService.Game game(long igdbId, String name, String... platforms) { private static IgdbGame game(long igdbId, String name, String... platforms) {
return new IgdbApiService.Game(igdbId, name, null, null, List.of(platforms), null); return new IgdbGame(igdbId, name, null, null, List.of(platforms), null);
} }
private static GameImport.Line line(String title, String platform) { private static GameImport.Line line(String title, String platform) {
@@ -37,12 +37,12 @@ class GameImportWorkerTest {
@Test @Test
void rank_SameNameFirst_ThenTheLinesPlatform_ThenIgdbsOrder_AtMostFive() { void rank_SameNameFirst_ThenTheLinesPlatform_ThenIgdbsOrder_AtMostFive() {
List<IgdbApiService.Game> found = List.of(game(1, "Doom Eternal", "PC"), game(2, "DOOM", "PC"), List<IgdbGame> found = List.of(game(1, "Doom Eternal", "PC"), game(2, "DOOM", "PC"),
game(3, "Doom", "Switch"), game(4, "Doom 3"), game(5, "Doom 64"), game(6, "Doom II")); game(3, "Doom", "Switch"), game(4, "Doom 3"), game(5, "Doom 64"), game(6, "Doom II"));
assertThat(GameImportWorker.rank(line("Doom", "switch"), found)).extracting(IgdbApiService.Game::igdbId) assertThat(GameImportWorker.rank(line("Doom", "switch"), found)).extracting(IgdbGame::igdbId)
.containsExactly(3L, 2L, 1L, 4L, 5L); .containsExactly(3L, 2L, 1L, 4L, 5L);
assertThat(GameImportWorker.rank(line("Doom", null), found)).extracting(IgdbApiService.Game::igdbId) assertThat(GameImportWorker.rank(line("Doom", null), found)).extracting(IgdbGame::igdbId)
.startsWith(2L, 3L); .startsWith(2L, 3L);
assertThat(GameImportWorker.rank(line("x", null), IntStream.range(0, 9) assertThat(GameImportWorker.rank(line("x", null), IntStream.range(0, 9)
.mapToObj(i -> game(i, "Game " + i)).toList())).hasSize(GameImportWorker.MAX_CANDIDATES); .mapToObj(i -> game(i, "Game " + i)).toList())).hasSize(GameImportWorker.MAX_CANDIDATES);
@@ -56,7 +56,7 @@ class GameImportWorkerTest {
.thenReturn(Optional.of(7L), Optional.empty()); .thenReturn(Optional.of(7L), Optional.empty());
when(repository.findStalled(any())).thenReturn(Map.of()); when(repository.findStalled(any())).thenReturn(Map.of());
GameImportWorker worker = new GameImportWorker(repository, mock(ManualGameService.class), GameImportWorker worker = new GameImportWorker(repository, mock(ManualGameService.class),
mock(IgdbApiService.class), mock(TimeToBeatService.class), mock(MoodService.class), 5); mock(TimeToBeatService.class), mock(MoodService.class), 5);
try { try {
worker.wakeMatching(); worker.wakeMatching();
verify(repository, timeout(2000)).findNext(GameImportWorker.MATCHING_QUEUE); verify(repository, timeout(2000)).findNext(GameImportWorker.MATCHING_QUEUE);