Public Access
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:
1 parent
86db6f7167
commit
9fd552a785
25 files changed
+1399
-95
No files matched your search
+162
@@ -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());
|
||||
|
||||
+100
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user