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

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