Public Access
feat(images): repair reported covers and achievement icons at night
This commit is contained in:
1 parent
a389bdd0c7
commit
246e255058
30 files changed
+1138
-28
No files matched your search
+7
-1
@@ -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 {
|
||||
|
||||
|
||||
Reference in new issue
Block a user