From ea1e1e9aecb7d6fcf8bda7163516fc88eb87e32c Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 24 Sep 2026 13:58:44 +0200 Subject: [PATCH] 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. --- .../library/api/LibraryController.java | 6 ++ .../modules/library/api/dto/WishlistDto.java | 16 +++ .../modules/library/domain/WishlistGame.java | 50 ++++++++++ .../library/domain/WishlistGameId.java | 18 ++++ .../library/repo/WishlistGameRepository.java | 22 +++++ .../library/service/LibraryQueryService.java | 10 ++ .../library/service/LibrarySyncWorker.java | 44 +++++++++ .../dto/response/SteamStoreItemsResponse.java | 36 +++++++ .../dto/response/SteamWishlistResponse.java | 26 +++++ .../service/SteamApiService.java | 99 +++++++++++++++++++ .../resources/db/migration/V5__wishlist.sql | 12 +++ .../api/LibraryControllerWishlistTest.java | 64 ++++++++++++ .../repo/WishlistGameRepositoryTest.java | 35 +++++++ .../service/LibrarySyncWorkerTest.java | 96 ++++++++++++++++++ .../service/SteamApiServiceTest.java | 70 +++++++++++++ 15 files changed, 604 insertions(+) create mode 100644 src/main/java/core_service/modules/library/api/dto/WishlistDto.java create mode 100644 src/main/java/core_service/modules/library/domain/WishlistGame.java create mode 100644 src/main/java/core_service/modules/library/domain/WishlistGameId.java create mode 100644 src/main/java/core_service/modules/library/repo/WishlistGameRepository.java create mode 100644 src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamStoreItemsResponse.java create mode 100644 src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamWishlistResponse.java create mode 100644 src/main/resources/db/migration/V5__wishlist.sql create mode 100644 src/test/java/core_service/modules/library/api/LibraryControllerWishlistTest.java create mode 100644 src/test/java/core_service/modules/library/repo/WishlistGameRepositoryTest.java diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index c911cfc..11312fe 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -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 getWishlist(@AuthenticationPrincipal UserPrincipal principal) { + return ResponseEntity.ok(queryService.getWishlist(principal.getIdAsLong())); + } + @GetMapping("/summary") public ResponseEntity getSummary(@AuthenticationPrincipal UserPrincipal principal, @RequestParam(required = false) Long exclude) { diff --git a/src/main/java/core_service/modules/library/api/dto/WishlistDto.java b/src/main/java/core_service/modules/library/api/dto/WishlistDto.java new file mode 100644 index 0000000..c9a29ac --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/WishlistDto.java @@ -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 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) {} +} diff --git a/src/main/java/core_service/modules/library/domain/WishlistGame.java b/src/main/java/core_service/modules/library/domain/WishlistGame.java new file mode 100644 index 0000000..f324cdc --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/WishlistGame.java @@ -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; +} diff --git a/src/main/java/core_service/modules/library/domain/WishlistGameId.java b/src/main/java/core_service/modules/library/domain/WishlistGameId.java new file mode 100644 index 0000000..c93c3f8 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/WishlistGameId.java @@ -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; +} diff --git a/src/main/java/core_service/modules/library/repo/WishlistGameRepository.java b/src/main/java/core_service/modules/library/repo/WishlistGameRepository.java new file mode 100644 index 0000000..be0a607 --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/WishlistGameRepository.java @@ -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 { + + // 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 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); +} diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index 3276fa8..f770b64 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -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(), diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java index f612f3f..c5616bd 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -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 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 ownedGames) { + Optional> wishlist = steamApiService.getWishlist(steamId); + if (wishlist.isEmpty()) { + log.warn("Sync user={} kept the old wishlist, Steam did not answer", userId); + return; + } + Set owned = ownedGames.stream() + .filter(g -> g.appid() != null) + .map(g -> g.appid().longValue()) + .collect(Collectors.toSet()); + List wanted = wishlist.get().stream() + .filter(item -> !owned.contains(item.appid())) + .toList(); + Map 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 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); diff --git a/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamStoreItemsResponse.java b/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamStoreItemsResponse.java new file mode 100644 index 0000000..be0a7f1 --- /dev/null +++ b/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamStoreItemsResponse.java @@ -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 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 + ) {} +} diff --git a/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamWishlistResponse.java b/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamWishlistResponse.java new file mode 100644 index 0000000..4a02147 --- /dev/null +++ b/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamWishlistResponse.java @@ -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 items + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Item( + @JsonProperty("appid") Long appid, + @JsonProperty("date_added") Long dateAdded + ) {} +} diff --git a/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java b/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java index efb7547..6c1a5e0 100644 --- a/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java +++ b/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java @@ -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> 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 getStoreItems(List appids) { + Map 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 getStoreItemsBatch(List 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 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. diff --git a/src/main/resources/db/migration/V5__wishlist.sql b/src/main/resources/db/migration/V5__wishlist.sql new file mode 100644 index 0000000..3068a50 --- /dev/null +++ b/src/main/resources/db/migration/V5__wishlist.sql @@ -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) +); diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerWishlistTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerWishlistTest.java new file mode 100644 index 0000000..35782f3 --- /dev/null +++ b/src/test/java/core_service/modules/library/api/LibraryControllerWishlistTest.java @@ -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))); + } +} diff --git a/src/test/java/core_service/modules/library/repo/WishlistGameRepositoryTest.java b/src/test/java/core_service/modules/library/repo/WishlistGameRepositoryTest.java new file mode 100644 index 0000000..bd21d0b --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/WishlistGameRepositoryTest.java @@ -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 own = wishlistGameRepository.findForUser(1L); + assertEquals(1, own.size()); + assertEquals("4,99€", own.getFirst().getPrice()); + assertEquals(1, wishlistGameRepository.findForUser(2L).size()); + } +} diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java index 43d7e4f..997c914 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -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> 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> 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); + } } diff --git a/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java b/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java index 589b413..dc07c28 100644 --- a/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java +++ b/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java @@ -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> 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 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 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(); + } }