feat(library): library-wide achievement sync and summary

Achievements were fetched only when a game detail page was opened, and a Steam error was stored as "no achievements" or "hidden". A background run after each library sync now fetches schemas and the unlocks of games with new playtime, one Steam call per second for all runs together, and never overwrites stored data on a Steam error. Adds GET /api/library/achievements/summary, achievement counts on GameDto and the public profile, and migration V7 (playtime_at_refresh).
This commit is contained in:
lobo committed 2026-09-25 10:13:31 +02:00
1 parent 86db6f7167
commit 9fd552a785
25 files changed
+1399 -95

No files matched your search

@@ -0,0 +1,162 @@
package core_service.modules.library.api;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.service.LibrarySyncWorker;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
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.time.Instant;
import java.util.List;
import java.util.Optional;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.nullValue;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
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.
*/
@AutoConfigureMockMvc
class LibraryControllerAchievementsTest extends AbstractIntegrationTest {
private static final long USER_ID = 1L;
@Autowired
private MockMvc mockMvc;
@MockitoBean
private SteamApiService steamApiService;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameAchievementSchemaMetaRepository schemaMetaRepository;
@Autowired
private UserGameAchievementMetaRepository userAchievementMetaRepository;
@Autowired
private LibrarySyncWorker librarySyncWorker;
@BeforeEach
void clean() {
userGameRepository.deleteAll();
schemaMetaRepository.deleteAll();
userAchievementMetaRepository.deleteAll();
}
/**
* @param total null for a schema that was never fetched, 0 for a game without achievements
* @param unlocked null for unlocks that were never fetched
*/
private void game(long appid, Integer total, Integer unlocked, boolean visible) {
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, appid, 60, Instant.now(), Instant.now()));
if (total != null) {
schemaMetaRepository.save(new GameAchievementSchemaMeta(appid, total, total > 0, Instant.now()));
}
if (unlocked != null) {
userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, appid, Instant.now(), visible, unlocked, 60));
}
}
@Test
void summary_EmptyLibrary() throws Exception {
mockMvc.perform(get("/api/library/achievements/summary"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.unlocked").value(0))
.andExpect(jsonPath("$.startedGames").value(0))
.andExpect(jsonPath("$.averageCompletion").value(nullValue()))
.andExpect(jsonPath("$.closest", hasSize(0)));
}
@Test
void summary_CountsOnlyVisibleStartedGamesAndCapsUnlocks() throws Exception {
game(10L, 10, 10, true); // perfect
game(20L, 4, 3, true); // 75 %
game(30L, 10, 1, true); // 10 %
game(40L, 5, 0, false); // hidden by Steam
game(50L, null, null, true); // schema never fetched
game(60L, 0, null, true); // no achievements
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
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());
userGameRepository.save(removed);
schemaMetaRepository.save(new GameAchievementSchemaMeta(95L, 5, true, Instant.now()));
userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 95L, Instant.now(), true, 5, 60));
userGameRepository.save(new UserGame(2L, 20L, 60, Instant.now(), Instant.now()));
userAchievementMetaRepository.save(new UserGameAchievementMeta(2L, 20L, Instant.now(), true, 4, 60));
mockMvc.perform(get("/api/library/achievements/summary"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.unlocked").value(24))
.andExpect(jsonPath("$.startedGames").value(4))
.andExpect(jsonPath("$.perfectGames").value(2))
.andExpect(jsonPath("$.averageCompletion").value(71.3))
.andExpect(jsonPath("$.pendingGames").value(2))
.andExpect(jsonPath("$.hiddenGames").value(1))
.andExpect(jsonPath("$.closest", hasSize(2)))
.andExpect(jsonPath("$.closest[0].appid").value(20))
.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));
}
@Test
void games_CarryTheAchievementProgress() throws Exception {
game(20L, 4, 3, true);
game(40L, 5, 0, false);
game(60L, 0, null, true);
game(80L, 10, 12, true);
mockMvc.perform(get("/api/library/games").param("sort", "appid,asc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].achievementsUnlocked").value(3))
.andExpect(jsonPath("$.content[0].achievementsTotal").value(4))
.andExpect(jsonPath("$.content[1].achievementsUnlocked").value(nullValue()))
.andExpect(jsonPath("$.content[1].achievementsTotal").value(5))
.andExpect(jsonPath("$.content[2].achievementsTotal").value(nullValue()))
.andExpect(jsonPath("$.content[3].achievementsUnlocked").value(10));
}
@Test
void librarySync_StartsTheAchievementSyncAfterCommit() {
when(steamApiService.getOwnedGames("76561198000000001"))
.thenReturn(List.of(new SteamOwnedGamesResponse.Game(620, "Portal 2", 90, null, true, 0)));
when(steamApiService.getWishlist(anyString())).thenReturn(Optional.of(List.of()));
when(steamApiService.getSchemaForGame(anyLong())).thenReturn(Optional.empty());
librarySyncWorker.runSyncNow(USER_ID, "76561198000000001");
verify(steamApiService, timeout(5_000)).getSchemaForGame(620L);
}
}
@@ -1,10 +1,14 @@
package core_service.modules.library.api;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Jwts;
@@ -37,6 +41,12 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameAchievementSchemaMetaRepository schemaMetaRepository;
@Autowired
private UserGameAchievementMetaRepository userAchievementMetaRepository;
private static String ticket(long userId) {
return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
@@ -54,6 +64,8 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
@BeforeEach
void seed() {
userGameRepository.deleteAll();
schemaMetaRepository.deleteAll();
userAchievementMetaRepository.deleteAll();
steamGameRepository.saveAll(List.of(
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
new SteamGame(620L, "Portal 2", null, Instant.now()),
@@ -87,6 +99,21 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$..priority").isEmpty());
}
@Test
void publicLibrary_ShowsPerfectGamesAndAverageCompletion() throws Exception {
schemaMetaRepository.saveAll(List.of(
new GameAchievementSchemaMeta(620L, 10, true, Instant.now()),
new GameAchievementSchemaMeta(220L, 4, true, Instant.now())));
userAchievementMetaRepository.saveAll(List.of(
new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), true, 10, 900),
new UserGameAchievementMeta(USER_ID, 220L, Instant.now(), true, 1, 300)));
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.perfectGames").value(1))
.andExpect(jsonPath("$.averageCompletion").value(62.5));
}
@Test
void publicLibrary_WithoutOrWithABadTicket_Is401() throws Exception {
mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized());
@@ -0,0 +1,100 @@
package core_service.modules.library.service;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameAchievementRepository;
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
/**
* The game detail page refreshes inside its own @Transactional while the background sync refreshes the same
* (user, appid) in a transaction of its own, e.g. right after the user pressed "sync" and opened the most played game.
*/
class AchievementSyncServiceConcurrencyTest extends AbstractIntegrationTest {
private static final long USER_ID = 1L;
private static final String STEAM_ID = "76561198000000001";
@Autowired
private AchievementSyncService achievementSyncService;
@Autowired
private UserGameAchievementMetaRepository userAchievementMetaRepository;
@Autowired
private UserGameAchievementRepository userAchievementRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@MockitoBean
private SteamApiService steamApiService;
@BeforeEach
@AfterEach
void clean() {
userAchievementMetaRepository.deleteAll();
userAchievementRepository.deleteAll();
}
@Test
void refreshUser_DetailPageAndBackgroundSyncOnTheSameGame_BothCommit() {
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(Optional.of(
new SteamPlayerAchievementsResponse.PlayerStats(STEAM_ID, "Portal 2",
List.of(new SteamPlayerAchievementsResponse.Achievement("A", 1, 1_700_000_000L)), true, null)));
CountDownLatch firstWrote = new CountDownLatch(1);
CountDownLatch secondStarted = new CountDownLatch(1);
CompletableFuture<Void> detailPage = CompletableFuture.runAsync(() -> new TransactionTemplate(transactionManager)
.executeWithoutResult(tx -> {
achievementSyncService.refreshUserIfStale(USER_ID, STEAM_ID, 620L, 60, Duration.ofHours(1));
firstWrote.countDown();
await(secondStarted);
// Keep the page's transaction open while the background run writes the same rows.
sleep();
}));
CompletableFuture<Void> backgroundRun = CompletableFuture.runAsync(() -> {
await(firstWrote);
secondStarted.countDown();
achievementSyncService.refreshUserIfStale(USER_ID, STEAM_ID, 620L, 60, Duration.ofHours(1));
});
assertDoesNotThrow(() -> CompletableFuture.allOf(detailPage, backgroundRun).get(30, TimeUnit.SECONDS));
assertEquals(1, userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 620L).orElseThrow().getAchievementsUnlocked());
}
private static void await(CountDownLatch latch) {
try {
if (!latch.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("latch timed out");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
}
private static void sleep() {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
@@ -0,0 +1,309 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameAchievementRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.web.client.ResourceAccessException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.stream.LongStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class AchievementSyncServiceTest extends AbstractUnitTest {
private static final long USER_ID = 1L;
private static final String STEAM_ID = "76561198000000001";
@Mock
private UserGameRepository userGameRepository;
@Mock
private GameAchievementSchemaRepository schemaRepository;
@Mock
private GameAchievementSchemaMetaRepository schemaMetaRepository;
@Mock
private UserGameAchievementRepository userAchievementRepository;
@Mock
private UserGameAchievementMetaRepository userAchievementMetaRepository;
@Mock
private SteamApiService steamApiService;
private AchievementSyncService service;
@BeforeEach
void setUp() {
service = new AchievementSyncService(userGameRepository, schemaRepository, schemaMetaRepository,
userAchievementRepository, userAchievementMetaRepository, steamApiService,
mock(PlatformTransactionManager.class), 30, 0);
}
private static UserGame game(long appid, int playtime) {
return new UserGame(USER_ID, appid, playtime, Instant.now(), Instant.now());
}
private static GameAchievementSchemaMeta schema(long appid, int total) {
return new GameAchievementSchemaMeta(appid, total, total > 0, Instant.now());
}
private static UserGameAchievementMeta unlocks(long appid, int unlocked, Integer playtimeAtRefresh) {
return new UserGameAchievementMeta(USER_ID, appid, Instant.now(), true, unlocked, playtimeAtRefresh);
}
private static Optional<SteamPlayerAchievementsResponse.PlayerStats> visible(String... unlockedNames) {
List<SteamPlayerAchievementsResponse.Achievement> list = java.util.Arrays.stream(unlockedNames)
.map(name -> new SteamPlayerAchievementsResponse.Achievement(name, 1, 1_700_000_000L))
.toList();
return Optional.of(new SteamPlayerAchievementsResponse.PlayerStats(STEAM_ID, "Game", list, true, null));
}
private void library(List<UserGame> games, List<GameAchievementSchemaMeta> schemas, List<UserGameAchievementMeta> metas) {
when(userGameRepository.findByUserIdAndRemovedAtIsNull(USER_ID)).thenReturn(games);
when(schemaMetaRepository.findAllById(any())).thenReturn(schemas);
when(userAchievementMetaRepository.findByUserId(USER_ID)).thenReturn(metas);
}
private UserGameAchievementMeta savedMeta() {
ArgumentCaptor<UserGameAchievementMeta> saved = ArgumentCaptor.forClass(UserGameAchievementMeta.class);
verify(userAchievementMetaRepository).save(saved.capture());
return saved.getValue();
}
@Test
void syncLibrary_UnchangedPlaytime_AsksSteamNothing() {
library(List.of(game(620L, 120)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, 120)));
service.syncLibrary(USER_ID, STEAM_ID);
verifyNoInteractions(steamApiService);
verify(userAchievementMetaRepository, never()).save(any());
}
@Test
void syncLibrary_ChangedPlaytime_StoresUnlocksAndThePlaytime() {
library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, 120)));
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A", "B", "C", "D"));
service.syncLibrary(USER_ID, STEAM_ID);
UserGameAchievementMeta meta = savedMeta();
assertEquals(4, meta.getAchievementsUnlocked());
assertEquals(150, meta.getPlaytimeAtRefresh());
assertTrue(meta.isStatsVisible());
verify(userAchievementRepository).deleteByUserIdAndAppid(USER_ID, 620L);
}
@Test
void syncLibrary_NeverRefreshedWithPlaytime_AsksSteamOnce() {
library(List.of(game(620L, 120)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, null)));
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A"));
service.syncLibrary(USER_ID, STEAM_ID);
assertEquals(120, savedMeta().getPlaytimeAtRefresh());
}
@Test
void syncLibrary_UnplayedGameWithoutData_AsksSteamOnce() {
library(List.of(game(620L, 0)), List.of(schema(620L, 10)), List.of());
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible());
service.syncLibrary(USER_ID, STEAM_ID);
UserGameAchievementMeta meta = savedMeta();
assertEquals(0, meta.getAchievementsUnlocked());
assertEquals(0, meta.getPlaytimeAtRefresh());
}
@Test
void syncLibrary_MissingSchema_FetchesItAndSkipsGamesWithoutAchievements() {
library(List.of(game(620L, 60)), List.of(), List.of());
when(steamApiService.getSchemaForGame(620L)).thenReturn(Optional.empty());
service.syncLibrary(USER_ID, STEAM_ID);
ArgumentCaptor<GameAchievementSchemaMeta> saved = ArgumentCaptor.forClass(GameAchievementSchemaMeta.class);
verify(schemaMetaRepository).save(saved.capture());
assertFalse(saved.getValue().isHasSchema());
verify(steamApiService, never()).getPlayerAchievements(anyString(), anyLong());
}
@Test
void syncLibrary_OldSchema_IsFetchedAgain() {
GameAchievementSchemaMeta old = new GameAchievementSchemaMeta(620L, 10, true, Instant.now().minus(Duration.ofDays(31)));
library(List.of(game(620L, 120)), List.of(old), List.of(unlocks(620L, 3, 120)));
when(steamApiService.getSchemaForGame(620L)).thenReturn(Optional.empty());
service.syncLibrary(USER_ID, STEAM_ID);
verify(steamApiService).getSchemaForGame(620L);
}
@Test
void syncLibrary_StopsAfterThreeSteamErrorsInARow_AndKeepsStoredData() {
List<UserGame> games = LongStream.rangeClosed(1, 6).mapToObj(appid -> game(appid, 60)).toList();
List<GameAchievementSchemaMeta> schemas = LongStream.rangeClosed(1, 6).mapToObj(appid -> schema(appid, 10)).toList();
library(games, schemas, List.of());
when(steamApiService.getPlayerAchievements(eq(STEAM_ID), anyLong())).thenThrow(new ResourceAccessException("down"));
service.syncLibrary(USER_ID, STEAM_ID);
verify(steamApiService, times(AchievementSyncService.MAX_ERRORS_IN_A_ROW)).getPlayerAchievements(eq(STEAM_ID), anyLong());
verify(userAchievementMetaRepository, never()).save(any());
verify(userAchievementRepository, never()).deleteByUserIdAndAppid(anyLong(), anyLong());
}
@Test
void syncLibrary_StopsWhenSteamHidesTheFirstGames() {
List<UserGame> games = LongStream.rangeClosed(1, 8).mapToObj(appid -> game(appid, 60)).toList();
List<GameAchievementSchemaMeta> schemas = LongStream.rangeClosed(1, 8).mapToObj(appid -> schema(appid, 10)).toList();
library(games, schemas, List.of());
when(steamApiService.getPlayerAchievements(eq(STEAM_ID), anyLong())).thenReturn(Optional.empty());
service.syncLibrary(USER_ID, STEAM_ID);
verify(steamApiService, times(AchievementSyncService.MAX_HIDDEN_IN_A_ROW)).getPlayerAchievements(eq(STEAM_ID), anyLong());
verify(userAchievementMetaRepository, times(AchievementSyncService.MAX_HIDDEN_IN_A_ROW))
.save(argThat(meta -> !meta.isStatsVisible() && meta.getPlaytimeAtRefresh() == null));
}
@Test
void syncLibrary_HiddenGame_IsAskedAgainOnTheNextSync() {
UserGameAchievementMeta hidden = new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), false, 0, null);
library(List.of(game(620L, 60)), List.of(schema(620L, 10)), List.of(hidden));
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A"));
service.syncLibrary(USER_ID, STEAM_ID);
assertTrue(savedMeta().isStatsVisible());
}
@Test
void syncLibrary_HiddenGamesAfterAVisibleOne_DoNotStopTheRun() {
List<UserGame> games = LongStream.rangeClosed(1, 8).mapToObj(appid -> game(appid, 100 - (int) appid)).toList();
List<GameAchievementSchemaMeta> schemas = LongStream.rangeClosed(1, 8).mapToObj(appid -> schema(appid, 10)).toList();
library(games, schemas, List.of());
when(steamApiService.getPlayerAchievements(eq(STEAM_ID), anyLong())).thenReturn(Optional.empty());
when(steamApiService.getPlayerAchievements(STEAM_ID, 1L)).thenReturn(visible("A"));
service.syncLibrary(USER_ID, STEAM_ID);
verify(steamApiService, times(8)).getPlayerAchievements(eq(STEAM_ID), anyLong());
}
@Test
void syncLibrary_CallWhileRunning_RunsOnceMoreAfterwards() throws Exception {
when(userGameRepository.findByUserIdAndRemovedAtIsNull(USER_ID)).thenReturn(List.of(game(620L, 60)));
when(schemaMetaRepository.findAllById(any())).thenReturn(List.of(schema(620L, 10)));
// The second run sees the row the first run stored.
when(userAchievementMetaRepository.findByUserId(USER_ID))
.thenReturn(List.of(), List.of(unlocks(620L, 1, 60)));
CountDownLatch inSteamCall = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenAnswer(invocation -> {
inSteamCall.countDown();
assertTrue(release.await(5, TimeUnit.SECONDS));
return visible("A");
});
Thread first = Thread.ofVirtual().start(() -> service.syncLibrary(USER_ID, STEAM_ID));
assertTrue(inSteamCall.await(5, TimeUnit.SECONDS));
service.syncLibrary(USER_ID, STEAM_ID);
release.countDown();
first.join(5_000);
verify(steamApiService, times(1)).getPlayerAchievements(STEAM_ID, 620L);
verify(userGameRepository, times(2)).findByUserIdAndRemovedAtIsNull(USER_ID);
}
@Test
void syncLibrary_UnplayedGameWithStoredUnlocks_KeepsTheUnlocks() {
UserGameAchievementMeta stored = unlocks(620L, 10, null);
library(List.of(game(620L, 0)), List.of(schema(620L, 10)), List.of(stored));
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L))
.thenReturn(visible("A", "B", "C", "D", "E", "F", "G", "H", "I", "J"));
service.syncLibrary(USER_ID, STEAM_ID);
verify(userAchievementMetaRepository, never()).save(argThat(meta -> meta.getAchievementsUnlocked() < 10));
}
@Test
void syncLibrary_TwoUsersInParallel_ShareOneSteamBudget() throws Exception {
long pauseMillis = 300;
AchievementSyncService paced = new AchievementSyncService(userGameRepository, schemaRepository, schemaMetaRepository,
userAchievementRepository, userAchievementMetaRepository, steamApiService,
mock(PlatformTransactionManager.class), 30, pauseMillis);
when(userGameRepository.findByUserIdAndRemovedAtIsNull(1L)).thenReturn(List.of(new UserGame(1L, 620L, 60, Instant.now(), Instant.now())));
when(userGameRepository.findByUserIdAndRemovedAtIsNull(2L)).thenReturn(List.of(new UserGame(2L, 620L, 60, Instant.now(), Instant.now())));
when(schemaMetaRepository.findAllById(any())).thenReturn(List.of(schema(620L, 10)));
when(userAchievementMetaRepository.findByUserId(anyLong())).thenReturn(List.of());
List<Long> steamCallsAtNanos = Collections.synchronizedList(new ArrayList<>());
when(steamApiService.getPlayerAchievements(eq(STEAM_ID), anyLong())).thenAnswer(invocation -> {
steamCallsAtNanos.add(System.nanoTime());
return visible("A");
});
Thread first = Thread.ofVirtual().start(() -> paced.syncLibrary(1L, STEAM_ID));
Thread second = Thread.ofVirtual().start(() -> paced.syncLibrary(2L, STEAM_ID));
first.join(5_000);
second.join(5_000);
assertEquals(2, steamCallsAtNanos.size());
long gapMillis = Math.abs(steamCallsAtNanos.get(1) - steamCallsAtNanos.get(0)) / 1_000_000;
assertTrue(gapMillis >= pauseMillis - 50, "Steam calls of two runs were only " + gapMillis + " ms apart");
}
@Test
void refreshUserIfStale_SteamError_ThrowsAndKeepsStoredData() {
lenient().when(userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 620L)).thenReturn(Optional.empty());
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenThrow(new ResourceAccessException("down"));
assertThrows(ResourceAccessException.class,
() -> service.refreshUserIfStale(USER_ID, STEAM_ID, 620L, 60, Duration.ofHours(1)));
verify(userAchievementMetaRepository, never()).save(any());
}
@Test
void refreshUserIfStale_FreshData_AsksSteamNothing() {
when(userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 620L)).thenReturn(Optional.of(unlocks(620L, 3, 60)));
service.refreshUserIfStale(USER_ID, STEAM_ID, 620L, 60, Duration.ofHours(1));
verifyNoInteractions(steamApiService);
}
}
@@ -0,0 +1,83 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
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.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
/**
* One connection in the pool: a detail request must not need two at once.
*/
@TestPropertySource(properties = {
"spring.datasource.hikari.maximum-pool-size=1",
"spring.datasource.hikari.connection-timeout=1000"})
class GameDetailServicePoolTest extends AbstractIntegrationTest {
private static final long USER_ID = 1L;
private static final String STEAM_ID = "76561198000000001";
@Autowired
private GameDetailService gameDetailService;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameAchievementSchemaMetaRepository schemaMetaRepository;
@Autowired
private UserGameAchievementMetaRepository userAchievementMetaRepository;
@MockitoBean
private SteamApiService steamApiService;
@Autowired
private GameAchievementSchemaRepository schemaRepository;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
schemaMetaRepository.deleteAll();
userAchievementMetaRepository.deleteAll();
steamGameRepository.save(new SteamGame(620L, "Portal 2", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 620L, 60, Instant.now(), Instant.now()));
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 1, true, Instant.now()));
schemaRepository.save(new GameAchievementSchema(620L, "A", "First", null, null, null, false));
}
@Test
void getGameDetail_NeedsOnlyOneConnectionAtATime() {
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(Optional.of(
new SteamPlayerAchievementsResponse.PlayerStats(STEAM_ID, "Portal 2",
List.of(new SteamPlayerAchievementsResponse.Achievement("A", 1, 1_700_000_000L)), true, null)));
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
assertEquals(1, userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 620L).orElseThrow().getAchievementsUnlocked());
assertEquals(1, detail.achievementsUnlocked());
}
}
@@ -0,0 +1,144 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameAchievementRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.web.client.ResourceAccessException;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
/**
* The detail page refreshes stale rows in a transaction of its own, then reads them in its own read transaction.
*/
class GameDetailServiceTest extends AbstractIntegrationTest {
private static final long USER_ID = 1L;
private static final String STEAM_ID = "76561198000000001";
@Autowired
private GameDetailService gameDetailService;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameAchievementSchemaRepository schemaRepository;
@Autowired
private GameAchievementSchemaMetaRepository schemaMetaRepository;
@Autowired
private UserGameAchievementRepository userAchievementRepository;
@Autowired
private UserGameAchievementMetaRepository userAchievementMetaRepository;
@MockitoBean
private SteamApiService steamApiService;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
schemaRepository.deleteAll();
schemaMetaRepository.deleteAll();
userAchievementRepository.deleteAll();
userAchievementMetaRepository.deleteAll();
steamGameRepository.save(new SteamGame(620L, "Portal 2", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 620L, 60, Instant.now(), Instant.now()));
}
private static SteamPlayerAchievementsResponse.Achievement achieved(String name) {
return new SteamPlayerAchievementsResponse.Achievement(name, 1, 1_700_000_000L);
}
@Test
void getGameDetail_StaleUserMeta_ShowsTheRefreshedCount() {
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 5, true, Instant.now()));
for (String name : List.of("A", "B", "C", "D", "E")) {
schemaRepository.save(new GameAchievementSchema(620L, name, name, null, null, null, false));
}
userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 620L,
Instant.now().minus(Duration.ofHours(2)), true, 3, 60));
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(Optional.of(
new SteamPlayerAchievementsResponse.PlayerStats(STEAM_ID, "Portal 2",
List.of(achieved("A"), achieved("B"), achieved("C"), achieved("D"), achieved("E")), true, null)));
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
assertEquals(5, userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 620L).orElseThrow().getAchievementsUnlocked());
assertEquals(5, detail.achievementsUnlocked());
}
@Test
void getGameDetail_StaleSchemaWithoutAchievements_ShowsTheRefreshedSchema() {
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 0, false, Instant.now().minus(Duration.ofDays(31))));
when(steamApiService.getSchemaForGame(620L)).thenReturn(Optional.of(new SteamSchemaForGameResponse.Game("Portal 2", "1",
new SteamSchemaForGameResponse.AvailableGameStats(List.of(
new SteamSchemaForGameResponse.Achievement("A", "A", null, 0, null, null))))));
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(Optional.of(
new SteamPlayerAchievementsResponse.PlayerStats(STEAM_ID, "Portal 2", List.of(achieved("A")), true, null)));
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
assertTrue(schemaMetaRepository.findById(620L).orElseThrow().isHasSchema());
assertTrue(detail.hasAchievements());
}
@Test
void getGameDetail_SchemaLostAchievements_CapsTheUnlocks() {
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 2, true, Instant.now()));
for (String name : List.of("A", "B")) {
schemaRepository.save(new GameAchievementSchema(620L, name, name, null, null, null, false));
}
userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), true, 3, 60));
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
assertEquals(2, detail.achievementsUnlocked());
assertEquals(100.0, detail.completionPercent());
assertEquals(0, detail.achievementsRemaining());
}
@Test
void getGameDetail_SteamDown_ShowsTheStoredData() {
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 2, true, Instant.now()));
for (String name : List.of("A", "B")) {
schemaRepository.save(new GameAchievementSchema(620L, name, name, null, null, null, false));
}
userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 620L,
Instant.now().minus(Duration.ofHours(2)), true, 1, 60));
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenThrow(new ResourceAccessException("down"));
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
assertEquals(1, detail.achievementsUnlocked());
assertTrue(detail.statsVisible());
}
}
@@ -21,6 +21,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
@@ -39,6 +40,7 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
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 LibrarySyncWorkerTest extends AbstractUnitTest {
@@ -99,6 +101,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock
private TimeToBeatService timeToBeatService;
@Mock
private ApplicationEventPublisher eventPublisher;
@InjectMocks
private LibrarySyncWorker worker;
@@ -116,6 +121,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(userGameRepository, never()).softDeleteMissingSince(anyLong(), any(), any());
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("FAILED"), any());
verifyNoInteractions(eventPublisher);
}
@Test
@@ -154,6 +160,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any());
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("OK"), any());
verify(eventPublisher).publishEvent(new LibrarySyncedEvent(USER_ID, STEAM_ID));
}
@ParameterizedTest
@@ -1,14 +1,17 @@
package core_service.modules.steam_adapter.service;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamProfileAndGamesResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.time.Instant;
import java.util.List;
@@ -20,9 +23,11 @@ import static org.hamcrest.CoreMatchers.allOf;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.not;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.queryParam;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class SteamApiServiceTest {
@@ -258,4 +263,67 @@ class SteamApiServiceTest {
server.verify();
}
@Test
void getPlayerAchievements_Forbidden_IsEmpty() {
server.expect(requestTo(containsString("/ISteamUserStats/GetPlayerAchievements/v1/")))
.andRespond(withStatus(HttpStatus.FORBIDDEN));
assertThat(steamApiService.getPlayerAchievements("76561197960435530", 620)).isEmpty();
}
@Test
void getPlayerAchievements_ServerError_Throws() {
server.expect(requestTo(containsString("/ISteamUserStats/GetPlayerAchievements/v1/")))
.andRespond(withServerError());
assertThatThrownBy(() -> steamApiService.getPlayerAchievements("76561197960435530", 620))
.isInstanceOf(RestClientException.class);
}
@Test
void getSchemaForGame_ServerError_Throws() {
server.expect(requestTo(containsString("/ISteamUserStats/GetSchemaForGame/v2/")))
.andRespond(withServerError());
assertThatThrownBy(() -> steamApiService.getSchemaForGame(620)).isInstanceOf(RestClientException.class);
}
@Test
void getSchemaForGame_NoGame_IsEmpty() {
server.expect(requestTo(containsString("/ISteamUserStats/GetSchemaForGame/v2/")))
.andRespond(withSuccess("{\"game\":null}", MediaType.APPLICATION_JSON));
assertThat(steamApiService.getSchemaForGame(620)).isEmpty();
}
@Test
void getPlayerAchievements_NoStatsForThisPlayer_IsVisibleWithNothingUnlocked() {
server.expect(requestTo(containsString("/ISteamUserStats/GetPlayerAchievements/v1/")))
.andRespond(withStatus(HttpStatus.BAD_REQUEST).contentType(MediaType.APPLICATION_JSON)
.body("{\"playerstats\":{\"error\":\"Requested app has no stats\",\"success\":false}}"));
Optional<SteamPlayerAchievementsResponse.PlayerStats> stats = steamApiService.getPlayerAchievements("76561197960435530", 620);
assertThat(stats).isPresent();
assertThat(stats.get().success()).isTrue();
assertThat(stats.get().achievements()).isEmpty();
}
@Test
void getSchemaForGame_ClientError_IsEmpty() {
server.expect(requestTo(containsString("/ISteamUserStats/GetSchemaForGame/v2/")))
.andRespond(withStatus(HttpStatus.BAD_REQUEST));
assertThat(steamApiService.getSchemaForGame(620)).isEmpty();
}
@Test
void getPlayerAchievements_OtherBadRequest_Throws() {
server.expect(requestTo(containsString("/ISteamUserStats/GetPlayerAchievements/v1/")))
.andRespond(withStatus(HttpStatus.BAD_REQUEST).contentType(MediaType.APPLICATION_JSON)
.body("{\"playerstats\":{\"error\":\"Invalid SteamID\",\"success\":false}}"));
assertThatThrownBy(() -> steamApiService.getPlayerAchievements("76561197960435530", 620))
.isInstanceOf(RestClientException.class);
}
}
@@ -39,6 +39,7 @@ public abstract class AbstractIntegrationTest {
static void secrets(DynamicPropertyRegistry registry) {
registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(TEST_KEYS.getPublic().getEncoded()));
registry.add("app.steam.api-key", () -> "test-key");
registry.add("app.library.achievement-sync-pause-ms", () -> "0");
}
private static KeyPair generateKeys() {