Public Access
feat(dev): add the local profile with recorded third-party answers
Profile "local" points Steam, Twitch/IGDB and speedrun.com to a WireMock container from compose.yaml (recordings in local/wiremock) and checks logins against the local Auth. The IGDB lookups now keep only the ids they asked for: an answer with another game made tags and release years drop the whole batch.
This commit is contained in:
1 parent
5a089b2082
commit
5c0c0c6a78
54 files changed
+1539
-8
No files matched your search
@@ -20,6 +20,7 @@ import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -275,13 +276,14 @@ public class IgdbApiService {
|
||||
List<Long> ids = List.copyOf(igdbIds);
|
||||
Map<Long, TimeToBeat> result = new HashMap<>();
|
||||
for (int from = 0; from < ids.size(); from += BATCH) {
|
||||
String gameIds = ids.subList(from, Math.min(from + BATCH, ids.size())).stream()
|
||||
.map(String::valueOf).collect(Collectors.joining(","));
|
||||
List<Long> batchIds = ids.subList(from, Math.min(from + BATCH, ids.size()));
|
||||
Set<Long> batch = new HashSet<>(batchIds);
|
||||
String gameIds = batchIds.stream().map(String::valueOf).collect(Collectors.joining(","));
|
||||
IgdbResponses.TimeToBeat[] times = query("/game_time_to_beats",
|
||||
"fields game_id,hastily,normally,completely,count; where game_id = (" + gameIds + "); limit " + LIMIT + ";",
|
||||
IgdbResponses.TimeToBeat[].class);
|
||||
for (IgdbResponses.TimeToBeat time : times) {
|
||||
if (time.gameId() != null) {
|
||||
if (time.gameId() != null && batch.contains(time.gameId())) {
|
||||
result.putIfAbsent(time.gameId(),
|
||||
new TimeToBeat(minutes(time.hastily()), minutes(time.normally()), minutes(time.completely())));
|
||||
}
|
||||
@@ -353,8 +355,10 @@ public class IgdbApiService {
|
||||
"fields genres.name,themes.name,game_modes.name; where id = (" + gameIds + "); limit " + LIMIT + ";",
|
||||
IgdbResponses.GameTags[].class);
|
||||
Map<Long, GameTags> result = new HashMap<>();
|
||||
// Only the asked ids, in this and the other lookups: callers index their maps by them, and an answer can
|
||||
// hold more games.
|
||||
for (IgdbResponses.GameTags game : games) {
|
||||
if (game.id() != null) {
|
||||
if (game.id() != null && igdbIds.contains(game.id())) {
|
||||
result.put(game.id(), new GameTags(names(game.genres()), names(game.themes()), names(game.gameModes())));
|
||||
}
|
||||
}
|
||||
@@ -371,7 +375,7 @@ public class IgdbApiService {
|
||||
IgdbResponses.Game[].class);
|
||||
Map<Long, Integer> result = new HashMap<>();
|
||||
for (IgdbResponses.Game game : games) {
|
||||
if (game.id() != null && game.firstReleaseDate() != null) {
|
||||
if (game.id() != null && game.firstReleaseDate() != null && igdbIds.contains(game.id())) {
|
||||
result.put(game.id(), year(game.firstReleaseDate()));
|
||||
}
|
||||
}
|
||||
@@ -397,7 +401,8 @@ public class IgdbApiService {
|
||||
IgdbResponses.ExternalGame[].class);
|
||||
Map<Long, List<Long>> steamByIgdb = new HashMap<>();
|
||||
for (IgdbResponses.ExternalGame external : externals) {
|
||||
if (external.game() != null && external.uid() != null && external.uid().matches("\\d+")) {
|
||||
if (external.game() != null && external.uid() != null && external.uid().matches("\\d+")
|
||||
&& steamAppids.contains(Long.parseLong(external.uid()))) {
|
||||
steamByIgdb.computeIfAbsent(external.game(), game -> new ArrayList<>()).add(Long.parseLong(external.uid()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Local development without keys or third parties: start with the profile "local" (alone), after Auth's local
|
||||
# profile. Postgres and WireMock (local/wiremock) come from compose.yaml. See the readme, "Run locally".
|
||||
spring:
|
||||
docker:
|
||||
compose:
|
||||
profiles:
|
||||
active: local
|
||||
# Also when Postgres still runs from a dev run; else WireMock would not start.
|
||||
start:
|
||||
skip: never
|
||||
|
||||
security:
|
||||
jwt:
|
||||
jwks-url: "http://localhost:8080/.well-known/jwks.json"
|
||||
|
||||
app:
|
||||
cors:
|
||||
allowed-origins: "http://localhost:5173"
|
||||
steam:
|
||||
api-key: local
|
||||
base-url: "http://localhost:8089/steam"
|
||||
igdb:
|
||||
client-id: local
|
||||
client-secret: local
|
||||
base-url: "http://localhost:8089/igdb"
|
||||
token-base-url: "http://localhost:8089/twitch"
|
||||
speedrun:
|
||||
base-url: "http://localhost:8089/speedrun"
|
||||
library:
|
||||
# The image repair checks the real Steam CDN.
|
||||
image-repair-cron: "-"
|
||||
@@ -0,0 +1,104 @@
|
||||
package core_service.configuration;
|
||||
|
||||
import core_service.modules.igdb_adapter.api.dto.response.IgdbResponses;
|
||||
import core_service.modules.speedrun_adapter.api.dto.response.SpeedrunResponses;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamGlobalAchievementPercentagesResponse;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamStoreItemsResponse;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamWishlistResponse;
|
||||
import core_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.RecordComponent;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* The recorded answers of the local profile (local/wiremock) still fit the adapters' response types.
|
||||
*/
|
||||
class LocalMocksParseTest extends AbstractUnitTest {
|
||||
|
||||
private static final Path FILES = Path.of("local/wiremock/__files");
|
||||
private static final Path MAPPINGS = Path.of("local/wiremock/mappings");
|
||||
|
||||
// By file name prefix; no prefix starts another one.
|
||||
private static final Map<String, Class<?>> TYPES = Map.ofEntries(
|
||||
Map.entry("steam-owned-games", SteamOwnedGamesResponse.class),
|
||||
Map.entry("steam-player-summary", SteamPlayerSummaryResponse.class),
|
||||
Map.entry("steam-wishlist", SteamWishlistResponse.class),
|
||||
Map.entry("steam-store-items", SteamStoreItemsResponse.class),
|
||||
Map.entry("steam-schema-", SteamSchemaForGameResponse.class),
|
||||
Map.entry("steam-global-percentages-", SteamGlobalAchievementPercentagesResponse.class),
|
||||
Map.entry("steam-player-achievements-", SteamPlayerAchievementsResponse.class),
|
||||
Map.entry("twitch-token", IgdbResponses.Token.class),
|
||||
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-game-", IgdbResponses.Game[].class),
|
||||
Map.entry("speedrun-games-", SpeedrunResponses.Games.class),
|
||||
Map.entry("speedrun-runs-", SpeedrunResponses.Runs.class));
|
||||
|
||||
private final JsonMapper jsonMapper = JsonMapper.builder().build();
|
||||
|
||||
@Test
|
||||
void everyMapping_PointsToAnExistingAnswer() throws IOException {
|
||||
List<Path> mappings;
|
||||
try (Stream<Path> listed = Files.list(MAPPINGS)) {
|
||||
mappings = listed.sorted().toList();
|
||||
}
|
||||
assertThat(mappings).isNotEmpty();
|
||||
|
||||
for (Path mapping : mappings) {
|
||||
JsonNode response = jsonMapper.readTree(mapping.toFile()).path("response");
|
||||
assertThat(response.has("bodyFileName") || response.has("jsonBody")).as(mapping.toString()).isTrue();
|
||||
if (response.has("bodyFileName")) {
|
||||
assertThat(FILES.resolve(response.get("bodyFileName").asString())).as(mapping.toString()).exists();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every top-level field of an answer, and the first field (the id) of every list element, is not null.
|
||||
private static void assertFilled(String name, Object parsed) throws ReflectiveOperationException {
|
||||
if (parsed instanceof Object[] elements) {
|
||||
assertThat(elements).as(name).isNotEmpty();
|
||||
for (Object element : elements) {
|
||||
RecordComponent id = element.getClass().getRecordComponents()[0];
|
||||
assertThat(id.getAccessor().invoke(element)).as(name + " " + id.getName()).isNotNull();
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (RecordComponent component : parsed.getClass().getRecordComponents()) {
|
||||
assertThat(component.getAccessor().invoke(parsed)).as(name + " " + component.getName()).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyRecordedAnswer_ParsesIntoItsResponseType() throws IOException, ReflectiveOperationException {
|
||||
List<Path> files;
|
||||
try (Stream<Path> listed = Files.list(FILES)) {
|
||||
files = listed.sorted().toList();
|
||||
}
|
||||
assertThat(files).isNotEmpty();
|
||||
|
||||
for (Path file : files) {
|
||||
String name = file.getFileName().toString();
|
||||
Class<?> type = TYPES.entrySet().stream()
|
||||
.filter(entry -> name.startsWith(entry.getKey()))
|
||||
.map(Map.Entry::getValue)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AssertionError("No response type for " + name));
|
||||
assertFilled(name, jsonMapper.readValue(file.toFile(), type));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,38 @@ class IgdbApiServiceTest extends AbstractUnitTest {
|
||||
assertThat(tags.get(620L).themes()).contains("Horror");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTimeToBeat_AnswersForAppsAndGamesNotAskedFor_AreLeftOut() {
|
||||
expectToken();
|
||||
igdb.expect(requestTo(containsString("/external_games")))
|
||||
.andRespond(withSuccess("[{\"game\": 72, \"uid\": \"620\"}, {\"game\": 73, \"uid\": \"999\"}]",
|
||||
MediaType.APPLICATION_JSON));
|
||||
igdb.expect(requestTo(containsString("/game_time_to_beats")))
|
||||
.andRespond(withSuccess("[{\"game_id\": 72, \"normally\": 3600}, {\"game_id\": 73, \"normally\": 7200}]",
|
||||
MediaType.APPLICATION_JSON));
|
||||
igdb.expect(requestTo(containsString("/game_time_to_beats")))
|
||||
.andRespond(withSuccess("[{\"game_id\": 1025, \"normally\": 3600}, {\"game_id\": 1026, \"normally\": 7200}]",
|
||||
MediaType.APPLICATION_JSON));
|
||||
|
||||
assertThat(service.getTimeToBeat(List.of(620L)).orElseThrow()).containsOnlyKeys(620L);
|
||||
assertThat(service.getTimeToBeatByGameIds(List.of(1025L)).orElseThrow()).containsOnlyKeys(1025L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameTagsAndYears_AnswerWithAGameNotAskedFor_KeepTheAskedGames() {
|
||||
expectToken();
|
||||
for (int call = 0; call < 2; call++) {
|
||||
igdb.expect(requestTo(containsString("/external_games")))
|
||||
.andRespond(withSuccess("[{\"game\": 72, \"uid\": \"620\"}]", MediaType.APPLICATION_JSON));
|
||||
igdb.expect(requestTo(containsString("/games")))
|
||||
.andRespond(withSuccess("[{\"id\": 72, \"first_release_date\": 1149120000, \"themes\": [{\"name\": \"Horror\"}]},"
|
||||
+ " {\"id\": 999, \"first_release_date\": 1493942400}]", MediaType.APPLICATION_JSON));
|
||||
}
|
||||
|
||||
assertThat(service.getGameTags(List.of(620L)).orElseThrow().get(620L).themes()).contains("Horror");
|
||||
assertThat(service.getReleaseYears(List.of(620L))).contains(Map.of(620L, Set.of(2006)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getReleaseYears_SteamAppLinkedToTwoIgdbGames_GetsBothYears() {
|
||||
expectToken();
|
||||
|
||||
Reference in new issue
Block a user