Public Access
feat(library): sync the Steam wishlist with store prices
Each sync fetches the public wishlist and the store name, price and discount (batches of 100) and replaces the user's wishlist_game rows, without games the user owns. A Steam error keeps the old list, a failed store batch keeps the last known price, and a Steam ID change drops the old account's list. GET /api/library/wishlist returns it newest first.
This commit is contained in:
1 parent
ba1f1e6fe1
commit
ea1e1e9aec
15 files changed
+604
No files matched your search
@@ -0,0 +1,64 @@
|
||||
package core_service.modules.library.api;
|
||||
|
||||
import core_service.modules.library.domain.WishlistGame;
|
||||
import core_service.modules.library.repo.WishlistGameRepository;
|
||||
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.web.servlet.MockMvc;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
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 LibraryControllerWishlistTest extends AbstractIntegrationTest {
|
||||
|
||||
private static final long USER_ID = 1L;
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private WishlistGameRepository wishlistGameRepository;
|
||||
|
||||
@BeforeEach
|
||||
void clean() {
|
||||
wishlistGameRepository.deleteAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void wishlist_OwnGamesNewestFirst() throws Exception {
|
||||
wishlistGameRepository.saveAll(List.of(
|
||||
new WishlistGame(USER_ID, 1L, "Old", Instant.parse("2024-01-01T00:00:00Z"), "9,99€", null, 0),
|
||||
new WishlistGame(USER_ID, 2L, "New", Instant.parse("2026-01-01T00:00:00Z"), "7,49€", "29,99€", 75),
|
||||
new WishlistGame(USER_ID, 3L, "No date", null, null, null, 0),
|
||||
new WishlistGame(2L, 4L, "Other user", Instant.now(), null, null, 0)));
|
||||
|
||||
mockMvc.perform(get("/api/library/wishlist"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.games", hasSize(3)))
|
||||
.andExpect(jsonPath("$.games[0].name").value("New"))
|
||||
.andExpect(jsonPath("$.games[0].price").value("7,49€"))
|
||||
.andExpect(jsonPath("$.games[0].originalPrice").value("29,99€"))
|
||||
.andExpect(jsonPath("$.games[0].discountPct").value(75))
|
||||
.andExpect(jsonPath("$.games[1].name").value("Old"))
|
||||
.andExpect(jsonPath("$.games[2].name").value("No date"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void wishlist_Empty() throws Exception {
|
||||
mockMvc.perform(get("/api/library/wishlist"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.games", hasSize(0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
import core_service.modules.library.domain.WishlistGame;
|
||||
import core_service.shared.test.AbstractPersistenceTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class WishlistGameRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
@Autowired
|
||||
private WishlistGameRepository wishlistGameRepository;
|
||||
|
||||
@Test
|
||||
void deleteByUserId_ThenSaveTheSameGames_ReplacesTheListInOneTransaction() {
|
||||
wishlistGameRepository.saveAll(List.of(
|
||||
new WishlistGame(1L, 10L, "Old price", Instant.EPOCH, "9,99€", null, 0),
|
||||
new WishlistGame(1L, 11L, "Removed from the wishlist", Instant.EPOCH, null, null, 0),
|
||||
new WishlistGame(2L, 10L, "Other user", Instant.EPOCH, null, null, 0)));
|
||||
wishlistGameRepository.flush();
|
||||
|
||||
wishlistGameRepository.deleteByUserId(1L);
|
||||
wishlistGameRepository.saveAll(List.of(new WishlistGame(1L, 10L, "New price", Instant.EPOCH, "4,99€", "9,99€", 50)));
|
||||
wishlistGameRepository.flush();
|
||||
|
||||
List<WishlistGame> own = wishlistGameRepository.findForUser(1L);
|
||||
assertEquals(1, own.size());
|
||||
assertEquals("4,99€", own.getFirst().getPrice());
|
||||
assertEquals(1, wishlistGameRepository.findForUser(2L).size());
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
import core_service.modules.library.domain.UserGameId;
|
||||
import core_service.modules.library.domain.UserSteamSync;
|
||||
import core_service.modules.library.domain.WishlistGame;
|
||||
import core_service.modules.library.repo.PlaytimeDayRepository;
|
||||
import core_service.modules.library.repo.SteamGameRepository;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
import core_service.modules.library.repo.UserSteamSyncRepository;
|
||||
import core_service.modules.library.repo.WishlistGameRepository;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
|
||||
import core_service.modules.steam_adapter.service.SteamApiService;
|
||||
import core_service.shared.test.AbstractUnitTest;
|
||||
@@ -16,6 +18,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
@@ -24,13 +27,16 @@ import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
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.when;
|
||||
@@ -87,6 +93,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
@Mock
|
||||
private PlaytimeDayRepository playtimeDayRepository;
|
||||
|
||||
@Mock
|
||||
private WishlistGameRepository wishlistGameRepository;
|
||||
|
||||
@InjectMocks
|
||||
private LibrarySyncWorker worker;
|
||||
|
||||
@@ -255,4 +264,91 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
|
||||
assertEquals(75, existing.getPlaytime2Weeks());
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void runSync_ReplacesTheWishlistWithoutOwnedGames() {
|
||||
steamReturns(game(0, 0), STEAM_ID);
|
||||
Instant added = Instant.ofEpochSecond(1706021901);
|
||||
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.of(List.of(
|
||||
new SteamApiService.WishlistItem(220L, added),
|
||||
new SteamApiService.WishlistItem(1257610L, added))));
|
||||
when(steamApiService.getStoreItems(List.of(1257610L))).thenReturn(Map.of(
|
||||
1257610L, new SteamApiService.StoreItem("Prison Architect 2", "7,49€", "29,99€", 75)));
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
ArgumentCaptor<List<WishlistGame>> saved = ArgumentCaptor.forClass(List.class);
|
||||
InOrder order = inOrder(wishlistGameRepository);
|
||||
order.verify(wishlistGameRepository).deleteByUserId(USER_ID);
|
||||
order.verify(wishlistGameRepository).saveAll(saved.capture());
|
||||
assertEquals(1, saved.getValue().size());
|
||||
WishlistGame wish = saved.getValue().getFirst();
|
||||
assertEquals(1257610L, wish.getAppid());
|
||||
assertEquals("Prison Architect 2", wish.getName());
|
||||
assertEquals("7,49€", wish.getPrice());
|
||||
assertEquals(75, wish.getDiscountPct());
|
||||
assertEquals(added, wish.getAddedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_WishlistError_KeepsTheOldWishlistAndTheLibrarySyncIsOk() {
|
||||
steamReturns(game(0, 0), STEAM_ID);
|
||||
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.empty());
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(wishlistGameRepository, never()).deleteByUserId(anyLong());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("OK"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_NoOwnedGames_StillSyncsTheWishlist() {
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of());
|
||||
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.of(List.of(new SteamApiService.WishlistItem(1257610L, null))));
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(wishlistGameRepository).saveAll(anyList());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("FAILED"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void runSync_StoreBatchFailed_KeepsNameAndPriceOfTheLastSync() {
|
||||
steamReturns(game(0, 0), STEAM_ID);
|
||||
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.of(List.of(new SteamApiService.WishlistItem(1257610L, null))));
|
||||
when(steamApiService.getStoreItems(List.of(1257610L))).thenReturn(Map.of());
|
||||
when(wishlistGameRepository.findForUser(USER_ID)).thenReturn(List.of(
|
||||
new WishlistGame(USER_ID, 1257610L, "Prison Architect 2", null, "29,99€", null, 0)));
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
ArgumentCaptor<List<WishlistGame>> saved = ArgumentCaptor.forClass(List.class);
|
||||
verify(wishlistGameRepository).saveAll(saved.capture());
|
||||
assertEquals("Prison Architect 2", saved.getValue().getFirst().getName());
|
||||
assertEquals("29,99€", saved.getValue().getFirst().getPrice());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_SteamIdChanged_WishlistError_RemovesTheOldAccountsWishlist() {
|
||||
steamReturns(game(0, 0), OTHER_STEAM_ID);
|
||||
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.empty());
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(wishlistGameRepository).deleteByUserId(USER_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_SteamIdChanged_LibraryFailed_RemovesTheOldAccountsWishlist() {
|
||||
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(syncRow(OTHER_STEAM_ID, OTHER_STEAM_ID)));
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of());
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(wishlistGameRepository).deleteByUserId(USER_ID);
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,19 @@ import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.LongStream;
|
||||
|
||||
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.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.withSuccess;
|
||||
|
||||
class SteamApiServiceTest {
|
||||
@@ -188,4 +194,68 @@ class SteamApiServiceTest {
|
||||
assertThat(result.get().games()).hasSize(1);
|
||||
assertThat(result.get().games().getFirst().name()).isEqualTo("Counter-Strike");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWishlist_ReturnsItemsWithTheDateAdded() {
|
||||
server.expect(requestTo(containsString("/IWishlistService/GetWishlist/v1/")))
|
||||
.andExpect(queryParam("steamid", "76561197960435530"))
|
||||
.andRespond(withSuccess("""
|
||||
{"response": {"items": [{"appid": 1257610, "priority": 0, "date_added": 1706021901}]}}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
Optional<List<SteamApiService.WishlistItem>> result = steamApiService.getWishlist("76561197960435530");
|
||||
|
||||
assertThat(result).contains(List.of(new SteamApiService.WishlistItem(1257610L, Instant.ofEpochSecond(1706021901))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWishlist_PrivateOrEmpty_IsAnEmptyList() {
|
||||
server.expect(requestTo(containsString("/IWishlistService/GetWishlist/v1/")))
|
||||
.andRespond(withSuccess("{\"response\": {}}", MediaType.APPLICATION_JSON));
|
||||
|
||||
assertThat(steamApiService.getWishlist("76561197960435530")).contains(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWishlist_SteamError_IsEmptySoTheCallerKeepsTheOldList() {
|
||||
server.expect(requestTo(containsString("/IWishlistService/GetWishlist/v1/")))
|
||||
.andRespond(withServerError());
|
||||
|
||||
assertThat(steamApiService.getWishlist("76561197960435530")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStoreItems_ReadsNameAndPriceAndSkipsUnknownApps() {
|
||||
server.expect(requestTo(containsString("/IStoreBrowseService/GetItems/v1/")))
|
||||
.andExpect(queryParam("input_json", containsString("%22appid%22%3A620")))
|
||||
.andRespond(withSuccess("""
|
||||
{"response": {"store_items": [
|
||||
{"appid": 620, "success": 1, "name": "Portal 2",
|
||||
"best_purchase_option": {"formatted_final_price": "2,43€", "formatted_original_price": "9,75€", "discount_pct": 75}},
|
||||
{"appid": 3012600, "success": 1, "name": "Coming Soon"},
|
||||
{"id": 42, "success": 2}
|
||||
]}}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
Map<Long, SteamApiService.StoreItem> items = steamApiService.getStoreItems(List.of(620L, 3012600L, 42L));
|
||||
|
||||
assertThat(items).containsOnlyKeys(620L, 3012600L);
|
||||
assertThat(items.get(620L)).isEqualTo(new SteamApiService.StoreItem("Portal 2", "2,43€", "9,75€", 75));
|
||||
assertThat(items.get(3012600L)).isEqualTo(new SteamApiService.StoreItem("Coming Soon", null, null, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStoreItems_AsksInBatchesOf100() {
|
||||
List<Long> appids = LongStream.rangeClosed(1, 150).boxed().toList();
|
||||
server.expect(requestTo(containsString("/IStoreBrowseService/GetItems/v1/")))
|
||||
.andExpect(queryParam("input_json", allOf(containsString("%22appid%22%3A100%7D"), not(containsString("%22appid%22%3A101%7D")))))
|
||||
.andRespond(withSuccess("{\"response\": {\"store_items\": []}}", MediaType.APPLICATION_JSON));
|
||||
server.expect(requestTo(containsString("/IStoreBrowseService/GetItems/v1/")))
|
||||
.andExpect(queryParam("input_json", allOf(containsString("%22appid%22%3A101%7D"), not(containsString("%22appid%22%3A100%7D")))))
|
||||
.andRespond(withSuccess("{\"response\": {\"store_items\": []}}", MediaType.APPLICATION_JSON));
|
||||
|
||||
steamApiService.getStoreItems(appids);
|
||||
|
||||
server.verify();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user