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 349eac0806
commit adb92293ba
15 files changed
+604

No files matched your search

@@ -6,6 +6,7 @@ import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.api.dto.LibrarySummaryDto; import core_service.modules.library.api.dto.LibrarySummaryDto;
import core_service.modules.library.api.dto.PageDto; import core_service.modules.library.api.dto.PageDto;
import core_service.modules.library.api.dto.SyncStatusDto; import core_service.modules.library.api.dto.SyncStatusDto;
import core_service.modules.library.api.dto.WishlistDto;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.service.BacklogService; import core_service.modules.library.service.BacklogService;
import core_service.modules.library.service.GameDetailService; import core_service.modules.library.service.GameDetailService;
@@ -144,6 +145,11 @@ public class LibraryController {
return ResponseEntity.ok(queryService.getActivity(principal.getIdAsLong(), safeDays)); return ResponseEntity.ok(queryService.getActivity(principal.getIdAsLong(), safeDays));
} }
@GetMapping("/wishlist")
public ResponseEntity<WishlistDto> getWishlist(@AuthenticationPrincipal UserPrincipal principal) {
return ResponseEntity.ok(queryService.getWishlist(principal.getIdAsLong()));
}
@GetMapping("/summary") @GetMapping("/summary")
public ResponseEntity<LibrarySummaryDto> getSummary(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<LibrarySummaryDto> getSummary(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam(required = false) Long exclude) { @RequestParam(required = false) Long exclude) {
@@ -0,0 +1,16 @@
package core_service.modules.library.api.dto;
import java.time.Instant;
import java.util.List;
/**
* @param games newest first, without the games the user owns
*/
public record WishlistDto(List<Game> games) {
/**
* @param price formatted by Steam, e.g. "7,49€"; null when the game has no price yet
* @param discountPct 0 when not on sale
*/
public record Game(Long appid, String name, Instant addedAt, String price, String originalPrice, int discountPct) {}
}
@@ -0,0 +1,50 @@
package core_service.modules.library.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.Instant;
/**
* @see core_service.modules.library.repo.WishlistGameRepository
*/
@Entity
@Table(name = "wishlist_game")
@IdClass(WishlistGameId.class)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class WishlistGame {
@Id
@Column(name = "user_id")
private Long userId;
@Id
@Column(name = "appid")
private Long appid;
// Null when the store does not know the app (any more).
@Column(name = "name", length = 512)
private String name;
@Column(name = "added_at")
private Instant addedAt;
// Formatted by Steam for the store country, e.g. "7,49€". Null for an app without a price.
@Column(name = "price", length = 32)
private String price;
@Column(name = "original_price", length = 32)
private String originalPrice;
@Column(name = "discount_pct", nullable = false)
private int discountPct;
}
@@ -0,0 +1,18 @@
package core_service.modules.library.domain;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@EqualsAndHashCode
public class WishlistGameId implements Serializable {
private Long userId;
private Long appid;
}
@@ -0,0 +1,22 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.WishlistGame;
import core_service.modules.library.domain.WishlistGameId;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface WishlistGameRepository extends JpaRepository<WishlistGame, WishlistGameId> {
// Newest first; games without a date last.
@Query("select w from WishlistGame w where w.userId = :userId order by w.addedAt desc nulls last, w.appid")
List<WishlistGame> findForUser(@Param("userId") Long userId);
// flushAutomatically: the delete must reach the DB before the saveAll of the same appids.
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("delete from WishlistGame w where w.userId = :userId")
void deleteByUserId(@Param("userId") Long userId);
}
@@ -4,6 +4,7 @@ import core_service.modules.library.api.dto.ActivityDto;
import core_service.modules.library.api.dto.GameDto; import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.api.dto.LibrarySummaryDto; import core_service.modules.library.api.dto.LibrarySummaryDto;
import core_service.modules.library.api.dto.RecentGameDto; import core_service.modules.library.api.dto.RecentGameDto;
import core_service.modules.library.api.dto.WishlistDto;
import core_service.modules.library.domain.DayMinutes; import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
@@ -13,6 +14,7 @@ import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserSteamSyncRepository; import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.modules.library.repo.WishlistGameRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@@ -38,6 +40,7 @@ public class LibraryQueryService {
private final UserSteamSyncRepository userSteamSyncRepository; private final UserSteamSyncRepository userSteamSyncRepository;
private final SteamGameRepository steamGameRepository; private final SteamGameRepository steamGameRepository;
private final PlaytimeDayRepository playtimeDayRepository; private final PlaytimeDayRepository playtimeDayRepository;
private final WishlistGameRepository wishlistGameRepository;
@Value("${app.library.zone:Europe/Berlin}") @Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone; private ZoneId zone;
@@ -102,6 +105,13 @@ public class LibraryQueryService {
return new ActivityDto(perDay, total, recentGames); return new ActivityDto(perDay, total, recentGames);
} }
public WishlistDto getWishlist(Long userId) {
return new WishlistDto(wishlistGameRepository.findForUser(userId).stream()
.map(w -> new WishlistDto.Game(w.getAppid(), w.getName(), w.getAddedAt(),
w.getPrice(), w.getOriginalPrice(), w.getDiscountPct()))
.toList());
}
private static GameDto toDto(UserGame ug, SteamGame meta) { private static GameDto toDto(UserGame ug, SteamGame meta) {
return new GameDto( return new GameDto(
ug.getAppid(), ug.getAppid(),
@@ -4,10 +4,12 @@ import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.domain.UserSteamSync; 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.PlaytimeDayRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserSteamSyncRepository; 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.api.dto.response.SteamOwnedGamesResponse;
import core_service.modules.steam_adapter.service.SteamApiService; import core_service.modules.steam_adapter.service.SteamApiService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -22,6 +24,10 @@ import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import core_service.modules.library.domain.UserSteamSync.Status; import core_service.modules.library.domain.UserSteamSync.Status;
@@ -40,6 +46,7 @@ public class LibrarySyncWorker {
private final UserGameRepository userGameRepository; private final UserGameRepository userGameRepository;
private final UserSteamSyncRepository userSteamSyncRepository; private final UserSteamSyncRepository userSteamSyncRepository;
private final PlaytimeDayRepository playtimeDayRepository; private final PlaytimeDayRepository playtimeDayRepository;
private final WishlistGameRepository wishlistGameRepository;
@Value("${app.library.running-stale-minutes:5}") @Value("${app.library.running-stale-minutes:5}")
private long runningStaleMinutes; private long runningStaleMinutes;
@@ -83,9 +90,12 @@ public class LibrarySyncWorker {
if (steamIdChanged) { if (steamIdChanged) {
// The old games belong to the previous Steam account. The upserts below restore the games this account also owns. // The old games belong to the previous Steam account. The upserts below restore the games this account also owns.
userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now()); userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now());
wishlistGameRepository.deleteByUserId(userId);
} }
List<SteamOwnedGamesResponse.Game> games = steamApiService.getOwnedGames(steamId); List<SteamOwnedGamesResponse.Game> games = steamApiService.getOwnedGames(steamId);
log.info("Sync user={} fetched {} games from Steam", userId, games.size()); log.info("Sync user={} fetched {} games from Steam", userId, games.size());
// Before the empty check: an account without games can still have a public wishlist.
syncWishlist(userId, steamId, games);
if (games.isEmpty()) { if (games.isEmpty()) {
// Steam errors and a private "Game details" setting also end here. A soft-delete would wipe the library. // Steam errors and a private "Game details" setting also end here. A soft-delete would wipe the library.
log.warn("Sync user={} got no games for SteamID {}", userId, steamId); log.warn("Sync user={} got no games for SteamID {}", userId, steamId);
@@ -111,6 +121,40 @@ public class LibrarySyncWorker {
} }
} }
private void syncWishlist(Long userId, String steamId, List<SteamOwnedGamesResponse.Game> ownedGames) {
Optional<List<SteamApiService.WishlistItem>> wishlist = steamApiService.getWishlist(steamId);
if (wishlist.isEmpty()) {
log.warn("Sync user={} kept the old wishlist, Steam did not answer", userId);
return;
}
Set<Long> owned = ownedGames.stream()
.filter(g -> g.appid() != null)
.map(g -> g.appid().longValue())
.collect(Collectors.toSet());
List<SteamApiService.WishlistItem> wanted = wishlist.get().stream()
.filter(item -> !owned.contains(item.appid()))
.toList();
Map<Long, SteamApiService.StoreItem> store = wanted.isEmpty()
? Map.of()
: steamApiService.getStoreItems(wanted.stream().map(SteamApiService.WishlistItem::appid).toList());
// A failed store batch leaves apps out of `store`; they keep the name and price of the last sync.
Map<Long, WishlistGame> previous = wishlistGameRepository.findForUser(userId).stream()
.collect(Collectors.toMap(WishlistGame::getAppid, w -> w));
wishlistGameRepository.deleteByUserId(userId);
wishlistGameRepository.saveAll(wanted.stream().map(item -> {
SteamApiService.StoreItem info = store.get(item.appid());
if (info != null) {
return new WishlistGame(userId, item.appid(), info.name(), item.addedAt(),
info.price(), info.originalPrice(), info.discountPct());
}
WishlistGame old = previous.get(item.appid());
return old == null
? new WishlistGame(userId, item.appid(), null, item.addedAt(), null, null, 0)
: new WishlistGame(userId, item.appid(), old.getName(), item.addedAt(),
old.getPrice(), old.getOriginalPrice(), old.getDiscountPct());
}).toList());
}
private void upsertGame(SteamOwnedGamesResponse.Game g, Instant now) { private void upsertGame(SteamOwnedGamesResponse.Game g, Instant now) {
Long appid = g.appid().longValue(); Long appid = g.appid().longValue();
SteamGame existing = steamGameRepository.findById(appid).orElse(null); SteamGame existing = steamGameRepository.findById(appid).orElse(null);
@@ -0,0 +1,36 @@
package core_service.modules.steam_adapter.api.dto.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record SteamStoreItemsResponse(
@JsonProperty("response") StoreItemsResponse response
) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record StoreItemsResponse(
@JsonProperty("store_items") List<StoreItem> storeItems
) {}
/**
* @param success 1 for a known app
* @param bestPurchaseOption null for an app without a price, e.g. not released yet
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record StoreItem(
@JsonProperty("appid") Long appid,
@JsonProperty("success") Integer success,
@JsonProperty("name") String name,
@JsonProperty("best_purchase_option") PurchaseOption bestPurchaseOption
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record PurchaseOption(
@JsonProperty("formatted_final_price") String formattedFinalPrice,
@JsonProperty("formatted_original_price") String formattedOriginalPrice,
@JsonProperty("discount_pct") Integer discountPct
) {}
}
@@ -0,0 +1,26 @@
package core_service.modules.steam_adapter.api.dto.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record SteamWishlistResponse(
@JsonProperty("response") WishlistResponse response
) {
/**
* @param items null for an empty or private wishlist
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record WishlistResponse(
@JsonProperty("items") List<Item> items
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Item(
@JsonProperty("appid") Long appid,
@JsonProperty("date_added") Long dateAdded
) {}
}
@@ -5,6 +5,8 @@ import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievemen
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse; import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamProfileAndGamesResponse; import core_service.modules.steam_adapter.api.dto.response.SteamProfileAndGamesResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse; import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamStoreItemsResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamWishlistResponse;
import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.HttpClientErrorException;
import io.github.resilience4j.ratelimiter.annotation.RateLimiter; import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import io.github.resilience4j.retry.annotation.Retry; import io.github.resilience4j.retry.annotation.Retry;
@@ -15,9 +17,13 @@ import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient;
import java.time.Instant;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -26,9 +32,17 @@ public class SteamApiService {
private final RestClient steamRestClient; private final RestClient steamRestClient;
private static final int STORE_ITEMS_BATCH = 100;
@Value("${app.steam.api-key}") @Value("${app.steam.api-key}")
private String apiKey; private String apiKey;
@Value("${app.steam.store-language:german}")
private String storeLanguage;
@Value("${app.steam.store-country:DE}")
private String storeCountry;
@Cacheable(value = "steamGameCount", key = "#steamId") @Cacheable(value = "steamGameCount", key = "#steamId")
@RateLimiter(name = "steamApi") @RateLimiter(name = "steamApi")
@Retry(name = "steamApi") @Retry(name = "steamApi")
@@ -142,6 +156,91 @@ public class SteamApiService {
return Collections.emptyList(); return Collections.emptyList();
} }
public record WishlistItem(long appid, Instant addedAt) {}
/**
* @param discountPct 0 when not on sale
*/
public record StoreItem(String name, String price, String originalPrice, int discountPct) {}
/**
* Needs a public wishlist; a private one comes back as an empty list, like an empty one.
*
* @return empty on a Steam error, so the caller can keep the list it has
*/
@RateLimiter(name = "steamApi")
@Retry(name = "steamApi")
public Optional<List<WishlistItem>> getWishlist(String steamId) {
try {
SteamWishlistResponse response = steamRestClient.get()
.uri(uriBuilder -> uriBuilder
.path("/IWishlistService/GetWishlist/v1/")
.queryParam("key", apiKey)
.queryParam("steamid", steamId)
.build())
.retrieve()
.body(SteamWishlistResponse.class);
if (response == null || response.response() == null || response.response().items() == null) {
return Optional.of(List.of());
}
return Optional.of(response.response().items().stream()
.filter(item -> item.appid() != null)
.map(item -> new WishlistItem(item.appid(),
item.dateAdded() != null ? Instant.ofEpochSecond(item.dateAdded()) : null))
.toList());
} catch (Exception e) {
log.error("Error communicating with Steam API for the wishlist of SteamID: {}", steamId, e);
return Optional.empty();
}
}
/**
* Store name and current price per app, asked in batches of {@value #STORE_ITEMS_BATCH}. Apps Steam does not
* know, and batches that fail, are missing from the result. One rate-limit permit covers all batches; a
* wishlist rarely has more than one.
*/
@RateLimiter(name = "steamApi")
public Map<Long, StoreItem> getStoreItems(List<Long> appids) {
Map<Long, StoreItem> result = new HashMap<>();
for (int from = 0; from < appids.size(); from += STORE_ITEMS_BATCH) {
result.putAll(getStoreItemsBatch(appids.subList(from, Math.min(from + STORE_ITEMS_BATCH, appids.size()))));
}
return result;
}
private Map<Long, StoreItem> getStoreItemsBatch(List<Long> appids) {
String ids = appids.stream().map(id -> "{\"appid\":" + id + "}").collect(Collectors.joining(","));
String inputJson = "{\"ids\":[" + ids + "],\"context\":{\"language\":\"" + storeLanguage
+ "\",\"country_code\":\"" + storeCountry + "\"},\"data_request\":{}}";
try {
SteamStoreItemsResponse response = steamRestClient.get()
.uri(uriBuilder -> uriBuilder
.path("/IStoreBrowseService/GetItems/v1/")
.queryParam("key", apiKey)
.queryParam("input_json", "{inputJson}")
.build(inputJson))
.retrieve()
.body(SteamStoreItemsResponse.class);
if (response == null || response.response() == null || response.response().storeItems() == null) {
return Map.of();
}
Map<Long, StoreItem> items = new HashMap<>();
for (SteamStoreItemsResponse.StoreItem item : response.response().storeItems()) {
if (item.appid() == null || !Integer.valueOf(1).equals(item.success())) continue;
SteamStoreItemsResponse.PurchaseOption price = item.bestPurchaseOption();
items.put(item.appid(), new StoreItem(
item.name(),
price != null ? price.formattedFinalPrice() : null,
price != null ? price.formattedOriginalPrice() : null,
price != null && price.discountPct() != null ? price.discountPct() : 0));
}
return items;
} catch (Exception e) {
log.error("Error communicating with Steam API for store items {}", appids, e);
return Map.of();
}
}
/** /**
* Per-game achievement schema (global, language-aware). * Per-game achievement schema (global, language-aware).
* Returns empty when game has no achievements or Steam returns no schema. * Returns empty when game has no achievements or Steam returns no schema.
@@ -0,0 +1,12 @@
-- The user's Steam wishlist without the games already owned. The sync replaces it as a whole.
-- Name and price come from the Steam store at sync time.
CREATE TABLE wishlist_game (
user_id BIGINT NOT NULL,
appid BIGINT NOT NULL,
name VARCHAR(512),
added_at TIMESTAMPTZ,
price VARCHAR(32),
original_price VARCHAR(32),
discount_pct INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, appid)
);
@@ -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.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.domain.UserSteamSync; 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.PlaytimeDayRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserSteamSyncRepository; 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.api.dto.response.SteamOwnedGamesResponse;
import core_service.modules.steam_adapter.service.SteamApiService; import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.test.AbstractUnitTest; 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.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
@@ -24,13 +27,16 @@ import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -87,6 +93,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock @Mock
private PlaytimeDayRepository playtimeDayRepository; private PlaytimeDayRepository playtimeDayRepository;
@Mock
private WishlistGameRepository wishlistGameRepository;
@InjectMocks @InjectMocks
private LibrarySyncWorker worker; private LibrarySyncWorker worker;
@@ -255,4 +264,91 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
assertEquals(75, existing.getPlaytime2Weeks()); 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.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; 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.containsString;
import static org.hamcrest.CoreMatchers.not;
import static org.assertj.core.api.Assertions.assertThat; 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.queryParam;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; 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; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class SteamApiServiceTest { class SteamApiServiceTest {
@@ -188,4 +194,68 @@ class SteamApiServiceTest {
assertThat(result.get().games()).hasSize(1); assertThat(result.get().games()).hasSize(1);
assertThat(result.get().games().getFirst().name()).isEqualTo("Counter-Strike"); 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();
}
} }