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.PageDto;
|
||||
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.service.BacklogService;
|
||||
import core_service.modules.library.service.GameDetailService;
|
||||
@@ -144,6 +145,11 @@ public class LibraryController {
|
||||
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")
|
||||
public ResponseEntity<LibrarySummaryDto> getSummary(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@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.LibrarySummaryDto;
|
||||
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.GameStatus;
|
||||
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.UserGameRepository;
|
||||
import core_service.modules.library.repo.UserSteamSyncRepository;
|
||||
import core_service.modules.library.repo.WishlistGameRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
@@ -38,6 +40,7 @@ public class LibraryQueryService {
|
||||
private final UserSteamSyncRepository userSteamSyncRepository;
|
||||
private final SteamGameRepository steamGameRepository;
|
||||
private final PlaytimeDayRepository playtimeDayRepository;
|
||||
private final WishlistGameRepository wishlistGameRepository;
|
||||
|
||||
@Value("${app.library.zone:Europe/Berlin}")
|
||||
private ZoneId zone;
|
||||
@@ -102,6 +105,13 @@ public class LibraryQueryService {
|
||||
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) {
|
||||
return new GameDto(
|
||||
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.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 lombok.RequiredArgsConstructor;
|
||||
@@ -22,6 +24,10 @@ 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 java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import core_service.modules.library.domain.UserSteamSync.Status;
|
||||
|
||||
@@ -40,6 +46,7 @@ public class LibrarySyncWorker {
|
||||
private final UserGameRepository userGameRepository;
|
||||
private final UserSteamSyncRepository userSteamSyncRepository;
|
||||
private final PlaytimeDayRepository playtimeDayRepository;
|
||||
private final WishlistGameRepository wishlistGameRepository;
|
||||
|
||||
@Value("${app.library.running-stale-minutes:5}")
|
||||
private long runningStaleMinutes;
|
||||
@@ -83,9 +90,12 @@ public class LibrarySyncWorker {
|
||||
if (steamIdChanged) {
|
||||
// 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());
|
||||
wishlistGameRepository.deleteByUserId(userId);
|
||||
}
|
||||
List<SteamOwnedGamesResponse.Game> games = steamApiService.getOwnedGames(steamId);
|
||||
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()) {
|
||||
// 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);
|
||||
@@ -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) {
|
||||
Long appid = g.appid().longValue();
|
||||
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.SteamProfileAndGamesResponse;
|
||||
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 io.github.resilience4j.ratelimiter.annotation.RateLimiter;
|
||||
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.web.client.RestClient;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -26,9 +32,17 @@ public class SteamApiService {
|
||||
|
||||
private final RestClient steamRestClient;
|
||||
|
||||
private static final int STORE_ITEMS_BATCH = 100;
|
||||
|
||||
@Value("${app.steam.api-key}")
|
||||
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")
|
||||
@RateLimiter(name = "steamApi")
|
||||
@Retry(name = "steamApi")
|
||||
@@ -142,6 +156,91 @@ public class SteamApiService {
|
||||
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).
|
||||
* 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.UserGameId;
|
||||
import core_service.modules.library.domain.UserSteamSync;
|
||||
import core_service.modules.library.domain.WishlistGame;
|
||||
import core_service.modules.library.repo.PlaytimeDayRepository;
|
||||
import core_service.modules.library.repo.SteamGameRepository;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
import core_service.modules.library.repo.UserSteamSyncRepository;
|
||||
import core_service.modules.library.repo.WishlistGameRepository;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
|
||||
import core_service.modules.steam_adapter.service.SteamApiService;
|
||||
import core_service.shared.test.AbstractUnitTest;
|
||||
@@ -16,6 +18,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
@@ -24,13 +27,16 @@ import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -87,6 +93,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
@Mock
|
||||
private PlaytimeDayRepository playtimeDayRepository;
|
||||
|
||||
@Mock
|
||||
private WishlistGameRepository wishlistGameRepository;
|
||||
|
||||
@InjectMocks
|
||||
private LibrarySyncWorker worker;
|
||||
|
||||
@@ -255,4 +264,91 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
|
||||
assertEquals(75, existing.getPlaytime2Weeks());
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void runSync_ReplacesTheWishlistWithoutOwnedGames() {
|
||||
steamReturns(game(0, 0), STEAM_ID);
|
||||
Instant added = Instant.ofEpochSecond(1706021901);
|
||||
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.of(List.of(
|
||||
new SteamApiService.WishlistItem(220L, added),
|
||||
new SteamApiService.WishlistItem(1257610L, added))));
|
||||
when(steamApiService.getStoreItems(List.of(1257610L))).thenReturn(Map.of(
|
||||
1257610L, new SteamApiService.StoreItem("Prison Architect 2", "7,49€", "29,99€", 75)));
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
ArgumentCaptor<List<WishlistGame>> saved = ArgumentCaptor.forClass(List.class);
|
||||
InOrder order = inOrder(wishlistGameRepository);
|
||||
order.verify(wishlistGameRepository).deleteByUserId(USER_ID);
|
||||
order.verify(wishlistGameRepository).saveAll(saved.capture());
|
||||
assertEquals(1, saved.getValue().size());
|
||||
WishlistGame wish = saved.getValue().getFirst();
|
||||
assertEquals(1257610L, wish.getAppid());
|
||||
assertEquals("Prison Architect 2", wish.getName());
|
||||
assertEquals("7,49€", wish.getPrice());
|
||||
assertEquals(75, wish.getDiscountPct());
|
||||
assertEquals(added, wish.getAddedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_WishlistError_KeepsTheOldWishlistAndTheLibrarySyncIsOk() {
|
||||
steamReturns(game(0, 0), STEAM_ID);
|
||||
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.empty());
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(wishlistGameRepository, never()).deleteByUserId(anyLong());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("OK"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_NoOwnedGames_StillSyncsTheWishlist() {
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of());
|
||||
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.of(List.of(new SteamApiService.WishlistItem(1257610L, null))));
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(wishlistGameRepository).saveAll(anyList());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("FAILED"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void runSync_StoreBatchFailed_KeepsNameAndPriceOfTheLastSync() {
|
||||
steamReturns(game(0, 0), STEAM_ID);
|
||||
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.of(List.of(new SteamApiService.WishlistItem(1257610L, null))));
|
||||
when(steamApiService.getStoreItems(List.of(1257610L))).thenReturn(Map.of());
|
||||
when(wishlistGameRepository.findForUser(USER_ID)).thenReturn(List.of(
|
||||
new WishlistGame(USER_ID, 1257610L, "Prison Architect 2", null, "29,99€", null, 0)));
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
ArgumentCaptor<List<WishlistGame>> saved = ArgumentCaptor.forClass(List.class);
|
||||
verify(wishlistGameRepository).saveAll(saved.capture());
|
||||
assertEquals("Prison Architect 2", saved.getValue().getFirst().getName());
|
||||
assertEquals("29,99€", saved.getValue().getFirst().getPrice());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_SteamIdChanged_WishlistError_RemovesTheOldAccountsWishlist() {
|
||||
steamReturns(game(0, 0), OTHER_STEAM_ID);
|
||||
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.empty());
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(wishlistGameRepository).deleteByUserId(USER_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_SteamIdChanged_LibraryFailed_RemovesTheOldAccountsWishlist() {
|
||||
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(syncRow(OTHER_STEAM_ID, OTHER_STEAM_ID)));
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of());
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(wishlistGameRepository).deleteByUserId(USER_ID);
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,19 @@ import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.LongStream;
|
||||
|
||||
import static org.hamcrest.CoreMatchers.allOf;
|
||||
import static org.hamcrest.CoreMatchers.containsString;
|
||||
import static org.hamcrest.CoreMatchers.not;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.queryParam;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
class SteamApiServiceTest {
|
||||
@@ -188,4 +194,68 @@ class SteamApiServiceTest {
|
||||
assertThat(result.get().games()).hasSize(1);
|
||||
assertThat(result.get().games().getFirst().name()).isEqualTo("Counter-Strike");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWishlist_ReturnsItemsWithTheDateAdded() {
|
||||
server.expect(requestTo(containsString("/IWishlistService/GetWishlist/v1/")))
|
||||
.andExpect(queryParam("steamid", "76561197960435530"))
|
||||
.andRespond(withSuccess("""
|
||||
{"response": {"items": [{"appid": 1257610, "priority": 0, "date_added": 1706021901}]}}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
Optional<List<SteamApiService.WishlistItem>> result = steamApiService.getWishlist("76561197960435530");
|
||||
|
||||
assertThat(result).contains(List.of(new SteamApiService.WishlistItem(1257610L, Instant.ofEpochSecond(1706021901))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWishlist_PrivateOrEmpty_IsAnEmptyList() {
|
||||
server.expect(requestTo(containsString("/IWishlistService/GetWishlist/v1/")))
|
||||
.andRespond(withSuccess("{\"response\": {}}", MediaType.APPLICATION_JSON));
|
||||
|
||||
assertThat(steamApiService.getWishlist("76561197960435530")).contains(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWishlist_SteamError_IsEmptySoTheCallerKeepsTheOldList() {
|
||||
server.expect(requestTo(containsString("/IWishlistService/GetWishlist/v1/")))
|
||||
.andRespond(withServerError());
|
||||
|
||||
assertThat(steamApiService.getWishlist("76561197960435530")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStoreItems_ReadsNameAndPriceAndSkipsUnknownApps() {
|
||||
server.expect(requestTo(containsString("/IStoreBrowseService/GetItems/v1/")))
|
||||
.andExpect(queryParam("input_json", containsString("%22appid%22%3A620")))
|
||||
.andRespond(withSuccess("""
|
||||
{"response": {"store_items": [
|
||||
{"appid": 620, "success": 1, "name": "Portal 2",
|
||||
"best_purchase_option": {"formatted_final_price": "2,43€", "formatted_original_price": "9,75€", "discount_pct": 75}},
|
||||
{"appid": 3012600, "success": 1, "name": "Coming Soon"},
|
||||
{"id": 42, "success": 2}
|
||||
]}}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
Map<Long, SteamApiService.StoreItem> items = steamApiService.getStoreItems(List.of(620L, 3012600L, 42L));
|
||||
|
||||
assertThat(items).containsOnlyKeys(620L, 3012600L);
|
||||
assertThat(items.get(620L)).isEqualTo(new SteamApiService.StoreItem("Portal 2", "2,43€", "9,75€", 75));
|
||||
assertThat(items.get(3012600L)).isEqualTo(new SteamApiService.StoreItem("Coming Soon", null, null, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStoreItems_AsksInBatchesOf100() {
|
||||
List<Long> appids = LongStream.rangeClosed(1, 150).boxed().toList();
|
||||
server.expect(requestTo(containsString("/IStoreBrowseService/GetItems/v1/")))
|
||||
.andExpect(queryParam("input_json", allOf(containsString("%22appid%22%3A100%7D"), not(containsString("%22appid%22%3A101%7D")))))
|
||||
.andRespond(withSuccess("{\"response\": {\"store_items\": []}}", MediaType.APPLICATION_JSON));
|
||||
server.expect(requestTo(containsString("/IStoreBrowseService/GetItems/v1/")))
|
||||
.andExpect(queryParam("input_json", allOf(containsString("%22appid%22%3A101%7D"), not(containsString("%22appid%22%3A100%7D")))))
|
||||
.andRespond(withSuccess("{\"response\": {\"store_items\": []}}", MediaType.APPLICATION_JSON));
|
||||
|
||||
steamApiService.getStoreItems(appids);
|
||||
|
||||
server.verify();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user