diff --git a/src/main/java/core_service/configuration/CacheConfig.java b/src/main/java/core_service/configuration/CacheConfig.java index bfad2c0..219f0f2 100644 --- a/src/main/java/core_service/configuration/CacheConfig.java +++ b/src/main/java/core_service/configuration/CacheConfig.java @@ -6,16 +6,18 @@ import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.caffeine.CaffeineCacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; import java.util.concurrent.TimeUnit; @Configuration -@EnableCaching +// Outside the resilience4j aspects, so a cache hit takes no Steam rate limiter permit. +@EnableCaching(order = Ordered.HIGHEST_PRECEDENCE) public class CacheConfig { @Bean public CacheManager cacheManager() { - CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount", "publicStats"); + CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount", "publicStats", "steamPlayerSummary"); cacheManager.setCaffeine(Caffeine.newBuilder() .expireAfterWrite(10, TimeUnit.MINUTES) .maximumSize(1000)); diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index 0c4e12c..1e484a6 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -5,6 +5,7 @@ import core_service.modules.library.api.dto.BacklogEntryDto; import core_service.modules.library.api.dto.GameDto; import core_service.modules.library.api.dto.LibrarySummaryDto; import core_service.modules.library.api.dto.PageDto; +import core_service.modules.library.api.dto.SteamProfileDto; import core_service.modules.library.api.dto.SyncStatusDto; import core_service.modules.library.api.dto.WishlistDto; import core_service.modules.library.domain.GameStatus; @@ -12,6 +13,7 @@ import core_service.modules.library.service.BacklogService; import core_service.modules.library.service.GameDetailService; import core_service.modules.library.service.LibraryQueryService; import core_service.modules.library.service.LibrarySyncService; +import core_service.modules.library.service.SteamProfileService; import core_service.modules.library.service.SyncCooldownException; import core_service.shared.security.UserPrincipal; import jakarta.validation.Valid; @@ -49,6 +51,7 @@ public class LibraryController { private final LibraryQueryService queryService; private final GameDetailService gameDetailService; private final BacklogService backlogService; + private final SteamProfileService steamProfileService; @GetMapping("/games-count") public ResponseEntity getGamesCount(@AuthenticationPrincipal UserPrincipal principal) { @@ -166,4 +169,9 @@ public class LibraryController { .map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.ok(new SyncStatusDto(null, null))); } + + @GetMapping("/steam-profile") + public ResponseEntity getSteamProfile(@AuthenticationPrincipal UserPrincipal principal) { + return ResponseEntity.ok(steamProfileService.getProfile(principal.steamId())); + } } diff --git a/src/main/java/core_service/modules/library/api/dto/SteamProfileDto.java b/src/main/java/core_service/modules/library/api/dto/SteamProfileDto.java new file mode 100644 index 0000000..a8698b6 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/SteamProfileDto.java @@ -0,0 +1,9 @@ +package core_service.modules.library.api.dto; + +/** + * The user's Steam name and avatar; both are null without a Steam ID or when Steam does not answer. + */ +public record SteamProfileDto( + String personaName, + String avatarUrl +) {} diff --git a/src/main/java/core_service/modules/library/service/SteamProfileService.java b/src/main/java/core_service/modules/library/service/SteamProfileService.java new file mode 100644 index 0000000..b6e628e --- /dev/null +++ b/src/main/java/core_service/modules/library/service/SteamProfileService.java @@ -0,0 +1,24 @@ +package core_service.modules.library.service; + +import core_service.modules.library.api.dto.SteamProfileDto; +import core_service.modules.steam_adapter.service.SteamApiService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class SteamProfileService { + + private static final SteamProfileDto NONE = new SteamProfileDto(null, null); + + private final SteamApiService steamApiService; + + public SteamProfileDto getProfile(Long steamId) { + if (steamId == null) return NONE; + return steamApiService.getPlayerSummary(String.valueOf(steamId)) + // The frontend puts the avatar into an img src, so only an https URL passes. + .map(player -> new SteamProfileDto(player.personaname(), + player.avatarfull() != null && player.avatarfull().startsWith("https://") ? player.avatarfull() : null)) + .orElse(NONE); + } +} diff --git a/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java b/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java index 6c1a5e0..74ec784 100644 --- a/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java +++ b/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java @@ -102,6 +102,8 @@ public class SteamApiService { return Optional.of(new SteamProfileAndGamesResponse(playerOpt.get(), games)); } + // Asked on every page load for the avatar in the header. + @Cacheable(value = "steamPlayerSummary", key = "#steamId", unless = "#result == null") @RateLimiter(name = "steamApi") @Retry(name = "steamApi") public Optional getPlayerSummary(String steamId) { diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerSteamProfileTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerSteamProfileTest.java new file mode 100644 index 0000000..fb68dcb --- /dev/null +++ b/src/test/java/core_service/modules/library/api/LibraryControllerSteamProfileTest.java @@ -0,0 +1,52 @@ +package core_service.modules.library.api; + +import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse; +import core_service.modules.steam_adapter.service.SteamApiService; +import core_service.shared.test.AbstractIntegrationTest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Optional; + +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The dev profile authenticates every request as user 1 with Steam ID 76561198000000001. + */ +@AutoConfigureMockMvc +class LibraryControllerSteamProfileTest extends AbstractIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private SteamApiService steamApiService; + + @Test + void steamProfile_SendsNameAndAvatar() throws Exception { + when(steamApiService.getPlayerSummary("76561198000000001")).thenReturn(Optional.of(new SteamPlayerSummaryResponse.Player( + "76561198000000001", "Lobo", null, "https://avatars.steamstatic.com/abc_full.jpg"))); + + mockMvc.perform(get("/api/library/steam-profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.personaName").value("Lobo")) + .andExpect(jsonPath("$.avatarUrl").value("https://avatars.steamstatic.com/abc_full.jpg")); + } + + @Test + void steamProfile_SteamDoesNotAnswer_SendsEmptyFields() throws Exception { + when(steamApiService.getPlayerSummary("76561198000000001")).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/library/steam-profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.personaName").value(nullValue())) + .andExpect(jsonPath("$.avatarUrl").value(nullValue())); + } +} diff --git a/src/test/java/core_service/modules/library/service/SteamProfileServiceTest.java b/src/test/java/core_service/modules/library/service/SteamProfileServiceTest.java new file mode 100644 index 0000000..212184d --- /dev/null +++ b/src/test/java/core_service/modules/library/service/SteamProfileServiceTest.java @@ -0,0 +1,58 @@ +package core_service.modules.library.service; + +import core_service.modules.library.api.dto.SteamProfileDto; +import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse; +import core_service.modules.steam_adapter.service.SteamApiService; +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SteamProfileServiceTest extends AbstractUnitTest { + + private static final String AVATAR = "https://avatars.steamstatic.com/abc_full.jpg"; + + @Mock + private SteamApiService steamApiService; + + @InjectMocks + private SteamProfileService service; + + @Test + void getProfile_TakesNameAndAvatarFromSteam() { + when(steamApiService.getPlayerSummary("76561198000000001")).thenReturn(Optional.of( + new SteamPlayerSummaryResponse.Player("76561198000000001", "Lobo", "https://steamcommunity.com/id/lobo", AVATAR))); + + assertEquals(new SteamProfileDto("Lobo", AVATAR), service.getProfile(76561198000000001L)); + } + + @Test + void getProfile_AvatarThatIsNotHttps_IsLeftOut() { + when(steamApiService.getPlayerSummary(any())).thenReturn(Optional.of( + new SteamPlayerSummaryResponse.Player("1", "Lobo", null, "javascript:alert(1)"))); + + assertEquals(new SteamProfileDto("Lobo", null), service.getProfile(1L)); + } + + @Test + void getProfile_SteamDoesNotAnswer_IsEmpty() { + when(steamApiService.getPlayerSummary(any())).thenReturn(Optional.empty()); + + assertEquals(new SteamProfileDto(null, null), service.getProfile(1L)); + } + + @Test + void getProfile_NoSteamId_AsksSteamNothing() { + assertEquals(new SteamProfileDto(null, null), service.getProfile(null)); + + verify(steamApiService, never()).getPlayerSummary(any()); + } +} diff --git a/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceCacheTest.java b/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceCacheTest.java new file mode 100644 index 0000000..c9c0d22 --- /dev/null +++ b/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceCacheTest.java @@ -0,0 +1,101 @@ +package core_service.modules.steam_adapter.service; + +import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse; +import core_service.shared.test.AbstractIntegrationTest; +import io.github.resilience4j.ratelimiter.RateLimiter; +import io.github.resilience4j.ratelimiter.RateLimiterRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.Answers; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.CacheManager; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.web.client.RestClient; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * The caching and resilience4j proxies around the real SteamApiService, with only the HTTP client mocked. + */ +class SteamApiServiceCacheTest extends AbstractIntegrationTest { + + private static final String STEAM_ID = "76561198000000001"; + + @MockitoBean(name = "steamRestClient", answers = Answers.RETURNS_DEEP_STUBS) + private RestClient steamRestClient; + + @Autowired + private SteamApiService steamApiService; + + @Autowired + private RateLimiterRegistry rateLimiterRegistry; + + @Autowired + private CacheManager cacheManager; + + private final AtomicInteger steamCalls = new AtomicInteger(); + + @AfterEach + void restore() { + RateLimiter limiter = rateLimiterRegistry.rateLimiter("steamApi"); + limiter.changeTimeoutDuration(Duration.ofSeconds(5)); + cacheManager.getCache("steamPlayerSummary").clear(); + } + + private void steamAnswers(SteamPlayerSummaryResponse response) { + when(steamRestClient.get().uri(any(Function.class)).retrieve().body(SteamPlayerSummaryResponse.class)) + .thenAnswer(invocation -> { + steamCalls.incrementAndGet(); + return response; + }); + } + + private static SteamPlayerSummaryResponse player() { + return new SteamPlayerSummaryResponse(new SteamPlayerSummaryResponse.SteamResponse(List.of( + new SteamPlayerSummaryResponse.Player(STEAM_ID, "Lobo", null, "https://avatars.steamstatic.com/a_full.jpg")))); + } + + @Test + void getPlayerSummary_AsksSteamOncePerSteamId() { + steamAnswers(player()); + + assertThat(steamApiService.getPlayerSummary(STEAM_ID)).isPresent(); + assertThat(steamApiService.getPlayerSummary(STEAM_ID)).isPresent(); + + assertThat(steamCalls).hasValue(1); + } + + @Test + void getPlayerSummary_NoPlayer_IsNotCached() { + steamAnswers(new SteamPlayerSummaryResponse(new SteamPlayerSummaryResponse.SteamResponse(List.of()))); + + assertThat(steamApiService.getPlayerSummary(STEAM_ID)).isEmpty(); + assertThat(steamApiService.getPlayerSummary(STEAM_ID)).isEmpty(); + + assertThat(steamCalls).hasValue(2); + } + + // The header and the Dashboard ask on every page load, also while a library sync uses all Steam permits. + @Test + void getPlayerSummary_CacheHit_TakesNoRateLimiterPermit() { + steamAnswers(player()); + assertThat(steamApiService.getPlayerSummary(STEAM_ID)).isPresent(); + assertThat(steamCalls).hasValue(1); + + RateLimiter limiter = rateLimiterRegistry.rateLimiter("steamApi"); + limiter.changeTimeoutDuration(Duration.ZERO); + while (limiter.acquirePermission()) { + // drain the current period + } + + assertThat(steamApiService.getPlayerSummary(STEAM_ID)).isPresent(); + assertThat(steamCalls).hasValue(1); + } +}