From 349fa40272ec263a948a4325e3f6aac4908728f9 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 1 Oct 2026 13:17:04 +0200 Subject: [PATCH] feat(rewards): shop, equipped looks and public looks Add endpoints to buy items, equip looks by slot and unequip. The shop has 8 whitelisted looks: avatar borders, badges, accents and banner frames. Ownership is a BUY ledger row; PLATFORM_ADMIN owns every catalog item without buying. The public profile endpoint shares only the equipped looks filtered by ownership. --- readme.md | 7 +- .../configuration/security/JwtService.java | 9 +- .../rewards/api/PublicRewardController.java | 36 ++++++++ .../modules/rewards/api/RewardController.java | 35 +++++++ .../modules/rewards/api/dto/EquipRequest.java | 8 ++ .../rewards/api/dto/PublicLooksDto.java | 7 ++ .../rewards/api/dto/PurchaseRequest.java | 6 ++ .../rewards/service/RewardService.java | 49 ++++++++++ .../shared/security/ProfileTicket.java | 3 +- .../security/JwtServiceTest.java | 17 +++- .../security/PublicEndpointsTest.java | 6 ++ .../api/PublicRewardControllerTest.java | 63 +++++++++++++ .../rewards/api/RewardControllerTest.java | 91 ++++++++++++++++++- .../service/RewardServiceConcurrencyTest.java | 60 ++++++++++++ .../shared/test/ContractTokens.java | 9 ++ .../contract/auth-core-contract.json | 1 + 16 files changed, 397 insertions(+), 10 deletions(-) create mode 100644 src/main/java/core_service/modules/rewards/api/PublicRewardController.java create mode 100644 src/main/java/core_service/modules/rewards/api/dto/EquipRequest.java create mode 100644 src/main/java/core_service/modules/rewards/api/dto/PublicLooksDto.java create mode 100644 src/main/java/core_service/modules/rewards/api/dto/PurchaseRequest.java create mode 100644 src/test/java/core_service/modules/rewards/api/PublicRewardControllerTest.java create mode 100644 src/test/java/core_service/modules/rewards/service/RewardServiceConcurrencyTest.java diff --git a/readme.md b/readme.md index 445fcb4..75f4d1c 100644 --- a/readme.md +++ b/readme.md @@ -63,7 +63,9 @@ To Be Played is split into three repositories: snapshots, once per user, kind and game (`ref`). Rules and numbers: `RewardRules`; a Steam finish needs playtime and one proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish; old history pays nothing). The month bonus needs a finished game in that month. Games added by hand pay 15 for a finish, at most 3 per ISO week. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant - for finishes from before the status log. + for finishes from before the status log. Shop: a whitelist of 8 looks (`ShopItem`); a `BUY` row proves ownership, + `reward_equipped` holds what is worn. A platform admin owns every item (the role of the login token, or the `role` + claim of the profile ticket for visitors). - **Wishlist**: synced from Steam with store price and discount. - **Broken image repair**: the frontend reports a Steam cover or achievement icon that does not load. At 01:30 (Europe/Berlin) a job checks up to 200 reports. A cover that loads again is left alone; else the store's hashed @@ -122,6 +124,8 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | Method | Path | Auth | |---|---|---| | `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name) | JWT | +| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS`) | JWT | +| `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT | | `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT | | `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT | | `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR | @@ -141,6 +145,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `GET` | `/api/library/achievements/summary` | JWT | | `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT | | `GET` | `/api/public/profiles/library` | profile ticket | +| `GET` | `/api/public/profiles/rewards` (`{equipped}`, the worn looks) | profile ticket | | `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | public | | `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token | | `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, blocked}]}`; POST updates, PUT replaces the whole set; `204`) | service token | diff --git a/src/main/java/core_service/configuration/security/JwtService.java b/src/main/java/core_service/configuration/security/JwtService.java index 8ff9a94..fed7540 100644 --- a/src/main/java/core_service/configuration/security/JwtService.java +++ b/src/main/java/core_service/configuration/security/JwtService.java @@ -58,8 +58,7 @@ public class JwtService implements ProfileTicketVerifier { if (userId == null) { return Optional.empty(); } - return Optional.of(new UserPrincipal(userId, claims.get("steamId", Long.class), - Role.parse(claims.get(ROLE_CLAIM) instanceof String role ? role : null))); + return Optional.of(new UserPrincipal(userId, claims.get("steamId", Long.class), role(claims))); } catch (Exception e) { return Optional.empty(); } @@ -77,6 +76,10 @@ public class JwtService implements ProfileTicketVerifier { } } + private static Role role(Claims claims) { + return Role.parse(claims.get(ROLE_CLAIM) instanceof String role ? role : null); + } + private static Set names(List claim) { return claim.stream().filter(String.class::isInstance).map(String.class::cast).collect(Collectors.toSet()); } @@ -92,7 +95,7 @@ public class JwtService implements ProfileTicketVerifier { // A ticket of an Auth from before the claim has none: then the profile shows no stats. Set profileStats = claims.get("profileStats") instanceof List groups ? names(groups) : Set.of(); Set sections = claims.get("sections") instanceof List shown ? names(shown) : null; - return Optional.of(new ProfileTicket(userId, profileStats, sections)); + return Optional.of(new ProfileTicket(userId, role(claims), profileStats, sections)); } catch (Exception e) { return Optional.empty(); } diff --git a/src/main/java/core_service/modules/rewards/api/PublicRewardController.java b/src/main/java/core_service/modules/rewards/api/PublicRewardController.java new file mode 100644 index 0000000..5c22599 --- /dev/null +++ b/src/main/java/core_service/modules/rewards/api/PublicRewardController.java @@ -0,0 +1,36 @@ +package core_service.modules.rewards.api; + +import core_service.modules.rewards.api.dto.PublicLooksDto; +import core_service.modules.rewards.service.RewardService; +import core_service.shared.api.ErrorResponse; +import core_service.shared.security.ProfileTicket; +import core_service.shared.security.ProfileTicketVerifier; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Optional; + +/** + * Public, without login, like the public library: the profile ticket names the user and the role. + */ +@RestController +@RequestMapping("/api/public/profiles") +@RequiredArgsConstructor +public class PublicRewardController { + + private final RewardService rewardService; + private final ProfileTicketVerifier profileTicketVerifier; + + @GetMapping("/rewards") + public ResponseEntity getPublicLooks(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket) { + return (ticket == null ? Optional.empty() : profileTicketVerifier.verifyProfileTicket(ticket)) + .>map(t -> ResponseEntity.ok(new PublicLooksDto(rewardService.publicLooks(t.userId(), t.role())))) + .orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ErrorResponse("INVALID_TICKET", "Missing, invalid or expired profile ticket"))); + } +} diff --git a/src/main/java/core_service/modules/rewards/api/RewardController.java b/src/main/java/core_service/modules/rewards/api/RewardController.java index 819e970..7371beb 100644 --- a/src/main/java/core_service/modules/rewards/api/RewardController.java +++ b/src/main/java/core_service/modules/rewards/api/RewardController.java @@ -1,12 +1,22 @@ package core_service.modules.rewards.api; +import core_service.modules.rewards.api.dto.EquipRequest; +import core_service.modules.rewards.api.dto.PurchaseRequest; import core_service.modules.rewards.api.dto.WalletDto; +import core_service.modules.rewards.domain.LookSlot; import core_service.modules.rewards.service.RewardService; +import core_service.shared.api.ErrorResponse; import core_service.shared.security.UserPrincipal; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -24,4 +34,29 @@ public class RewardController { public ResponseEntity getWallet(@AuthenticationPrincipal UserPrincipal principal) { return ResponseEntity.ok(WalletDto.of(rewardService.wallet(principal.getIdAsLong(), principal.role()))); } + + @PostMapping("/purchases") + public ResponseEntity buy(@AuthenticationPrincipal UserPrincipal principal, @Valid @RequestBody PurchaseRequest request) { + RewardService.BuyResult result = rewardService.buy(principal.getIdAsLong(), principal.role(), request.item()); + return switch (result) { + case BOUGHT -> getWallet(principal); + case ALREADY_OWNED -> ResponseEntity.status(HttpStatus.CONFLICT) + .body(new ErrorResponse("ALREADY_OWNED", "The item is already owned")); + case NOT_ENOUGH_POINTS -> ResponseEntity.status(HttpStatus.CONFLICT) + .body(new ErrorResponse("NOT_ENOUGH_POINTS", "Not enough Pile Points")); + }; + } + + @PutMapping("/equipped/{slot}") + public ResponseEntity equip(@AuthenticationPrincipal UserPrincipal principal, @PathVariable LookSlot slot, + @RequestBody EquipRequest request) { + RewardService.EquipResult result = rewardService.equip(principal.getIdAsLong(), principal.role(), slot, request.item()); + return switch (result) { + case DONE -> getWallet(principal); + case NOT_OWNED -> ResponseEntity.status(HttpStatus.CONFLICT) + .body(new ErrorResponse("NOT_OWNED", "The item is not owned")); + case WRONG_SLOT -> ResponseEntity.badRequest() + .body(new ErrorResponse("WRONG_SLOT", "The item does not fit this slot")); + }; + } } diff --git a/src/main/java/core_service/modules/rewards/api/dto/EquipRequest.java b/src/main/java/core_service/modules/rewards/api/dto/EquipRequest.java new file mode 100644 index 0000000..de13868 --- /dev/null +++ b/src/main/java/core_service/modules/rewards/api/dto/EquipRequest.java @@ -0,0 +1,8 @@ +package core_service.modules.rewards.api.dto; + +import core_service.modules.rewards.domain.ShopItem; + +/** + * @param item null takes the slot's look off + */ +public record EquipRequest(ShopItem item) {} diff --git a/src/main/java/core_service/modules/rewards/api/dto/PublicLooksDto.java b/src/main/java/core_service/modules/rewards/api/dto/PublicLooksDto.java new file mode 100644 index 0000000..999d37d --- /dev/null +++ b/src/main/java/core_service/modules/rewards/api/dto/PublicLooksDto.java @@ -0,0 +1,7 @@ +package core_service.modules.rewards.api.dto; + +import core_service.modules.rewards.domain.LookSlot; + +import java.util.Map; + +public record PublicLooksDto(Map equipped) {} diff --git a/src/main/java/core_service/modules/rewards/api/dto/PurchaseRequest.java b/src/main/java/core_service/modules/rewards/api/dto/PurchaseRequest.java new file mode 100644 index 0000000..760d31b --- /dev/null +++ b/src/main/java/core_service/modules/rewards/api/dto/PurchaseRequest.java @@ -0,0 +1,6 @@ +package core_service.modules.rewards.api.dto; + +import core_service.modules.rewards.domain.ShopItem; +import jakarta.validation.constraints.NotNull; + +public record PurchaseRequest(@NotNull ShopItem item) {} diff --git a/src/main/java/core_service/modules/rewards/service/RewardService.java b/src/main/java/core_service/modules/rewards/service/RewardService.java index 5b4f3a6..f7f86c5 100644 --- a/src/main/java/core_service/modules/rewards/service/RewardService.java +++ b/src/main/java/core_service/modules/rewards/service/RewardService.java @@ -7,8 +7,10 @@ import core_service.modules.rewards.domain.Looks; import core_service.modules.rewards.domain.RewardEntry; import core_service.modules.rewards.domain.RewardKind; import core_service.modules.rewards.domain.RewardRules; +import core_service.modules.rewards.domain.ShopItem; import core_service.modules.rewards.repo.RewardFactsRepository; import core_service.modules.rewards.repo.RewardLedgerRepository; +import core_service.shared.security.Public; import core_service.shared.security.RequiresRole; import core_service.shared.security.Role; import lombok.RequiredArgsConstructor; @@ -64,6 +66,53 @@ public class RewardService { ledger.history(userId, HISTORY_SIZE)); } + public enum BuyResult { BOUGHT, ALREADY_OWNED, NOT_ENOUGH_POINTS } + + public enum EquipResult { DONE, NOT_OWNED, WRONG_SLOT } + + /** + * An admin owns every item, so never buys. + */ + @Transactional + public BuyResult buy(long userId, Role role, ShopItem item) { + ledger.lockUser(userId); + if (Looks.owned(ledger.bought(userId), role).contains(item.name())) { + return BuyResult.ALREADY_OWNED; + } + if (ledger.balance(userId) < item.price()) { + return BuyResult.NOT_ENOUGH_POINTS; + } + ledger.add(userId, new RewardEntry(RewardKind.BUY, item.name(), -item.price(), Instant.now())); + return BuyResult.BOUGHT; + } + + /** + * @param item null takes the slot's look off + */ + @Transactional + public EquipResult equip(long userId, Role role, LookSlot slot, ShopItem item) { + if (item == null) { + ledger.unequip(userId, slot); + return EquipResult.DONE; + } + if (item.slot() != slot) { + return EquipResult.WRONG_SLOT; + } + if (!Looks.owned(ledger.bought(userId), role).contains(item.name())) { + return EquipResult.NOT_OWNED; + } + ledger.equip(userId, slot, item.name()); + return EquipResult.DONE; + } + + /** + * The looks a visitor of the public profile sees; the role comes from the profile ticket. + */ + @Public + public Map publicLooks(long userId, Role role) { + return Looks.wearable(ledger.equipped(userId), Looks.owned(ledger.bought(userId), role)); + } + /** * Every active user, each in its own transaction; one failure does not stop the others. Runs after the nightly * sync and the image repair, so month and streak bonuses also reach users who do not open the app. diff --git a/src/main/java/core_service/shared/security/ProfileTicket.java b/src/main/java/core_service/shared/security/ProfileTicket.java index 5ab661a..53fcd7d 100644 --- a/src/main/java/core_service/shared/security/ProfileTicket.java +++ b/src/main/java/core_service/shared/security/ProfileTicket.java @@ -5,7 +5,8 @@ import java.util.Set; /** * A valid public-profile ticket from Auth. * + * @param role the role of the user; USER for a ticket without the claim * @param profileStats the names of the stat groups the user shows on the profile; empty for a ticket without them * @param sections the names of the profile sections the user shows; null for a ticket without them, which shows all */ -public record ProfileTicket(long userId, Set profileStats, Set sections) {} +public record ProfileTicket(long userId, Role role, Set profileStats, Set sections) {} diff --git a/src/test/java/core_service/configuration/security/JwtServiceTest.java b/src/test/java/core_service/configuration/security/JwtServiceTest.java index c280481..be8f56c 100644 --- a/src/test/java/core_service/configuration/security/JwtServiceTest.java +++ b/src/test/java/core_service/configuration/security/JwtServiceTest.java @@ -109,17 +109,28 @@ class JwtServiceTest extends AbstractUnitTest { @Test void verifyProfileTicket_ReturnsTheUser() { - assertEquals(Optional.of(new ProfileTicket(42L, Set.of(), null)), + assertEquals(Optional.of(new ProfileTicket(42L, Role.USER, Set.of(), null)), jwtService.verifyProfileTicket(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "42", 60_000))); } + @Test + void verifyProfileTicket_ReadsTheRoleAndTakesUserWithoutIt() { + String ticket = Jwts.builder().subject("public-profile").issuer(JwtService.ISSUER).audience().add(JwtService.PROFILE_TICKET_AUDIENCE).and().claim("profileUserId", "42") + .claim("role", "PLATFORM_ADMIN") + .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact(); + + assertEquals(Role.PLATFORM_ADMIN, jwtService.verifyProfileTicket(ticket).orElseThrow().role()); + assertEquals(Role.USER, jwtService.verifyProfileTicket(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "42", 60_000)) + .orElseThrow().role()); + } + @Test void verifyProfileTicket_ReadsTheSharedStatGroups() { String ticket = Jwts.builder().subject("public-profile").issuer(JwtService.ISSUER).audience().add(JwtService.PROFILE_TICKET_AUDIENCE).and().claim("profileUserId", "42") .claim("profileStats", List.of("PILE", "TREND")) .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact(); - assertEquals(Optional.of(new ProfileTicket(42L, Set.of("PILE", "TREND"), null)), jwtService.verifyProfileTicket(ticket)); + assertEquals(Optional.of(new ProfileTicket(42L, Role.USER, Set.of("PILE", "TREND"), null)), jwtService.verifyProfileTicket(ticket)); } @Test @@ -128,7 +139,7 @@ class JwtServiceTest extends AbstractUnitTest { .claim("sections", List.of("PLAYING", "STATS")) .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact(); - assertEquals(Optional.of(new ProfileTicket(42L, Set.of(), Set.of("PLAYING", "STATS"))), jwtService.verifyProfileTicket(ticket)); + assertEquals(Optional.of(new ProfileTicket(42L, Role.USER, Set.of(), Set.of("PLAYING", "STATS"))), jwtService.verifyProfileTicket(ticket)); } @Test diff --git a/src/test/java/core_service/configuration/security/PublicEndpointsTest.java b/src/test/java/core_service/configuration/security/PublicEndpointsTest.java index a61fd2a..53cee36 100644 --- a/src/test/java/core_service/configuration/security/PublicEndpointsTest.java +++ b/src/test/java/core_service/configuration/security/PublicEndpointsTest.java @@ -93,6 +93,12 @@ class PublicEndpointsTest { .andExpect(jsonPath("$.code").value("INVALID_TICKET")); } + @Test + void publicRewardsEndpointIsPublic() throws Exception { + mockMvc.perform(get("/api/public/profiles/rewards")).andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("INVALID_TICKET")); + } + // Without a login: a class role marker on the way would answer 401. @Test void publicProfileWithSharedStats_WorksWithoutALogin() throws Exception { diff --git a/src/test/java/core_service/modules/rewards/api/PublicRewardControllerTest.java b/src/test/java/core_service/modules/rewards/api/PublicRewardControllerTest.java new file mode 100644 index 0000000..a964d78 --- /dev/null +++ b/src/test/java/core_service/modules/rewards/api/PublicRewardControllerTest.java @@ -0,0 +1,63 @@ +package core_service.modules.rewards.api; + +import core_service.modules.rewards.domain.LookSlot; +import core_service.modules.rewards.repo.RewardLedgerRepository; +import core_service.shared.security.Role; +import core_service.shared.test.AbstractIntegrationTest; +import core_service.shared.test.ContractTokens; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; + +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; + +@AutoConfigureMockMvc +class PublicRewardControllerTest extends AbstractIntegrationTest { + + private static final long USER = 5L; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private RewardLedgerRepository ledger; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + void seed() { + jdbc.update("delete from reward_ledger where user_id = ?", USER); + jdbc.update("delete from reward_equipped where user_id = ?", USER); + // An admin wears a free item: only the role in the ticket makes it show. + ledger.equip(USER, LookSlot.BADGE, "BADGE_PILE_SLAYER"); + } + + @Test + void rewards_TicketOfAnAdmin_ShowsTheFreeItem() throws Exception { + mockMvc.perform(get("/api/public/profiles/rewards") + .header("X-Profile-Ticket", ContractTokens.ticketToken(USER, Role.PLATFORM_ADMIN))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.equipped.BADGE").value("BADGE_PILE_SLAYER")); + } + + @Test + void rewards_TicketOfAUser_ShowsOnlyWhatIsOwned() throws Exception { + mockMvc.perform(get("/api/public/profiles/rewards") + .header("X-Profile-Ticket", ContractTokens.ticketToken(USER, Role.USER))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.equipped").isEmpty()); + } + + @Test + void rewards_WithoutATicket_Answers401() throws Exception { + mockMvc.perform(get("/api/public/profiles/rewards")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("INVALID_TICKET")); + } +} diff --git a/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java b/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java index 4e7c1e9..bc2ef3a 100644 --- a/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java +++ b/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java @@ -1,16 +1,24 @@ package core_service.modules.rewards.api; +import core_service.configuration.security.ClientHeaderFilter; import core_service.modules.rewards.service.RewardService; +import core_service.shared.security.Role; import core_service.shared.test.AbstractIntegrationTest; +import core_service.shared.test.ContractTokens; 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.http.MediaType; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -21,6 +29,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. class RewardControllerTest extends AbstractIntegrationTest { private static final long USER = 1L; + private static final long ADMIN = 2L; private static final long GAME = 4_100_001L; @Autowired @@ -34,8 +43,8 @@ class RewardControllerTest extends AbstractIntegrationTest { @BeforeEach void clean() { - jdbc.update("delete from reward_ledger where user_id = ?", USER); - jdbc.update("delete from reward_equipped where user_id = ?", USER); + jdbc.update("delete from reward_ledger where user_id in (?, ?)", USER, ADMIN); + jdbc.update("delete from reward_equipped where user_id in (?, ?)", USER, ADMIN); jdbc.update("delete from user_game_playtime_day where user_id = ?", USER); jdbc.update("delete from user_game where user_id = ?", USER); } @@ -137,4 +146,82 @@ class RewardControllerTest extends AbstractIntegrationTest { Long sum = jdbc.queryForObject("select coalesce(sum(points), 0) from reward_ledger where user_id = ?", Long.class, USER); return sum != null ? sum : 0; } + + private void points(long userId, int points) { + jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', ?, now())", + userId, points); + } + + private static MockHttpServletRequestBuilder asAdmin( + MockHttpServletRequestBuilder request) { + return request.header("Authorization", "Bearer " + ContractTokens.loginToken(ADMIN, Role.PLATFORM_ADMIN)); + } + + private ResultActions buy(String item) throws Exception { + return mockMvc.perform(post("/api/rewards/purchases").header(ClientHeaderFilter.CLIENT_HEADER, "test") + .contentType(MediaType.APPLICATION_JSON).content("{\"item\":\"" + item + "\"}")); + } + + private ResultActions equip(String slot, String item) throws Exception { + return mockMvc.perform(put("/api/rewards/equipped/" + slot).header(ClientHeaderFilter.CLIENT_HEADER, "test") + .contentType(MediaType.APPLICATION_JSON).content("{\"item\":" + (item == null ? "null" : "\"" + item + "\"") + "}")); + } + + @Test + void buy_WithTooFewPoints_Answers409AndKeepsTheBalance() throws Exception { + points(USER, 99); + + buy("BADGE_PILE_SLAYER").andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("NOT_ENOUGH_POINTS")); + + mockMvc.perform(get("/api/rewards/wallet")).andExpect(jsonPath("$.balance").value(99)); + } + + @Test + void buy_WithEnoughPoints_BooksTheSpendAndOwnsTheItem() throws Exception { + points(USER, 150); + + buy("BADGE_PILE_SLAYER").andExpect(status().isOk()) + .andExpect(jsonPath("$.balance").value(50)) + .andExpect(jsonPath("$.owned[0]").value("BADGE_PILE_SLAYER")) + .andExpect(jsonPath("$.history[0].kind").value("BUY")) + .andExpect(jsonPath("$.history[0].points").value(-100)); + + buy("BADGE_PILE_SLAYER").andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("ALREADY_OWNED")); + } + + @Test + void equip_NotOwned_Answers409_OwnedShowsInTheWalletAndNullTakesItOff() throws Exception { + points(USER, 150); + + equip("BADGE", "BADGE_PILE_SLAYER").andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("NOT_OWNED")); + + buy("BADGE_PILE_SLAYER"); + equip("BADGE", "BADGE_PILE_SLAYER").andExpect(status().isOk()) + .andExpect(jsonPath("$.equipped.BADGE").value("BADGE_PILE_SLAYER")); + equip("BADGE", null).andExpect(status().isOk()).andExpect(jsonPath("$.equipped.BADGE").doesNotExist()); + } + + @Test + void equip_ItemOfAnotherSlot_Answers400() throws Exception { + points(USER, 150); + buy("BADGE_PILE_SLAYER"); + + equip("ACCENT", "BADGE_PILE_SLAYER").andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("WRONG_SLOT")); + } + + @Test + void admin_OwnsEveryItem_NeverBuysAndFallsBackToDefaultAsAUser() throws Exception { + mockMvc.perform(asAdmin(get("/api/rewards/wallet"))) + .andExpect(jsonPath("$.owned.length()").value(8)); + mockMvc.perform(asAdmin(post("/api/rewards/purchases")).header(ClientHeaderFilter.CLIENT_HEADER, "test") + .contentType(MediaType.APPLICATION_JSON).content("{\"item\":\"FRAME_GOLD\"}")) + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("ALREADY_OWNED")); + mockMvc.perform(asAdmin(put("/api/rewards/equipped/BANNER_FRAME")).header(ClientHeaderFilter.CLIENT_HEADER, "test") + .contentType(MediaType.APPLICATION_JSON).content("{\"item\":\"FRAME_GOLD\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.equipped.BANNER_FRAME").value("FRAME_GOLD")); + + mockMvc.perform(get("/api/rewards/wallet").header("Authorization", "Bearer " + ContractTokens.loginToken(ADMIN))) + .andExpect(jsonPath("$.owned").isEmpty()) + .andExpect(jsonPath("$.equipped").isEmpty()); + } } diff --git a/src/test/java/core_service/modules/rewards/service/RewardServiceConcurrencyTest.java b/src/test/java/core_service/modules/rewards/service/RewardServiceConcurrencyTest.java new file mode 100644 index 0000000..e498231 --- /dev/null +++ b/src/test/java/core_service/modules/rewards/service/RewardServiceConcurrencyTest.java @@ -0,0 +1,60 @@ +package core_service.modules.rewards.service; + +import core_service.modules.rewards.domain.ShopItem; +import core_service.shared.security.Role; +import core_service.shared.test.AbstractIntegrationTest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class RewardServiceConcurrencyTest extends AbstractIntegrationTest { + + private static final long USER = 6L; + + @Autowired + private RewardService rewardService; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + @AfterEach + void clean() { + jdbc.update("delete from reward_ledger where user_id = ?", USER); + } + + @Test + void twoParallelBuysOfTheLastPoints_OnlyOneSucceeds() throws Exception { + jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', 150, now())", USER); + CountDownLatch go = new CountDownLatch(1); + + // Two different items: only the balance check can stop the second one. + CompletableFuture first = buyAfter(go, ShopItem.BORDER_GOLD); + CompletableFuture second = buyAfter(go, ShopItem.FRAME_GOLD); + go.countDown(); + + List results = List.of(first.get(30, TimeUnit.SECONDS), second.get(30, TimeUnit.SECONDS)); + assertThat(results).containsExactlyInAnyOrder(RewardService.BuyResult.BOUGHT, RewardService.BuyResult.NOT_ENOUGH_POINTS); + assertThat(rewardService.wallet(USER, Role.USER).balance()).isZero(); + } + + private CompletableFuture buyAfter(CountDownLatch go, ShopItem item) { + return CompletableFuture.supplyAsync(() -> { + try { + go.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return rewardService.buy(USER, Role.USER, item); + }); + } +} diff --git a/src/test/java/core_service/shared/test/ContractTokens.java b/src/test/java/core_service/shared/test/ContractTokens.java index 2de168c..85e4cad 100644 --- a/src/test/java/core_service/shared/test/ContractTokens.java +++ b/src/test/java/core_service/shared/test/ContractTokens.java @@ -87,8 +87,13 @@ public final class ContractTokens { * @param sections null for a ticket without the claim */ public static Map ticket(long userId, List profileStats, List sections) { + return ticket(userId, Role.USER, profileStats, sections); + } + + public static Map ticket(long userId, Role role, List profileStats, List sections) { Map claims = claims(PUBLIC_PROFILE); claims.put("profileUserId", String.valueOf(userId)); + claims.put("role", role.name()); putOrRemove(claims, "profileStats", profileStats); putOrRemove(claims, "sections", sections); return claims; @@ -98,6 +103,10 @@ public final class ContractTokens { return sign(ticket(userId, profileStats, sections)); } + public static String ticketToken(long userId, Role role) { + return sign(ticket(userId, role, null, null)); + } + public static String serviceToken() { return sign(claims(SERVICE)); } diff --git a/src/test/resources/contract/auth-core-contract.json b/src/test/resources/contract/auth-core-contract.json index 4cffd65..a296e79 100644 --- a/src/test/resources/contract/auth-core-contract.json +++ b/src/test/resources/contract/auth-core-contract.json @@ -13,6 +13,7 @@ "aud": ["tbp-public-profile"], "sub": "public-profile", "profileUserId": "42", + "role": "USER", "profileStats": ["OVERVIEW"], "sections": ["SUMMARY"] },