feat(images): repair reported covers and achievement icons at night
Build, Test & Push Core Service / quality-check (push) Successful in 56s
Build, Test & Push Core Service / docker (push) Successful in 1m24s

This commit is contained in:
lobo committed 2026-09-26 12:07:07 +02:00
1 parent a389bdd0c7
commit 246e255058
30 files changed
+1138 -28

No files matched your search

+11 -1
View File
@@ -32,6 +32,11 @@ To Be Played is split into three repositories:
- **Playtime history**: one row per game and day, for the activity view and the "most played in the last two weeks"
dashboard banner.
- **Wishlist**: synced from Steam with store price and discount.
- **Broken image repair**: the frontend reports a Steam cover or achievement icon that does not load. At 01:30
(Europe/Berlin) a job checks up to 200 reports. A cover that loads again is left alone; else the store's hashed
header picture (`IStoreBrowseService/GetItems` with assets) goes to `steam_game.image_url`. When a few achievement
icons do not load, the schema comes fresh from Steam; an empty answer keeps the stored one. A report that cannot be repaired is tried again on the next report, at most 3 times,
then after 30 days.
- **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the
feature is off and the rest works.
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
@@ -57,6 +62,7 @@ accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` he
| `PUT` | `/api/library/games/{appid}/backlog` | JWT |
| `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT |
| `POST` | `/api/library/sync` | JWT |
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
| `GET` | `/api/library/picker?maxMinutes=&mood=&count=&exclude=` | JWT |
| `GET` | `/api/library/achievements/summary` | JWT |
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT |
@@ -97,6 +103,7 @@ flowchart LR
Browser -->|"tbp_access cookie"| Security
Security -->|"JWKS by kid"| Auth["ToBePlayed-Auth"]
Steam --> SteamApi["Steam Web API"]
Steam -->|"HEAD"| SteamCdn["Steam CDN"]
Igdb --> IgdbApi["IGDB API"]
Igdb -->|"OAuth token"| Twitch["Twitch"]
Speedrun --> SpeedrunApi["speedrun.com API"]
@@ -108,7 +115,7 @@ flowchart LR
|---|---|
| `library` | Sync, backlog, manual games, playtime history, wishlist, achievements, public profile data |
| `stats` | Public numbers for the landing page |
| `steam_adapter` | Steam Web API client |
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
@@ -142,6 +149,9 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
The frontend polls `GET /api/library/sync-status` until the run ends.
At 01:30 `ImageRepairService` repairs the pictures reported through `POST /api/library/broken-images` (table
`broken_image`, one row per game and kind). Reports only count for Steam games the user owns.
### Manual games next to Steam games
A game the user adds by hand comes from IGDB (`ManualGameService`). Steam games and manual games share
@@ -3,6 +3,7 @@ package core_service.modules.library.api;
import core_service.modules.library.api.dto.AchievementSummaryDto;
import core_service.modules.library.api.dto.ActivityDto;
import core_service.modules.library.api.dto.BacklogEntryDto;
import core_service.modules.library.api.dto.BrokenImageReport;
import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.api.dto.LibrarySummaryDto;
import core_service.modules.library.api.dto.PageDto;
@@ -15,6 +16,7 @@ import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.service.BacklogService;
import core_service.modules.library.service.GameDetailService;
import core_service.modules.library.service.ImageRepairService;
import core_service.modules.library.service.LibraryQueryService;
import core_service.modules.library.service.LibrarySyncService;
import core_service.modules.library.service.MoodService;
@@ -64,6 +66,7 @@ public class LibraryController {
private final SteamProfileService steamProfileService;
private final MoodService moodService;
private final SpeedrunService speedrunService;
private final ImageRepairService imageRepairService;
@GetMapping("/games-count")
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
@@ -139,6 +142,16 @@ public class LibraryController {
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")));
}
/**
* 204 also when the report is dropped, so the answer says nothing about the library.
*/
@PostMapping("/broken-images")
public ResponseEntity<Void> reportBrokenImage(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody BrokenImageReport report) {
imageRepairService.report(principal.getIdAsLong(), report.appid(), report.kind());
return ResponseEntity.noContent().build();
}
@PutMapping("/games/{appid}/backlog")
public ResponseEntity<?> updateBacklog(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid,
@@ -10,7 +10,8 @@ import java.util.List;
* @param averageCompletion mean completion in percent over the started games; null when none is started
* @param pendingGames owned games the achievement sync has not fetched yet
* @param hiddenGames games whose achievements Steam hides (private profile or stats)
* @param closest up to 5 started games that are not complete, closest to 100 % first
* @param closest up to 5 started games that are not complete, closest to 100 % first; {@code imageUrl} see
* {@link GameDto#imageUrl()}
*/
public record AchievementSummaryDto(
int unlocked,
@@ -22,5 +23,5 @@ public record AchievementSummaryDto(
List<Game> closest
) {
public record Game(Long appid, String name, int unlocked, int total) {}
public record Game(Long appid, String name, int unlocked, int total, String imageUrl) {}
}
@@ -0,0 +1,6 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.ImageKind;
import jakarta.validation.constraints.NotNull;
public record BrokenImageReport(@NotNull Long appid, @NotNull ImageKind kind) {}
@@ -9,7 +9,7 @@ import core_service.modules.library.domain.GameStatus;
* @param achievementsTotal null when the game has no achievements or they were not fetched yet
* @param achievementsUnlocked null when not fetched yet or hidden by Steam
* @param platform where the user owns a game added by hand, e.g. "PS5"; null for Steam games
* @param imageUrl picture for a game that has no Steam art; null means the Steam CDN has one
* @param imageUrl a game added by hand from IGDB, or a repaired Steam header; null means the fixed Steam CDN header
*/
public record GameDto(
Long appid,
@@ -4,11 +4,13 @@ import core_service.modules.library.domain.GameStatus;
/**
* @param playtime2Weeks Steam's playtime of the last two weeks, in minutes
* @param imageUrl see {@link GameDto#imageUrl()}
*/
public record RecentGameDto(
Long appid,
String name,
int playtimeMinutes,
int playtime2Weeks,
GameStatus status
GameStatus status,
String imageUrl
) {}
@@ -0,0 +1,48 @@
package core_service.modules.library.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Entity
@Table(name = "broken_image")
@IdClass(BrokenImageId.class)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BrokenImage {
@Id
@Column(name = "appid")
private Long appid;
@Id
@Enumerated(EnumType.STRING)
@Column(name = "kind", length = 24)
private ImageKind kind;
@Column(name = "reported_at", nullable = false)
private Instant reportedAt;
@Column(name = "checked_at")
private Instant checkedAt;
@Column(name = "attempts", nullable = false)
private int attempts;
public BrokenImage(Long appid, ImageKind kind, Instant reportedAt, Instant checkedAt, int attempts) {
this.appid = appid;
this.kind = kind;
this.reportedAt = reportedAt;
this.checkedAt = checkedAt;
this.attempts = attempts;
}
}
@@ -0,0 +1,19 @@
package core_service.modules.library.domain;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.io.Serializable;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class BrokenImageId implements Serializable {
private Long appid;
private ImageKind kind;
}
@@ -0,0 +1,8 @@
package core_service.modules.library.domain;
public enum ImageKind {
// The header picture, also used as the cover.
COVER,
// Any achievement icon of the game: they all come from one schema.
ACHIEVEMENT_ICONS
}
@@ -0,0 +1,52 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.BrokenImage;
import core_service.modules.library.domain.BrokenImageId;
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.List;
public interface BrokenImageRepository extends JpaRepository<BrokenImage, BrokenImageId> {
@Modifying
@Transactional
@Query(value = "insert into broken_image (appid, kind, reported_at, attempts) values (:appid, :kind, :reportedAt, 0) " +
"on conflict (appid, kind) do update set reported_at = excluded.reported_at",
nativeQuery = true)
void report(@Param("appid") Long appid, @Param("kind") String kind, @Param("reportedAt") Instant reportedAt);
@Modifying
@Transactional
@Query(value = "delete from broken_image where appid = :appid and kind = :kind and reported_at <= :reportedAt",
nativeQuery = true)
void deleteReportedUpTo(@Param("appid") Long appid, @Param("kind") String kind, @Param("reportedAt") Instant reportedAt);
/**
* Rows reported after their last check. Never checked rows come first, then the oldest check: each report moves
* {@code reported_at}, so it cannot order the queue. A row out of attempts waits for {@code retryBefore}.
*/
@Query(value = "select * from broken_image " +
"where (checked_at is null or reported_at > checked_at) " +
"and (attempts < :maxAttempts or checked_at < :retryBefore) " +
"order by checked_at nulls first, reported_at limit :limit",
nativeQuery = true)
List<BrokenImage> findDue(@Param("maxAttempts") int maxAttempts, @Param("retryBefore") Instant retryBefore,
@Param("limit") int limit);
/**
* One failed repair. A row that waited out its retry time counts from 1 again.
*/
@Modifying
@Transactional
@Query(value = "update broken_image set checked_at = :checkedAt, " +
"attempts = case when attempts >= :maxAttempts then 1 else attempts + 1 end " +
"where appid = :appid and kind = :kind",
nativeQuery = true)
void markChecked(@Param("appid") Long appid, @Param("kind") String kind, @Param("checkedAt") Instant checkedAt,
@Param("maxAttempts") int maxAttempts);
}
@@ -2,6 +2,15 @@ package core_service.modules.library.repo;
import core_service.modules.library.domain.SteamGame;
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;
public interface SteamGameRepository extends JpaRepository<SteamGame, Long> {
@Modifying
@Transactional
@Query("update SteamGame g set g.imageUrl = :imageUrl where g.appid = :appid")
void setImageUrl(@Param("appid") Long appid, @Param("imageUrl") String imageUrl);
}
@@ -125,6 +125,15 @@ public class AchievementSyncService {
}
}
/**
* Ignores the schema TTL, e.g. after an icon did not load. An empty answer changes nothing: storing it would
* hide the achievements for the whole TTL.
*/
public void refreshSchemaNow(Long appid) throws InterruptedException {
pause();
refreshSchema(appid, true);
}
public void refreshUserIfStale(Long userId, String steamId, Long appid, int playtimeMinutes, Duration ttl) {
Optional<UserGameAchievementMeta> existing = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid);
if (existing.isEmpty() || existing.get().getRefreshedAt().isBefore(Instant.now().minus(ttl))) {
@@ -192,11 +201,16 @@ public class AchievementSyncService {
}
private void refreshSchema(Long appid) {
refreshSchema(appid, false);
}
private void refreshSchema(Long appid, boolean keepOnEmpty) {
Optional<SteamSchemaForGameResponse.Game> schema = steamApiService.getSchemaForGame(appid);
List<SteamSchemaForGameResponse.Achievement> achievements = schema
.map(SteamSchemaForGameResponse.Game::availableGameStats)
.map(SteamSchemaForGameResponse.AvailableGameStats::achievements)
.orElse(List.of());
if (keepOnEmpty && achievements.isEmpty()) return;
writeLocked(0L, appid, () -> {
if (!achievements.isEmpty()) {
schemaRepository.deleteByAppid(appid);
@@ -0,0 +1,207 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.BrokenImage;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GameKeys;
import core_service.modules.library.domain.ImageKind;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.repo.BrokenImageRepository;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.modules.steam_adapter.service.SteamCdnService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Repairs the Steam pictures that browsers reported as broken, at night and only those. A report is only a hint:
* the job checks the picture itself first, so a false report (ad blocker, bad network) changes nothing.
*/
@Service
@Slf4j
public class ImageRepairService {
private final BrokenImageRepository brokenImageRepository;
private final UserGameRepository userGameRepository;
private final SteamGameRepository steamGameRepository;
private final GameAchievementSchemaRepository schemaRepository;
private final GameAchievementSchemaMetaRepository schemaMetaRepository;
private final AchievementSyncService achievementSyncService;
private final SteamApiService steamApiService;
private final SteamCdnService steamCdnService;
private final String steamImageBaseUrl;
private final int batchSize;
private final int maxAttempts;
private final Duration retryAfter;
// Icons of one game change their hashes together, so a few checks tell a false report.
static final int ICONS_TO_CHECK = 5;
public ImageRepairService(BrokenImageRepository brokenImageRepository,
UserGameRepository userGameRepository,
SteamGameRepository steamGameRepository,
GameAchievementSchemaRepository schemaRepository,
GameAchievementSchemaMetaRepository schemaMetaRepository,
AchievementSyncService achievementSyncService,
SteamApiService steamApiService,
SteamCdnService steamCdnService,
@Value("${app.steam.imageService-url}") String steamImageBaseUrl,
@Value("${app.library.image-repair-batch:200}") int batchSize,
@Value("${app.library.image-repair-max-attempts:3}") int maxAttempts,
@Value("${app.library.image-repair-retry-days:30}") long retryDays) {
this.brokenImageRepository = brokenImageRepository;
this.userGameRepository = userGameRepository;
this.steamGameRepository = steamGameRepository;
this.schemaRepository = schemaRepository;
this.schemaMetaRepository = schemaMetaRepository;
this.achievementSyncService = achievementSyncService;
this.steamApiService = steamApiService;
this.steamCdnService = steamCdnService;
this.steamImageBaseUrl = steamImageBaseUrl;
this.batchSize = batchSize;
this.maxAttempts = maxAttempts;
this.retryAfter = Duration.ofDays(retryDays);
}
/**
* Only for a Steam game the user owns; anything else is dropped.
*/
public void report(Long userId, Long appid, ImageKind kind) {
if (!GameKeys.isSteam(appid) || userGameRepository.findActiveAppids(userId, List.of(appid)).isEmpty()) {
return;
}
brokenImageRepository.report(appid, kind.name(), Instant.now());
}
@Scheduled(cron = "${app.library.image-repair-cron:0 30 1 * * *}", zone = "${app.library.zone:Europe/Berlin}")
public void repairNightly() {
Instant startedAt = Instant.now();
List<BrokenImage> due = brokenImageRepository.findDue(maxAttempts, startedAt.minus(retryAfter), batchSize);
if (due.isEmpty()) return;
log.info("Repairing {} reported images", due.size());
List<BrokenImage> covers = due.stream().filter(row -> row.getKind() == ImageKind.COVER).toList();
try {
repairCovers(covers, startedAt);
} catch (Exception e) {
log.warn("Cover repair failed", e);
covers.forEach(row -> markCheckedQuietly(row, startedAt));
}
for (BrokenImage row : due) {
if (row.getKind() != ImageKind.ACHIEVEMENT_ICONS) continue;
try {
repairAchievementIcons(row, startedAt);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
} catch (Exception e) {
log.warn("Achievement icon repair failed for app {}", row.getAppid(), e);
markCheckedQuietly(row, startedAt);
}
}
}
private void repairCovers(List<BrokenImage> rows, Instant startedAt) {
if (rows.isEmpty()) return;
Map<Long, SteamGame> games = steamGameRepository.findAllById(rows.stream().map(BrokenImage::getAppid).toList())
.stream().collect(Collectors.toMap(SteamGame::getAppid, Function.identity()));
List<BrokenImage> broken = new ArrayList<>();
for (BrokenImage row : rows) {
SteamGame game = games.get(row.getAppid());
String current = game != null && game.getImageUrl() != null
? game.getImageUrl() : steamImageBaseUrl + row.getAppid() + "/header.jpg";
if (steamCdnService.isAvailable(current)) {
delete(row);
} else {
broken.add(row);
}
}
if (broken.isEmpty()) return;
Map<Long, String> storeHeaders = steamApiService.getStoreHeaderUrls(broken.stream().map(BrokenImage::getAppid).toList());
for (BrokenImage row : broken) {
try {
String url = storeHeaders.get(row.getAppid());
if (url != null && steamCdnService.isAvailable(url)) {
steamGameRepository.setImageUrl(row.getAppid(), url);
delete(row);
} else {
markChecked(row, startedAt);
}
} catch (Exception e) {
log.warn("Cover repair failed for app {}", row.getAppid(), e);
markCheckedQuietly(row, startedAt);
}
}
}
/**
* The icon URLs come from the schema. A schema refreshed after the report already has the new ones; else the
* repair asks Steam again and counts as done when any URL changed.
*/
private void repairAchievementIcons(BrokenImage row, Instant startedAt) throws InterruptedException {
Long appid = row.getAppid();
Optional<GameAchievementSchemaMeta> meta = schemaMetaRepository.findById(appid);
if (meta.isPresent() && meta.get().getRefreshedAt().isAfter(row.getReportedAt())) {
delete(row);
return;
}
Set<String> before = iconUrls(appid);
List<String> sample = before.stream().filter(Objects::nonNull).limit(ICONS_TO_CHECK).toList();
if (!sample.isEmpty() && sample.stream().allMatch(steamCdnService::isAvailable)) {
delete(row);
return;
}
achievementSyncService.refreshSchemaNow(appid);
if (iconUrls(appid).equals(before)) {
markChecked(row, startedAt);
} else {
delete(row);
}
}
private Set<String> iconUrls(Long appid) {
Set<String> urls = new LinkedHashSet<>();
for (GameAchievementSchema achievement : schemaRepository.findByAppid(appid)) {
urls.add(achievement.getIconUrl());
urls.add(achievement.getIconGrayUrl());
}
return urls;
}
/**
* Keeps a row that was reported again during the run.
*/
private void delete(BrokenImage row) {
brokenImageRepository.deleteReportedUpTo(row.getAppid(), row.getKind().name(), row.getReportedAt());
}
/**
* Checked at the start of the run, so a report during the run makes the row due again.
*/
private void markChecked(BrokenImage row, Instant startedAt) {
brokenImageRepository.markChecked(row.getAppid(), row.getKind().name(), startedAt, maxAttempts);
}
private void markCheckedQuietly(BrokenImage row, Instant startedAt) {
try {
markChecked(row, startedAt);
} catch (Exception e) {
log.warn("Could not store the failed repair of app {}", row.getAppid(), e);
}
}
}
@@ -130,7 +130,8 @@ public class LibraryQueryService {
closest.stream()
.map(g -> new AchievementSummaryDto.Game(g.appid(),
names.containsKey(g.appid()) ? names.get(g.appid()).getName() : null,
g.unlockedCapped(), g.total()))
g.unlockedCapped(), g.total(),
names.containsKey(g.appid()) ? names.get(g.appid()).getImageUrl() : null))
.toList());
}
@@ -184,7 +185,8 @@ public class LibraryQueryService {
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null,
ug.getPlaytimeMinutes(),
ug.getPlaytime2Weeks(),
ug.getStatus()))
ug.getStatus(),
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getImageUrl() : null))
.toList();
return new ActivityDto(perDay, total, recentGames);
}
@@ -16,7 +16,7 @@ public record PublicStatsDto(
List<TopGame> topPlayedGames
) {
/**
* @param imageUrl picture for a game that has no Steam art, see GameDto
* @param imageUrl see GameDto
*/
public record TopGame(Long appid, String name, String imgIconUrl, String imageUrl, long value) {}
}
@@ -18,13 +18,25 @@ public record SteamStoreItemsResponse(
/**
* @param success 1 for a known app
* @param bestPurchaseOption null for an app without a price, e.g. not released yet
* @param assets only sent when the request asks for them
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record StoreItem(
@JsonProperty("appid") Long appid,
@JsonProperty("success") Integer success,
@JsonProperty("name") String name,
@JsonProperty("best_purchase_option") PurchaseOption bestPurchaseOption
@JsonProperty("best_purchase_option") PurchaseOption bestPurchaseOption,
@JsonProperty("assets") Assets assets
) {}
/**
* @param assetUrlFormat e.g. {@code steam/apps/620/${FILENAME}?t=1776125736}
* @param header e.g. {@code 7983574d464e6559ac7e24275727f73a8bcca1f3/header.jpg}
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Assets(
@JsonProperty("asset_url_format") String assetUrlFormat,
@JsonProperty("header") String header
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -3,8 +3,11 @@ package core_service.modules.steam_adapter.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
import java.time.Duration;
@Configuration
public class SteamClientConfig {
@Bean
@@ -13,4 +16,14 @@ public class SteamClientConfig {
.baseUrl(baseUrl)
.build();
}
@Bean
public RestClient steamCdnRestClient() {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(Duration.ofSeconds(3));
requestFactory.setReadTimeout(Duration.ofSeconds(10));
return RestClient.builder()
.requestFactory(requestFactory)
.build();
}
}
@@ -35,6 +35,9 @@ public class SteamApiService {
private final RestClient steamRestClient;
private static final int STORE_ITEMS_BATCH = 100;
private static final String ASSET_FILENAME = "${FILENAME}";
// The cloudflare "shared" host answers 301 for these paths; akamai serves them.
private static final String STORE_ASSET_HOST = "https://shared.akamai.steamstatic.com/store_item_assets/";
@Value("${app.steam.api-key}")
private String apiKey;
@@ -215,10 +218,46 @@ public class SteamApiService {
return result;
}
/**
* The store's hashed header picture per app. It loads when {@code steam/apps/<appid>/header.jpg} does not.
* Apps without one and failed batches are missing.
*/
@RateLimiter(name = "steamApi")
public Map<Long, String> getStoreHeaderUrls(List<Long> appids) {
Map<Long, String> result = new HashMap<>();
for (int from = 0; from < appids.size(); from += STORE_ITEMS_BATCH) {
List<Long> batch = appids.subList(from, Math.min(from + STORE_ITEMS_BATCH, appids.size()));
for (SteamStoreItemsResponse.StoreItem item : fetchStoreItems(batch, "{\"include_assets\":true}")) {
SteamStoreItemsResponse.Assets assets = item.assets();
if (assets != null && assets.assetUrlFormat() != null && assets.header() != null
&& assets.assetUrlFormat().contains(ASSET_FILENAME)) {
result.put(item.appid(), STORE_ASSET_HOST + assets.assetUrlFormat().replace(ASSET_FILENAME, assets.header()));
}
}
}
return result;
}
private Map<Long, StoreItem> getStoreItemsBatch(List<Long> appids) {
Map<Long, StoreItem> items = new HashMap<>();
for (SteamStoreItemsResponse.StoreItem item : fetchStoreItems(appids, "{}")) {
SteamStoreItemsResponse.PurchaseOption price = item.bestPurchaseOption();
items.put(item.appid(), new StoreItem(
item.name(),
price != null ? price.formattedFinalPrice() : null,
price != null ? price.formattedOriginalPrice() : null,
price != null && price.discountPct() != null ? price.discountPct() : 0));
}
return items;
}
/**
* @return the known apps (success 1); empty when the request failed
*/
private List<SteamStoreItemsResponse.StoreItem> fetchStoreItems(List<Long> appids, String dataRequest) {
String ids = appids.stream().map(id -> "{\"appid\":" + id + "}").collect(Collectors.joining(","));
String inputJson = "{\"ids\":[" + ids + "],\"context\":{\"language\":\"" + storeLanguage
+ "\",\"country_code\":\"" + storeCountry + "\"},\"data_request\":{}}";
+ "\",\"country_code\":\"" + storeCountry + "\"},\"data_request\":" + dataRequest + "}";
try {
SteamStoreItemsResponse response = steamRestClient.get()
.uri(uriBuilder -> uriBuilder
@@ -229,29 +268,21 @@ public class SteamApiService {
.retrieve()
.body(SteamStoreItemsResponse.class);
if (response == null || response.response() == null || response.response().storeItems() == null) {
return Map.of();
return List.of();
}
Map<Long, StoreItem> items = new HashMap<>();
for (SteamStoreItemsResponse.StoreItem item : response.response().storeItems()) {
if (item.appid() == null || !Integer.valueOf(1).equals(item.success())) continue;
SteamStoreItemsResponse.PurchaseOption price = item.bestPurchaseOption();
items.put(item.appid(), new StoreItem(
item.name(),
price != null ? price.formattedFinalPrice() : null,
price != null ? price.formattedOriginalPrice() : null,
price != null && price.discountPct() != null ? price.discountPct() : 0));
}
return items;
return response.response().storeItems().stream()
.filter(item -> item.appid() != null && Integer.valueOf(1).equals(item.success()))
.toList();
} catch (Exception e) {
log.error("Error communicating with Steam API for store items {}", appids, e);
return Map.of();
return List.of();
}
}
/**
* Per-game achievement schema (global, language-aware).
* Returns empty when the game has no achievements or Steam answers 4xx, its final answer for delisted apps and
* tools. A server or network failure throws, so the caller keeps what it stored before.
* tools. A server or network failure, 408 or 429 throws, so the caller keeps what it stored before.
*/
@RateLimiter(name = "steamApi")
@Retry(name = "steamApi")
@@ -269,6 +300,9 @@ public class SteamApiService {
return Optional.of(response.game());
}
} catch (HttpClientErrorException e) {
if (e.getStatusCode().value() == 408 || e.getStatusCode().value() == 429) {
throw e;
}
log.info("No achievement schema for appid {}: {}", appid, e.getStatusCode());
}
return Optional.empty();
@@ -0,0 +1,31 @@
package core_service.modules.steam_adapter.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
@Service
@RequiredArgsConstructor
@Slf4j
public class SteamCdnService {
private final RestClient steamCdnRestClient;
/**
* {@code HEAD} on a Steam CDN picture. A redirect counts as not available: the request does not follow it.
*/
public boolean isAvailable(String url) {
try {
return steamCdnRestClient.head()
.uri(url)
.retrieve()
.toBodilessEntity()
.getStatusCode()
.is2xxSuccessful();
} catch (Exception e) {
log.debug("Steam CDN check failed for {}: {}", url, e.getMessage());
return false;
}
}
}
+5
View File
@@ -61,6 +61,11 @@ app:
achievement-ttl-hours: 1
schema-ttl-days: 30
speedrun-ttl-days: 30
# Reported broken pictures, repaired after the nightly sync.
image-repair-cron: "0 30 1 * * *"
image-repair-batch: 200
image-repair-max-attempts: 3
image-repair-retry-days: 30
resilience4j:
retry:
@@ -0,0 +1,9 @@
-- Pictures a browser could not load. One row per game and kind; a new report only moves reported_at.
CREATE TABLE broken_image (
appid BIGINT NOT NULL,
kind VARCHAR(24) NOT NULL,
reported_at TIMESTAMPTZ NOT NULL,
checked_at TIMESTAMPTZ,
attempts INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (appid, kind)
);
@@ -41,6 +41,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
class LibraryControllerAchievementsTest extends AbstractIntegrationTest {
private static final long USER_ID = 1L;
private static final String REPAIRED_COVER = "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1/x/header.jpg";
@Autowired
private MockMvc mockMvc;
@@ -106,6 +107,9 @@ class LibraryControllerAchievementsTest extends AbstractIntegrationTest {
game(70L, 8, null, true); // unlocks never fetched
game(80L, 10, 12, true); // schema lost achievements, still perfect
game(90L, 6, 0, true); // not started
SteamGame repaired = steamGameRepository.findById(20L).orElseThrow();
repaired.setImageUrl(REPAIRED_COVER);
steamGameRepository.save(repaired);
steamGameRepository.save(new SteamGame(95L, "Game 95", null, Instant.now()));
UserGame removed = new UserGame(USER_ID, 95L, 60, Instant.now(), Instant.now());
removed.setRemovedAt(Instant.now());
@@ -128,7 +132,9 @@ class LibraryControllerAchievementsTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.closest[0].name").value("Game 20"))
.andExpect(jsonPath("$.closest[0].unlocked").value(3))
.andExpect(jsonPath("$.closest[0].total").value(4))
.andExpect(jsonPath("$.closest[1].appid").value(30));
.andExpect(jsonPath("$.closest[0].imageUrl").value(REPAIRED_COVER))
.andExpect(jsonPath("$.closest[1].appid").value(30))
.andExpect(jsonPath("$.closest[1].imageUrl").doesNotExist());
}
@Test
@@ -30,6 +30,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
class LibraryControllerActivityTest extends AbstractIntegrationTest {
private static final long USER_ID = 1L;
private static final String REPAIRED_COVER = "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1/x/header.jpg";
@Autowired
private MockMvc mockMvc;
@@ -52,9 +53,11 @@ class LibraryControllerActivityTest extends AbstractIntegrationTest {
void seed() {
userGameRepository.deleteAll();
jdbc.update("delete from user_game_playtime_day");
SteamGame repairedPortal = new SteamGame(620L, "Portal 2", null, Instant.now());
repairedPortal.setImageUrl(REPAIRED_COVER);
steamGameRepository.saveAll(List.of(
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
new SteamGame(620L, "Portal 2", null, Instant.now()),
repairedPortal,
new SteamGame(700L, "Dota 2", null, Instant.now())));
UserGame hl2 = new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now());
hl2.setPlaytime2Weeks(30);
@@ -83,7 +86,9 @@ class LibraryControllerActivityTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.recentGames[0].appid").value(620))
.andExpect(jsonPath("$.recentGames[0].name").value("Portal 2"))
.andExpect(jsonPath("$.recentGames[0].playtime2Weeks").value(120))
.andExpect(jsonPath("$.recentGames[1].appid").value(220));
.andExpect(jsonPath("$.recentGames[0].imageUrl").value(REPAIRED_COVER))
.andExpect(jsonPath("$.recentGames[1].appid").value(220))
.andExpect(jsonPath("$.recentGames[1].imageUrl").doesNotExist());
}
@Test
@@ -0,0 +1,92 @@
package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.library.domain.BrokenImageId;
import core_service.modules.library.domain.ImageKind;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.repo.BrokenImageRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The dev profile authenticates every request as user 1.
*/
@AutoConfigureMockMvc
class LibraryControllerBrokenImageTest extends AbstractIntegrationTest {
private static final long USER_ID = 1L;
private static final long APPID = 220L;
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private BrokenImageRepository brokenImageRepository;
@BeforeEach
void seed() {
brokenImageRepository.deleteAll();
userGameRepository.deleteAll();
steamGameRepository.save(new SteamGame(APPID, "Half-Life 2", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, APPID, 0, Instant.now(), Instant.now()));
}
@Test
void reportBrokenImage_StoresTheReport() throws Exception {
mockMvc.perform(post("/api/library/broken-images").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"appid\":220,\"kind\":\"ACHIEVEMENT_ICONS\"}"))
.andExpect(status().isNoContent());
assertThat(brokenImageRepository.findById(new BrokenImageId(APPID, ImageKind.ACHIEVEMENT_ICONS))).isPresent();
}
@Test
void reportBrokenImage_ForAGameNotOwnedAnswersTheSameAndStoresNothing() throws Exception {
mockMvc.perform(post("/api/library/broken-images").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"appid\":620,\"kind\":\"COVER\"}"))
.andExpect(status().isNoContent());
assertThat(brokenImageRepository.count()).isZero();
}
@ParameterizedTest
@ValueSource(strings = {"{\"appid\":220,\"kind\":\"BANNER\"}", "{\"appid\":220}", "{\"kind\":\"COVER\"}"})
void reportBrokenImage_BadBodyIs400(String body) throws Exception {
mockMvc.perform(post("/api/library/broken-images").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isBadRequest());
assertThat(brokenImageRepository.count()).isZero();
}
@Test
void reportBrokenImage_WithoutClientHeaderIs403() throws Exception {
mockMvc.perform(post("/api/library/broken-images")
.contentType(MediaType.APPLICATION_JSON).content("{\"appid\":220,\"kind\":\"COVER\"}"))
.andExpect(status().isForbidden());
assertThat(brokenImageRepository.count()).isZero();
}
}
@@ -0,0 +1,117 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.BrokenImage;
import core_service.modules.library.domain.BrokenImageId;
import core_service.modules.library.domain.ImageKind;
import core_service.shared.test.AbstractPersistenceTest;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class BrokenImageRepositoryTest extends AbstractPersistenceTest {
private static final int MAX_ATTEMPTS = 3;
@Autowired
private BrokenImageRepository brokenImageRepository;
@Autowired
private EntityManager entityManager;
private final Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
@BeforeEach
void clean() {
brokenImageRepository.deleteAll();
}
private List<Long> dueAppids(int limit) {
return brokenImageRepository.findDue(MAX_ATTEMPTS, now.minus(Duration.ofDays(30)), limit).stream()
.map(BrokenImage::getAppid).toList();
}
private BrokenImage stored(long appid, ImageKind kind) {
entityManager.clear();
return brokenImageRepository.findById(new BrokenImageId(appid, kind)).orElseThrow();
}
@Test
void report_KeepsOneRowPerGameAndKindAndMovesTheReportTime() {
brokenImageRepository.report(620L, "COVER", now.minus(Duration.ofDays(1)));
brokenImageRepository.markChecked(620L, "COVER", now.minus(Duration.ofHours(12)), MAX_ATTEMPTS);
brokenImageRepository.report(620L, "COVER", now);
brokenImageRepository.report(620L, "ACHIEVEMENT_ICONS", now);
assertThat(brokenImageRepository.count()).isEqualTo(2);
BrokenImage cover = stored(620L, ImageKind.COVER);
assertThat(cover.getReportedAt()).isEqualTo(now);
assertThat(cover.getAttempts()).isEqualTo(1);
}
@Test
void findDue_OldestReportFirstAndOnlyUpToTheLimit() {
brokenImageRepository.report(620L, "COVER", now.minus(Duration.ofHours(1)));
brokenImageRepository.report(220L, "COVER", now.minus(Duration.ofHours(3)));
brokenImageRepository.report(400L, "COVER", now.minus(Duration.ofHours(2)));
assertThat(dueAppids(2)).containsExactly(220L, 400L);
}
@Test
void findDue_NeverCheckedRowsFirstThenTheLongestWaitingCheck() {
brokenImageRepository.report(620L, "COVER", now.minus(Duration.ofDays(5)));
brokenImageRepository.markChecked(620L, "COVER", now.minus(Duration.ofDays(4)), MAX_ATTEMPTS);
brokenImageRepository.report(620L, "COVER", now);
brokenImageRepository.report(220L, "COVER", now.minus(Duration.ofDays(3)));
brokenImageRepository.markChecked(220L, "COVER", now.minus(Duration.ofDays(2)), MAX_ATTEMPTS);
brokenImageRepository.report(220L, "COVER", now.minus(Duration.ofDays(1)));
brokenImageRepository.report(400L, "COVER", now);
assertThat(dueAppids(10)).containsExactly(400L, 620L, 220L);
}
@Test
void deleteReportedUpTo_KeepsARowReportedAgain() {
brokenImageRepository.report(620L, "COVER", now.minus(Duration.ofHours(1)));
brokenImageRepository.report(620L, "COVER", now);
brokenImageRepository.deleteReportedUpTo(620L, "COVER", now.minus(Duration.ofHours(1)));
assertThat(brokenImageRepository.count()).isEqualTo(1);
brokenImageRepository.deleteReportedUpTo(620L, "COVER", now);
assertThat(brokenImageRepository.count()).isZero();
}
@Test
void findDue_ACheckedRowWaitsForTheNextReport() {
brokenImageRepository.report(620L, "COVER", now.minus(Duration.ofHours(2)));
brokenImageRepository.markChecked(620L, "COVER", now.minus(Duration.ofHours(1)), MAX_ATTEMPTS);
assertThat(dueAppids(10)).isEmpty();
brokenImageRepository.report(620L, "COVER", now);
assertThat(dueAppids(10)).containsExactly(620L);
}
@Test
void findDue_AfterTheLastAttemptARowWaitsForTheRetryTimeAndStartsAgain() {
brokenImageRepository.report(620L, "COVER", now.minus(Duration.ofDays(40)));
for (int i = 0; i < MAX_ATTEMPTS; i++) {
brokenImageRepository.markChecked(620L, "COVER", now.minus(Duration.ofDays(10)), MAX_ATTEMPTS);
}
brokenImageRepository.report(620L, "COVER", now);
assertThat(dueAppids(10)).isEmpty();
brokenImageRepository.markChecked(620L, "COVER", now.minus(Duration.ofDays(31)), MAX_ATTEMPTS);
assertThat(stored(620L, ImageKind.COVER).getAttempts()).isEqualTo(1);
brokenImageRepository.report(620L, "COVER", now);
assertThat(dueAppids(10)).containsExactly(620L);
}
}
@@ -173,6 +173,16 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
verify(steamApiService, never()).getPlayerAchievements(anyString(), anyLong());
}
@Test
void refreshSchemaNow_EmptyAnswer_KeepsTheStoredSchema() throws Exception {
when(steamApiService.getSchemaForGame(620L)).thenReturn(Optional.empty());
service.refreshSchemaNow(620L);
verify(schemaMetaRepository, never()).save(any());
verify(schemaRepository, never()).deleteByAppid(anyLong());
}
@Test
void syncLibrary_OldSchema_IsFetchedAgain() {
GameAchievementSchemaMeta old = new GameAchievementSchemaMeta(620L, 10, true, Instant.now().minus(Duration.ofDays(31)));
@@ -0,0 +1,284 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.BrokenImage;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.ImageKind;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.repo.BrokenImageRepository;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.modules.steam_adapter.service.SteamCdnService;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
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 ImageRepairServiceTest extends AbstractUnitTest {
private static final String BASE = "https://cdn.cloudflare.steamstatic.com/steam/apps/";
private static final String FIXED_620 = BASE + "620/header.jpg";
private static final String HASHED_620 = "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/620/abc/header.jpg?t=1";
@Mock
private BrokenImageRepository brokenImageRepository;
@Mock
private UserGameRepository userGameRepository;
@Mock
private SteamGameRepository steamGameRepository;
@Mock
private GameAchievementSchemaRepository schemaRepository;
@Mock
private GameAchievementSchemaMetaRepository schemaMetaRepository;
@Mock
private AchievementSyncService achievementSyncService;
@Mock
private SteamApiService steamApiService;
@Mock
private SteamCdnService steamCdnService;
private ImageRepairService service;
@BeforeEach
void setUp() {
service = new ImageRepairService(brokenImageRepository, userGameRepository, steamGameRepository, schemaRepository,
schemaMetaRepository, achievementSyncService, steamApiService, steamCdnService, BASE, 200, 3, 30);
}
private void due(BrokenImage... rows) {
when(brokenImageRepository.findDue(eq(3), any(), eq(200))).thenReturn(List.of(rows));
}
private static BrokenImage row(long appid, ImageKind kind, Instant reportedAt) {
return new BrokenImage(appid, kind, reportedAt, null, 0);
}
private static GameAchievementSchema icon(long appid, String apiName, String url) {
return new GameAchievementSchema(appid, apiName, apiName, null, url, url + "_gray", false);
}
@Test
void report_StoresAReportForAnOwnedSteamGame() {
when(userGameRepository.findActiveAppids(1L, List.of(620L))).thenReturn(List.of(620L));
service.report(1L, 620L, ImageKind.COVER);
verify(brokenImageRepository).report(eq(620L), eq("COVER"), any());
}
@Test
void report_DropsGamesTheUserDoesNotOwn() {
when(userGameRepository.findActiveAppids(1L, List.of(620L))).thenReturn(List.of());
service.report(1L, 620L, ImageKind.COVER);
verify(brokenImageRepository, never()).report(anyLong(), anyString(), any());
}
@Test
void report_DropsGamesOffSteam() {
service.report(1L, -1234L, ImageKind.COVER);
verifyNoInteractions(userGameRepository, brokenImageRepository);
}
@Test
void repairNightly_DeletesAFalseCoverReportWithoutAskingTheStore() {
due(row(620L, ImageKind.COVER, Instant.now()));
when(steamCdnService.isAvailable(FIXED_620)).thenReturn(true);
service.repairNightly();
verify(brokenImageRepository).deleteReportedUpTo(eq(620L), eq("COVER"), any());
verifyNoInteractions(steamApiService);
verify(steamGameRepository, never()).setImageUrl(anyLong(), anyString());
}
@Test
void repairNightly_StoresTheHashedStoreHeaderWhenTheFixedOneIsGone() {
due(row(620L, ImageKind.COVER, Instant.now()));
when(steamCdnService.isAvailable(FIXED_620)).thenReturn(false);
when(steamApiService.getStoreHeaderUrls(List.of(620L))).thenReturn(Map.of(620L, HASHED_620));
when(steamCdnService.isAvailable(HASHED_620)).thenReturn(true);
service.repairNightly();
verify(steamGameRepository).setImageUrl(620L, HASHED_620);
verify(brokenImageRepository).deleteReportedUpTo(eq(620L), eq("COVER"), any());
}
@Test
void repairNightly_ChecksTheStoredRepairFirst() {
due(row(620L, ImageKind.COVER, Instant.now()));
SteamGame game = new SteamGame(620L, "Portal 2", null, Instant.now());
game.setImageUrl(HASHED_620);
when(steamGameRepository.findAllById(List.of(620L))).thenReturn(List.of(game));
when(steamCdnService.isAvailable(HASHED_620)).thenReturn(true);
service.repairNightly();
verify(steamCdnService, never()).isAvailable(FIXED_620);
verify(brokenImageRepository).deleteReportedUpTo(eq(620L), eq("COVER"), any());
}
@Test
void repairNightly_CountsAnAttemptWhenTheStoreHasNoHeader() {
due(row(620L, ImageKind.COVER, Instant.now()));
when(steamApiService.getStoreHeaderUrls(List.of(620L))).thenReturn(Map.of());
service.repairNightly();
verify(brokenImageRepository).markChecked(eq(620L), eq("COVER"), any(), eq(3));
verify(brokenImageRepository, never()).deleteReportedUpTo(anyLong(), anyString(), any());
verify(steamGameRepository, never()).setImageUrl(anyLong(), anyString());
}
@Test
void repairNightly_CountsAnAttemptWhenTheStoreHeaderIsBrokenToo() {
due(row(620L, ImageKind.COVER, Instant.now()));
when(steamApiService.getStoreHeaderUrls(List.of(620L))).thenReturn(Map.of(620L, HASHED_620));
service.repairNightly();
verify(brokenImageRepository).markChecked(eq(620L), eq("COVER"), any(), eq(3));
verify(steamGameRepository, never()).setImageUrl(anyLong(), anyString());
}
@Test
void repairNightly_DeletesTheIconReportWhenTheRefreshBringsNewUrls() throws Exception {
Instant reported = Instant.now().minus(Duration.ofHours(2));
due(row(620L, ImageKind.ACHIEVEMENT_ICONS, reported));
when(schemaMetaRepository.findById(620L)).thenReturn(Optional.of(
new GameAchievementSchemaMeta(620L, 1, true, reported.minus(Duration.ofDays(3)))));
when(schemaRepository.findByAppid(620L))
.thenReturn(List.of(icon(620L, "A", "old")))
.thenReturn(List.of(icon(620L, "A", "new")));
service.repairNightly();
verify(achievementSyncService).refreshSchemaNow(620L);
verify(brokenImageRepository).deleteReportedUpTo(eq(620L), eq("ACHIEVEMENT_ICONS"), any());
}
@Test
void repairNightly_CountsAnAttemptWhenTheIconsStayTheSame() throws Exception {
due(row(620L, ImageKind.ACHIEVEMENT_ICONS, Instant.now()));
when(schemaRepository.findByAppid(620L)).thenReturn(List.of(icon(620L, "A", "same")));
service.repairNightly();
verify(achievementSyncService).refreshSchemaNow(620L);
verify(brokenImageRepository).markChecked(eq(620L), eq("ACHIEVEMENT_ICONS"), any(), eq(3));
verify(brokenImageRepository, never()).deleteReportedUpTo(anyLong(), anyString(), any());
}
@Test
void repairNightly_DoesNotAskSteamWhenTheSchemaIsNewerThanTheReport() throws Exception {
Instant reported = Instant.now().minus(Duration.ofHours(5));
due(row(620L, ImageKind.ACHIEVEMENT_ICONS, reported));
when(schemaMetaRepository.findById(620L)).thenReturn(Optional.of(
new GameAchievementSchemaMeta(620L, 1, true, reported.plus(Duration.ofHours(1)))));
service.repairNightly();
verify(achievementSyncService, never()).refreshSchemaNow(anyLong());
verify(brokenImageRepository).deleteReportedUpTo(eq(620L), eq("ACHIEVEMENT_ICONS"), any());
}
@Test
void repairNightly_ASteamFailureCountsAnAttemptAndTheNextRowStillRuns() throws Exception {
due(row(620L, ImageKind.ACHIEVEMENT_ICONS, Instant.now()), row(220L, ImageKind.ACHIEVEMENT_ICONS, Instant.now()));
doThrow(new IllegalStateException("Steam 500")).when(achievementSyncService).refreshSchemaNow(620L);
when(schemaRepository.findByAppid(620L)).thenReturn(List.of(icon(620L, "A", "same")));
when(schemaRepository.findByAppid(220L))
.thenReturn(List.of(icon(220L, "A", "old")))
.thenReturn(List.of(icon(220L, "A", "new")));
service.repairNightly();
verify(brokenImageRepository).markChecked(eq(620L), eq("ACHIEVEMENT_ICONS"), any(), anyInt());
verify(brokenImageRepository).deleteReportedUpTo(eq(220L), eq("ACHIEVEMENT_ICONS"), any());
}
@Test
void repairNightly_DeletesAFalseIconReportWithoutAskingSteam() throws Exception {
due(row(620L, ImageKind.ACHIEVEMENT_ICONS, Instant.now()));
when(schemaRepository.findByAppid(620L)).thenReturn(List.of(icon(620L, "A", "https://x/a.jpg")));
when(steamCdnService.isAvailable(anyString())).thenReturn(true);
service.repairNightly();
verify(achievementSyncService, never()).refreshSchemaNow(anyLong());
verify(brokenImageRepository).deleteReportedUpTo(eq(620L), eq("ACHIEVEMENT_ICONS"), any());
}
@Test
void repairNightly_ASchemaWithoutIconsIsNoFalseReport() throws Exception {
due(row(620L, ImageKind.ACHIEVEMENT_ICONS, Instant.now()));
when(schemaRepository.findByAppid(620L)).thenReturn(List.of());
service.repairNightly();
verify(achievementSyncService).refreshSchemaNow(620L);
verifyNoInteractions(steamCdnService);
}
@Test
void repairNightly_AFailedCoverWriteCountsAnAttempt() {
due(row(620L, ImageKind.COVER, Instant.now()));
when(steamApiService.getStoreHeaderUrls(List.of(620L))).thenReturn(Map.of(620L, HASHED_620));
when(steamCdnService.isAvailable(HASHED_620)).thenReturn(true);
doThrow(new IllegalStateException("db down")).when(steamGameRepository).setImageUrl(620L, HASHED_620);
service.repairNightly();
verify(brokenImageRepository).markChecked(eq(620L), eq("COVER"), any(), eq(3));
}
@Test
void repairNightly_MarksTheRunStartSoAReportDuringTheRunStaysDue() {
due(row(620L, ImageKind.COVER, Instant.now()));
when(steamApiService.getStoreHeaderUrls(List.of(620L))).thenReturn(Map.of());
Instant before = Instant.now();
service.repairNightly();
org.mockito.ArgumentCaptor<Instant> checkedAt = org.mockito.ArgumentCaptor.forClass(Instant.class);
verify(brokenImageRepository).markChecked(eq(620L), eq("COVER"), checkedAt.capture(), eq(3));
verify(brokenImageRepository).findDue(eq(3), eq(checkedAt.getValue().minus(Duration.ofDays(30))), eq(200));
org.assertj.core.api.Assertions.assertThat(checkedAt.getValue()).isAfterOrEqualTo(before);
}
@Test
void repairNightly_StopsWhenInterrupted() throws Exception {
due(row(620L, ImageKind.ACHIEVEMENT_ICONS, Instant.now()), row(220L, ImageKind.ACHIEVEMENT_ICONS, Instant.now()));
doAnswer(call -> { throw new InterruptedException(); }).when(achievementSyncService).refreshSchemaNow(620L);
service.repairNightly();
verify(achievementSyncService, never()).refreshSchemaNow(220L);
// Clear the flag for the next test on this thread.
org.assertj.core.api.Assertions.assertThat(Thread.interrupted()).isTrue();
}
}
@@ -6,6 +6,8 @@ import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryRes
import core_service.modules.steam_adapter.api.dto.response.SteamProfileAndGamesResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
@@ -282,6 +284,34 @@ class SteamApiServiceTest {
server.verify();
}
@Test
void getStoreHeaderUrls_BuildsTheHashedUrlAndSkipsAppsWithoutAssets() {
server.expect(requestTo(containsString("/IStoreBrowseService/GetItems/v1/")))
.andExpect(queryParam("input_json", containsString("%22data_request%22%3A%7B%22include_assets%22%3Atrue%7D")))
.andRespond(withSuccess("""
{"response": {"store_items": [
{"appid": 620, "success": 1,
"assets": {"asset_url_format": "steam/apps/620/${FILENAME}?t=1776125736",
"header": "7983574d464e6559ac7e24275727f73a8bcca1f3/header.jpg"}},
{"appid": 3012600, "success": 1},
{"appid": 42, "success": 1, "assets": {"asset_url_format": "steam/apps/42/${FILENAME}"}}
]}}
""", MediaType.APPLICATION_JSON));
Map<Long, String> urls = steamApiService.getStoreHeaderUrls(List.of(620L, 3012600L, 42L));
assertThat(urls).containsOnly(Map.entry(620L,
"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/620/7983574d464e6559ac7e24275727f73a8bcca1f3/header.jpg?t=1776125736"));
}
@Test
void getStoreHeaderUrls_ServerError_IsEmpty() {
server.expect(requestTo(containsString("/IStoreBrowseService/GetItems/v1/")))
.andRespond(withServerError());
assertThat(steamApiService.getStoreHeaderUrls(List.of(620L))).isEmpty();
}
@Test
void getPlayerAchievements_Forbidden_IsEmpty() {
server.expect(requestTo(containsString("/ISteamUserStats/GetPlayerAchievements/v1/")))
@@ -327,6 +357,15 @@ class SteamApiServiceTest {
assertThat(stats.get().achievements()).isEmpty();
}
@ParameterizedTest
@ValueSource(ints = {408, 429})
void getSchemaForGame_TooManyRequestsOrTimeout_Throws(int status) {
server.expect(requestTo(containsString("/ISteamUserStats/GetSchemaForGame/v2/")))
.andRespond(withStatus(HttpStatus.valueOf(status)));
assertThatThrownBy(() -> steamApiService.getSchemaForGame(620)).isInstanceOf(RestClientException.class);
}
@Test
void getSchemaForGame_ClientError_IsEmpty() {
server.expect(requestTo(containsString("/ISteamUserStats/GetSchemaForGame/v2/")))
@@ -0,0 +1,62 @@
package core_service.modules.steam_adapter.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import java.io.IOException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withException;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class SteamCdnServiceTest {
private static final String URL = "https://cdn.cloudflare.steamstatic.com/steam/apps/620/header.jpg";
private SteamCdnService steamCdnService;
private MockRestServiceServer server;
@BeforeEach
void setUp() {
RestClient.Builder builder = RestClient.builder();
server = MockRestServiceServer.bindTo(builder).build();
steamCdnService = new SteamCdnService(builder.build());
}
@Test
void isAvailable_OkIsTrue() {
server.expect(requestTo(URL)).andExpect(method(HttpMethod.HEAD)).andRespond(withSuccess());
assertThat(steamCdnService.isAvailable(URL)).isTrue();
}
@Test
void isAvailable_NotFoundIsFalse() {
server.expect(requestTo(URL)).andRespond(withStatus(HttpStatus.NOT_FOUND));
assertThat(steamCdnService.isAvailable(URL)).isFalse();
}
@Test
void isAvailable_RedirectIsFalse() {
server.expect(requestTo(URL)).andRespond(withStatus(HttpStatus.MOVED_PERMANENTLY)
.header(HttpHeaders.LOCATION, "https://example.com/"));
assertThat(steamCdnService.isAvailable(URL)).isFalse();
}
@Test
void isAvailable_NetworkErrorIsFalse() {
server.expect(requestTo(URL)).andRespond(withException(new IOException("timeout")));
assertThat(steamCdnService.isAvailable(URL)).isFalse();
}
}
@@ -20,7 +20,7 @@ import java.util.Base64;
@Tag("IntegrationTest")
// The nightly sync would call Steam if a test ran at 23:55; speedrun.com needs no key, so it is switched off.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"app.library.nightly-sync-cron=-", "app.speedrun.enabled=false"})
properties = {"app.library.nightly-sync-cron=-", "app.library.image-repair-cron=-", "app.speedrun.enabled=false"})
@ActiveProfiles("dev")
public abstract class AbstractIntegrationTest {