feat(library): Steam name and avatar for the header

This commit is contained in:
lobo committed 2026-09-24 15:27:33 +02:00
1 parent 00a7991380
commit ba75c00301
8 files changed
+258 -2

No files matched your search

@@ -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));
@@ -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<SteamProfileDto> getSteamProfile(@AuthenticationPrincipal UserPrincipal principal) {
return ResponseEntity.ok(steamProfileService.getProfile(principal.steamId()));
}
}
@@ -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
) {}
@@ -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);
}
}
@@ -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<SteamPlayerSummaryResponse.Player> getPlayerSummary(String steamId) {
@@ -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()));
}
}
@@ -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());
}
}
@@ -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);
}
}