From 34d64e7215841539848449c8cb9e47349b43aaf8 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 30 Sep 2026 15:00:47 +0200 Subject: [PATCH] refactor: item 29 cleanup C14 C16 C17 C18 C20 A11 A15 X10 X12 X15: dead Steam count code, GameNames, steamIdText, one query per game for the finished estimate, no API versioning config, open-in-view off, CORS from APP_FRONTEND_URL, non-root image, prune only up to the highest known id. --- Dockerfile | 4 ++ readme.md | 16 +++++-- .../configuration/CacheConfig.java | 2 +- .../repo/AccountCleanupRepository.java | 9 ++-- .../library/api/LibraryController.java | 24 ++++------ .../modules/library/domain/GameNames.java | 25 +++++++++++ .../repo/UserGameAchievementRepository.java | 4 ++ .../service/FinishedEstimateService.java | 19 ++++---- .../library/service/GameImportWorker.java | 3 +- .../library/service/LibraryQueryService.java | 4 +- .../library/service/SpeedrunMatch.java | 20 ++------- .../steam_adapter/api/SteamController.java | 30 ------------- .../dto/response/SteamOwnedGamesResponse.java | 1 - .../service/SteamApiService.java | 45 ------------------- .../shared/security/UserPrincipal.java | 8 ++++ src/main/resources/application-local.yml | 3 +- src/main/resources/application.yml | 13 +++--- .../repo/AccountCleanupRepositoryTest.java | 8 +++- .../modules/library/domain/GameNamesTest.java | 15 +++++++ .../library/service/SpeedrunMatchTest.java | 6 --- .../service/SteamApiServiceCacheTest.java | 2 +- .../service/SteamApiServiceTest.java | 30 ------------- .../shared/test/AbstractWebMvcTest.java | 13 ------ 23 files changed, 116 insertions(+), 188 deletions(-) create mode 100644 src/main/java/core_service/modules/library/domain/GameNames.java delete mode 100644 src/main/java/core_service/modules/steam_adapter/api/SteamController.java create mode 100644 src/test/java/core_service/modules/library/domain/GameNamesTest.java delete mode 100644 src/test/java/core_service/shared/test/AbstractWebMvcTest.java diff --git a/Dockerfile b/Dockerfile index d086b1a..ab2d26c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,5 +36,9 @@ EXPOSE 8090 # Copy the compiled jar file from the builder stage COPY --from=builder /app/target/*.jar app.jar +# No root in the container +RUN addgroup -S app && adduser -S app -G app +USER app + # Run the application ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/readme.md b/readme.md index 370e075..2e94b35 100644 --- a/readme.md +++ b/readme.md @@ -76,9 +76,9 @@ To Be Played is split into three repositories: ## Authentication Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's -JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Loaded keys stay -until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. The token comes from Auth's -`tbp_access` cookie or a `Bearer` header, whichever verifies first. Every token names its kind in `aud` +JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Each load replaces +the key set: a key that left the JWKS stops working. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. +The token comes from Auth's `tbp_access` cookie or a `Bearer` header, whichever verifies first. Every token names its kind in `aud` (`tbp-login`, `tbp-public-profile`, `tbp-internal`, with `iss=tbp-auth`); a login's `sub` is the user id. Core reads `steamId` and the active `role` (missing or unknown = USER) from the claims and grants that role and every lower one (`USER < AUDITOR < PLATFORM_ADMIN`). A token without `aud` comes from an Auth from before item 26 (kind in `typ`, @@ -98,11 +98,19 @@ only guard. Every service method a controller calls carries one role marker, on the method or on its class (the method wins): `@RequiresRole(Role.X)`, `@Public` (no login, e.g. landing stats; the public profile checks its ticket) or `@Internal` (only Auth's service token). A Spring Security method interceptor checks it while a request is served; -scheduled jobs and background work run without a user and are not checked. A denied call answers `401` without a +scheduled jobs and background work run without a user and are not checked. That stays on purpose: the sync +worker calls `SteamApiService` and the import worker `ManualGameService`, both `@RequiresRole(USER)`, without a +request. Auth denies instead, because it has no such caller. A denied call answers `401` without a login, else `403`. The URL rules in `SecurityConfig` stay as the outer guard. ArchUnit fails the build for an unmarked method, two markers, a controller calling an `@Async` method (the check would never run), and a service method calling another service's method that needs more. +**Mirrored in Auth.** These files exist in both services with small differences; a change in one needs the same +change in the other: `ClientHeaderFilter`, `RoleAuthorizationManager`, `MethodSecurityConfig`, `Role`, `RequiresRole`, +`Public` (only Core has `Internal`), `UserPrincipal`, the login token check in `JwtService.parseLoginToken`, the +ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/auth-core-contract.json` +(`ToBePlayed-Deployment/scripts/check-contract.sh` compares the two). + ## API | Method | Path | Auth | diff --git a/src/main/java/core_service/configuration/CacheConfig.java b/src/main/java/core_service/configuration/CacheConfig.java index be4c21d..98472f9 100644 --- a/src/main/java/core_service/configuration/CacheConfig.java +++ b/src/main/java/core_service/configuration/CacheConfig.java @@ -17,7 +17,7 @@ public class CacheConfig { @Bean public CacheManager cacheManager() { - CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount", "publicStats", "steamPlayerSummary", + CaffeineCacheManager cacheManager = new CaffeineCacheManager("publicStats", "steamPlayerSummary", "igdbSearch"); cacheManager.setCaffeine(Caffeine.newBuilder() .expireAfterWrite(10, TimeUnit.MINUTES) diff --git a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java index 1b4f0f5..e82d116 100644 --- a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java +++ b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java @@ -6,6 +6,7 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; import java.util.Collection; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -90,13 +91,15 @@ public class AccountCleanupRepository { } /** - * Users that have rows in Core but are not in the list. + * Users that have rows in Core but are not in the list. Ids above the highest known one are left out: Auth's + * ids never come back, so such a user registered after Auth read the list. * * @param knownUserIds not empty */ public List findOrphans(Collection knownUserIds) { String union = String.join(" union ", ORPHAN_SOURCES.stream().map(table -> "select user_id from " + table).toList()); - return jdbc.queryForList("select user_id from (" + union + ") u where user_id not in (:ids) order by user_id", - ids(knownUserIds), Long.class); + return jdbc.queryForList("select user_id from (" + union + ") u where user_id not in (:ids) " + + "and user_id < :highest order by user_id", + ids(knownUserIds).addValue("highest", Collections.max(knownUserIds)), Long.class); } } diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index fe39546..aa1c07d 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -57,6 +57,7 @@ import org.springframework.web.bind.annotation.RestController; import java.util.EnumSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Objects; import java.util.Set; @@ -87,11 +88,8 @@ public class LibraryController { @GetMapping("/games-count") public ResponseEntity getGamesCount(@AuthenticationPrincipal UserPrincipal principal) { Long userId = principal.getIdAsLong(); - if (principal.steamId() != null) { - syncService.syncIfStale(userId, String.valueOf(principal.steamId())); - } else { - syncService.stopNightlySync(userId); - } + principal.steamIdText().ifPresentOrElse(steamId -> syncService.syncIfStale(userId, steamId), + () -> syncService.stopNightlySync(userId)); long count = queryService.countOwnedGames(userId); return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null)); } @@ -108,9 +106,7 @@ public class LibraryController { @RequestParam(defaultValue = "false") boolean hidden ) { Long userId = principal.getIdAsLong(); - if (principal.steamId() != null) { - syncService.syncIfStale(userId, String.valueOf(principal.steamId())); - } + principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId)); int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE; int safePage = Math.max(0, page); @@ -132,11 +128,12 @@ public class LibraryController { @PostMapping("/sync") public ResponseEntity forceSync(@AuthenticationPrincipal UserPrincipal principal) { - if (principal.steamId() == null) { + Optional steamId = principal.steamIdText(); + if (steamId.isEmpty()) { return ResponseEntity.badRequest().body(new ErrorResponse("NO_STEAM_ID", "User has no Steam ID")); } try { - syncService.forceSync(principal.getIdAsLong(), String.valueOf(principal.steamId())); + syncService.forceSync(principal.getIdAsLong(), steamId.get()); return ResponseEntity.accepted().body(Map.of("status", "started")); } catch (SyncCooldownException e) { return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) @@ -152,8 +149,7 @@ public class LibraryController { public ResponseEntity getGameDetail(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("appid") Long appid) { Long userId = principal.getIdAsLong(); - String steamId = principal.steamId() != null ? String.valueOf(principal.steamId()) : null; - return gameDetailService.getGameDetail(userId, steamId, appid) + return gameDetailService.getGameDetail(userId, principal.steamIdText().orElse(null), appid) .>map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) .body(GAME_NOT_FOUND)); @@ -310,9 +306,7 @@ public class LibraryController { int safeCount = Math.clamp(count, 1, MAX_PICKER_GAMES); Integer budget = maxMinutes != null && maxMinutes > 0 ? maxMinutes : null; Long userId = principal.getIdAsLong(); - if (principal.steamId() != null) { - syncService.syncIfStale(userId, String.valueOf(principal.steamId())); - } + principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId)); return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, safeCount)); } diff --git a/src/main/java/core_service/modules/library/domain/GameNames.java b/src/main/java/core_service/modules/library/domain/GameNames.java new file mode 100644 index 0000000..da36b22 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/GameNames.java @@ -0,0 +1,25 @@ +package core_service.modules.library.domain; + +import java.text.Normalizer; +import java.util.Locale; + +/** + * Game names as the matches compare them (import lines, speedrun.com hits). + */ +public final class GameNames { + + public static final String TRADEMARK_SIGNS = "[™®©]"; + + private GameNames() { + } + + /** + * Lower case, without accents, ™ ® © and punctuation, words split by one space. + */ + public static String normalize(String name) { + // Before NFKD, which turns ™ into "TM". + String withoutMarks = name.replaceAll(TRADEMARK_SIGNS, ""); + String plain = Normalizer.normalize(withoutMarks, Normalizer.Form.NFKD).replaceAll("\\p{M}", ""); + return plain.toLowerCase(Locale.ROOT).replaceAll("[^\\p{L}\\p{N}]+", " ").trim(); + } +} diff --git a/src/main/java/core_service/modules/library/repo/UserGameAchievementRepository.java b/src/main/java/core_service/modules/library/repo/UserGameAchievementRepository.java index e088cbe..02b4ce9 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameAchievementRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameAchievementRepository.java @@ -4,11 +4,15 @@ import core_service.modules.library.domain.UserGameAchievement; import core_service.modules.library.domain.UserGameAchievementId; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Collection; import java.util.List; public interface UserGameAchievementRepository extends JpaRepository { List findByUserIdAndAppid(Long userId, Long appid); + List findByAppidAndApiNameAndUserIdInAndUnlockedTrueAndUnlockedAtNotNull(Long appid, + String apiName, Collection userIds); + void deleteByUserIdAndAppid(Long userId, Long appid); } diff --git a/src/main/java/core_service/modules/library/service/FinishedEstimateService.java b/src/main/java/core_service/modules/library/service/FinishedEstimateService.java index e8ee4c2..6833ee6 100644 --- a/src/main/java/core_service/modules/library/service/FinishedEstimateService.java +++ b/src/main/java/core_service/modules/library/service/FinishedEstimateService.java @@ -10,6 +10,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Optional; @@ -39,15 +40,17 @@ public class FinishedEstimateService { for (Map.Entry> app : byApp.entrySet()) { Optional ending = EndingAchievement.of(schemaRepository.findByAppid(app.getKey())); if (ending.isEmpty()) continue; - String apiName = ending.get().achievement().getApiName(); + // One query per game, not per user: most candidates never unlocked the ending and come back every night. + // With the user ids, the primary key (user_id, appid, api_name) serves it. + List userIds = app.getValue().stream().map(FinishedWithoutDate::userId).toList(); + Map unlockByUser = userAchievementRepository + .findByAppidAndApiNameAndUserIdInAndUnlockedTrueAndUnlockedAtNotNull(app.getKey(), + ending.get().achievement().getApiName(), userIds).stream() + .collect(Collectors.toMap(UserGameAchievement::getUserId, UserGameAchievement::getUnlockedAt)); for (FinishedWithoutDate game : app.getValue()) { - Optional unlock = userAchievementRepository - .findByUserIdAndAppid(game.userId(), game.appid()).stream() - .filter(a -> a.isUnlocked() && a.getUnlockedAt() != null && a.getApiName().equals(apiName)) - .findFirst(); - if (unlock.isPresent()) { - dated += userGameRepository.setFinishedAtEstimate(game.userId(), game.appid(), - unlock.get().getUnlockedAt()); + Instant unlockedAt = unlockByUser.get(game.userId()); + if (unlockedAt != null) { + dated += userGameRepository.setFinishedAtEstimate(game.userId(), game.appid(), unlockedAt); } } } diff --git a/src/main/java/core_service/modules/library/service/GameImportWorker.java b/src/main/java/core_service/modules/library/service/GameImportWorker.java index 20f07c6..4645073 100644 --- a/src/main/java/core_service/modules/library/service/GameImportWorker.java +++ b/src/main/java/core_service/modules/library/service/GameImportWorker.java @@ -1,5 +1,6 @@ package core_service.modules.library.service; +import core_service.modules.library.domain.GameNames; import core_service.modules.library.domain.GameImport; import core_service.modules.library.domain.IgdbGame; import core_service.modules.library.repo.GameImportRepository; @@ -195,7 +196,7 @@ public class GameImportWorker { * Case, accents, punctuation and marks like ™ ignored, as in the speedrun.com match. */ static boolean sameName(String a, String b) { - return SpeedrunMatch.normalize(a).equals(SpeedrunMatch.normalize(b)); + return GameNames.normalize(a).equals(GameNames.normalize(b)); } /** diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index d19ba32..f55090d 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -212,11 +212,9 @@ public class LibraryQueryService { /** * @param profileStats the stat group names of the profile ticket - */ - @Public - /** * @param sectionNames see {@link ProfileSection#shown}; a hidden section runs no query */ + @Public public PublicLibraryDto getPublicLibrary(Long userId, Collection profileStats, Collection sectionNames) { Set sections = ProfileSection.shown(sectionNames); boolean summary = sections.contains(ProfileSection.SUMMARY); diff --git a/src/main/java/core_service/modules/library/service/SpeedrunMatch.java b/src/main/java/core_service/modules/library/service/SpeedrunMatch.java index b9a28b2..5c3e266 100644 --- a/src/main/java/core_service/modules/library/service/SpeedrunMatch.java +++ b/src/main/java/core_service/modules/library/service/SpeedrunMatch.java @@ -1,12 +1,11 @@ package core_service.modules.library.service; +import core_service.modules.library.domain.GameNames; import core_service.modules.speedrun_adapter.service.SpeedrunApiService; -import java.text.Normalizer; import java.util.Collection; import java.util.HashSet; import java.util.List; -import java.util.Locale; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -21,7 +20,6 @@ final class SpeedrunMatch { static final String WEBLINK_PREFIX = "https://www.speedrun.com/"; // speedrun.com tells remakes apart by the year: "Prey (2017)" next to "Prey". - private static final String TRADEMARK_SIGNS = "[™®©]"; private static final Pattern YEAR_SUFFIX = Pattern.compile("^(.*?)\\s*\\((\\d{4})\\)$"); // The column sizes of game_speedrun. private static final int MAX_GAME_ID_LENGTH = 16; @@ -64,24 +62,14 @@ final class SpeedrunMatch { * The name for speedrun.com's search, which does not find "DOOM Eternal" for "DOOM Eternal™". */ static String searchText(String name) { - return name.replaceAll(TRADEMARK_SIGNS, "").trim(); - } - - /** - * Lower case, without accents, ™ ® © and punctuation, words split by one space. - */ - static String normalize(String name) { - // Before NFKD, which turns ™ into "TM". - String withoutMarks = name.replaceAll(TRADEMARK_SIGNS, ""); - String plain = Normalizer.normalize(withoutMarks, Normalizer.Form.NFKD).replaceAll("\\p{M}", ""); - return plain.toLowerCase(Locale.ROOT).replaceAll("[^\\p{L}\\p{N}]+", " ").trim(); + return name.replaceAll(GameNames.TRADEMARK_SIGNS, "").trim(); } private static NameAndYear split(String name) { Matcher matcher = YEAR_SUFFIX.matcher(name.trim()); return matcher.matches() - ? new NameAndYear(normalize(matcher.group(1)), Integer.parseInt(matcher.group(2))) - : new NameAndYear(normalize(name), null); + ? new NameAndYear(GameNames.normalize(matcher.group(1)), Integer.parseInt(matcher.group(2))) + : new NameAndYear(GameNames.normalize(name), null); } private record NameAndYear(String name, Integer year) {} diff --git a/src/main/java/core_service/modules/steam_adapter/api/SteamController.java b/src/main/java/core_service/modules/steam_adapter/api/SteamController.java deleted file mode 100644 index 6cc8602..0000000 --- a/src/main/java/core_service/modules/steam_adapter/api/SteamController.java +++ /dev/null @@ -1,30 +0,0 @@ -package core_service.modules.steam_adapter.api; - -import core_service.modules.steam_adapter.service.SteamApiService; -import core_service.shared.security.UserPrincipal; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.util.Map; - -@RestController -@RequestMapping("/api/steam") -@RequiredArgsConstructor -public class SteamController { - - private final SteamApiService steamApiService; - - @GetMapping("/games-count") - public ResponseEntity getGamesCount(@AuthenticationPrincipal UserPrincipal principal) { - if (principal.steamId() == null) { - return ResponseEntity.ok(Map.of("count", 0, "hasSteamId", false)); - } - - int count = steamApiService.getOwnedGamesCount(String.valueOf(principal.steamId())); - return ResponseEntity.ok(Map.of("count", count, "hasSteamId", true)); - } -} diff --git a/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamOwnedGamesResponse.java b/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamOwnedGamesResponse.java index 691e74c..90651b9 100644 --- a/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamOwnedGamesResponse.java +++ b/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamOwnedGamesResponse.java @@ -11,7 +11,6 @@ public record SteamOwnedGamesResponse( @JsonIgnoreProperties(ignoreUnknown = true) public record OwnedGamesResponse( - @JsonProperty("game_count") Integer gameCount, @JsonProperty("games") List games ) {} diff --git a/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java b/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java index 6fc66d3..78b93b7 100644 --- a/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java +++ b/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java @@ -51,51 +51,6 @@ public class SteamApiService { @Value("${app.steam.store-country:DE}") private String storeCountry; - @Cacheable(value = "steamGameCount", key = "#steamId") - @RateLimiter(name = "steamApi") - @Retry(name = "steamApiOnce", fallbackMethod = "noGamesCount") - public int getOwnedGamesCount(String steamId) { - if (apiKey == null || apiKey.isEmpty() || apiKey.equals("${STEAM_API_KEY}")) { - log.error("Steam API Key is not configured correctly!"); - return 0; - } - - SteamOwnedGamesResponse response = steamRestClient.get() - .uri(uriBuilder -> uriBuilder - .path("/IPlayerService/GetOwnedGames/v1/") - .queryParam("key", apiKey) - .queryParam("steamid", steamId) - .queryParam("include_appinfo", "true") - .queryParam("include_played_free_games", "true") - .queryParam("skip_unvetted_apps", 0) - .queryParam("format", "json") - .build()) - .retrieve() - .body(SteamOwnedGamesResponse.class); - - if (response != null && response.response() != null) { - Integer count = response.response().gameCount(); - if (count == null) { - if (response.response().games() != null) { - int size = response.response().games().size(); - log.info("game_count was null, but games list has {} entries for SteamID: {}", size, steamId); - return size; - } - log.warn("Steam API returned an empty response object for SteamID: {}. Check if the ID is correct and 'Game details' are Public.", steamId); - return 0; - } - log.info("Successfully fetched {} games for SteamID: {}", count, steamId); - return count; - } - return 0; - } - - // The fallbacks run after the last try; the callers keep their "Steam failed" rule. - private int noGamesCount(String steamId, Exception e) { - log.error("Error communicating with Steam API for game count of SteamID: {}", steamId, e); - return 0; - } - // Asked on every page load for the avatar in the header. @Cacheable(value = "steamPlayerSummary", key = "#steamId", unless = "#result == null") @RateLimiter(name = "steamApi") diff --git a/src/main/java/core_service/shared/security/UserPrincipal.java b/src/main/java/core_service/shared/security/UserPrincipal.java index 91502ed..01d4296 100644 --- a/src/main/java/core_service/shared/security/UserPrincipal.java +++ b/src/main/java/core_service/shared/security/UserPrincipal.java @@ -1,6 +1,7 @@ package core_service.shared.security; import java.security.Principal; +import java.util.Optional; /** * @param role the active role of this login ("view as"), set by Auth @@ -20,6 +21,13 @@ public record UserPrincipal( return id; } + /** + * The Steam ID as the Steam API and user_steam_sync take it; empty, never "null", without one. + */ + public Optional steamIdText() { + return Optional.ofNullable(steamId).map(String::valueOf); + } + public long getIdAsLong() { return Long.parseLong(id); } diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 10b6c5d..c43d69b 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -14,8 +14,7 @@ security: jwks-url: "http://localhost:8080/.well-known/jwks.json" app: - cors: - allowed-origins: "http://localhost:5173" + frontend-url: "http://localhost:5173" steam: api-key: local base-url: "http://localhost:8089/steam" diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b204674..2053bb9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -7,7 +7,7 @@ spring: deserialization: accept-float-as-int: false jpa: - open-in-view: true + open-in-view: false flyway: enabled: true baseline-on-migrate: true @@ -15,12 +15,6 @@ spring: threads: virtual: enabled: true - mvc: - apiversion: - use: - query-parameter: version - default: "1.0" - supported: 1.0, 2,0 server: port: 8090 @@ -42,8 +36,11 @@ app: # Filled from the CI run by the Dockerfile. commit: ${APP_BUILD_COMMIT:dev} time: ${APP_BUILD_TIME:} + # The frontend; FRONTEND_HOST in the Deployment compose file. The prod host as default: an image that starts before + # the compose file sets the env var keeps working. + frontend-url: ${APP_FRONTEND_URL:https://tbp.andreas-dahm.eu} cors: - allowed-origins: "https://tbp.andreas-dahm.eu" + allowed-origins: ${app.frontend-url} steam: api-key: ${STEAM_API_KEY} base-url: https://api.steampowered.com diff --git a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java index d328e05..04f165c 100644 --- a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java +++ b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java @@ -111,10 +111,16 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest { void findOrphans_ReturnsUsersNotInTheList() { repository.addDeleted(List.of(7L)); - assertThat(repository.findOrphans(List.of(2L))).containsExactly(1L, 7L); + assertThat(repository.findOrphans(List.of(2L, 8L))).containsExactly(1L, 7L); assertThat(repository.findOrphans(List.of(1L, 2L, 7L))).isEmpty(); } + // Auth reads its ids before the call: a user who registers in between has a higher id and is no orphan. + @Test + void findOrphans_SkipsUsersAboveTheHighestKnownId() { + assertThat(repository.findOrphans(List.of(1L))).isEmpty(); + } + // A new table with user rows must be in the purge list, or "Delete my account" leaves its rows behind. @Test void tables_CoverEveryTableWithAUserId() { diff --git a/src/test/java/core_service/modules/library/domain/GameNamesTest.java b/src/test/java/core_service/modules/library/domain/GameNamesTest.java new file mode 100644 index 0000000..7f3def0 --- /dev/null +++ b/src/test/java/core_service/modules/library/domain/GameNamesTest.java @@ -0,0 +1,15 @@ +package core_service.modules.library.domain; + +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GameNamesTest extends AbstractUnitTest { + + @Test + void normalize_DropsCaseAccentsTrademarksAndPunctuation() { + assertThat(GameNames.normalize(" Pokémon™: Let's Go, Pikachu!® ")).isEqualTo("pokemon let s go pikachu"); + assertThat(GameNames.normalize("The Witcher 3: Wild Hunt")).isEqualTo(GameNames.normalize("The Witcher® 3 - Wild Hunt")); + } +} diff --git a/src/test/java/core_service/modules/library/service/SpeedrunMatchTest.java b/src/test/java/core_service/modules/library/service/SpeedrunMatchTest.java index c085067..6632db9 100644 --- a/src/test/java/core_service/modules/library/service/SpeedrunMatchTest.java +++ b/src/test/java/core_service/modules/library/service/SpeedrunMatchTest.java @@ -18,12 +18,6 @@ class SpeedrunMatchTest extends AbstractUnitTest { private static final SpeedrunApiService.Game PREY_2006 = hit("prey", "Prey", 2006); private static final SpeedrunApiService.Game PREY_2017 = hit("prey2017", "Prey (2017)", 2017); - @Test - void normalize_DropsCaseAccentsTrademarksAndPunctuation() { - assertThat(SpeedrunMatch.normalize(" Pokémon™: Let's Go, Pikachu!® ")).isEqualTo("pokemon let s go pikachu"); - assertThat(SpeedrunMatch.normalize("The Witcher 3: Wild Hunt")).isEqualTo(SpeedrunMatch.normalize("The Witcher® 3 - Wild Hunt")); - } - @Test void candidates_YearSuffixTellsTheRemakeApart() { assertThat(SpeedrunMatch.candidates("Prey", Set.of(2017), List.of(PREY_2006, PREY_2017))).containsExactly(PREY_2017); diff --git a/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceCacheTest.java b/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceCacheTest.java index 5eca018..8fb9793 100644 --- a/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceCacheTest.java +++ b/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceCacheTest.java @@ -108,7 +108,7 @@ class SteamApiServiceCacheTest extends AbstractIntegrationTest { @Test void getOwnedGames_TransientSteamError_IsTriedAgain() { - SteamOwnedGamesResponse games = new SteamOwnedGamesResponse(new SteamOwnedGamesResponse.OwnedGamesResponse(1, + SteamOwnedGamesResponse games = new SteamOwnedGamesResponse(new SteamOwnedGamesResponse.OwnedGamesResponse( List.of(new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 60, null, true, 0)))); when(steamRestClient.get().uri(any(Function.class)).retrieve().body(SteamOwnedGamesResponse.class)) .thenAnswer(invocation -> { diff --git a/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java b/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java index b5b054d..a52ca0d 100644 --- a/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java +++ b/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java @@ -45,27 +45,6 @@ class SteamApiServiceTest { ReflectionTestUtils.setField(steamApiService, "apiKey", "test-api-key"); } - @Test - void getOwnedGamesCount_shouldReturnCount() { - String steamId = "76561197960435530"; - String jsonResponse = """ - { - "response": { - "game_count": 42 - } - } - """; - - server.expect(requestTo(containsString("/IPlayerService/GetOwnedGames/v1/"))) - .andExpect(queryParam("steamid", steamId)) - .andExpect(queryParam("key", "test-api-key")) - .andRespond(withSuccess(jsonResponse, MediaType.APPLICATION_JSON)); - - int result = steamApiService.getOwnedGamesCount(steamId); - - assertThat(result).isEqualTo(42); - } - @Test void getPlayerSummary_shouldReturnPlayer() { String steamId = "76561197960435530"; @@ -135,15 +114,6 @@ class SteamApiServiceTest { assertThat(steamApiService.getOwnedGames("76561197960435530")).hasSize(1); } - @Test - void getOwnedGamesCount_shouldCountUnvettedApps() { - server.expect(requestTo(containsString("/IPlayerService/GetOwnedGames/v1/"))) - .andExpect(queryParam("skip_unvetted_apps", "0")) - .andRespond(withSuccess("{\"response\":{\"game_count\":1}}", MediaType.APPLICATION_JSON)); - - assertThat(steamApiService.getOwnedGamesCount("76561197960435530")).isEqualTo(1); - } - @Test void getOwnedGames_shouldReturnGamesEvenWithNullValues() { String steamId = "76561197960435530"; diff --git a/src/test/java/core_service/shared/test/AbstractWebMvcTest.java b/src/test/java/core_service/shared/test/AbstractWebMvcTest.java deleted file mode 100644 index 99aaf84..0000000 --- a/src/test/java/core_service/shared/test/AbstractWebMvcTest.java +++ /dev/null @@ -1,13 +0,0 @@ -package core_service.shared.test; - -import org.junit.jupiter.api.Tag; -import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; - -/** - * Base class for WebMvc tests. - * Slices the web layer. - */ -@Tag("WebMvcTest") -@WebMvcTest -public abstract class AbstractWebMvcTest { -}