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
@@ -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);
|
||||||
|
|||||||
+36
@@ -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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+26
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user