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:
lobo committed 2026-09-24 13:58:44 +02:00
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();
}
}