feat(rewards): shop, equipped looks and public looks
Build, Test & Push Core Service / quality-check (push) Successful in 1m5s
Build, Test & Push Core Service / docker (push) Successful in 1m35s

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.
This commit is contained in:
lobo committed 2026-10-01 13:17:04 +02:00
1 parent 837dad8a6f
commit 349fa40272
16 files changed
+397 -10

No files matched your search

+6 -1
View File
@@ -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 |
@@ -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<String> 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<String> profileStats = claims.get("profileStats") instanceof List<?> groups ? names(groups) : Set.of();
Set<String> 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();
}
@@ -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.<ProfileTicket>empty() : profileTicketVerifier.verifyProfileTicket(ticket))
.<ResponseEntity<?>>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")));
}
}
@@ -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<WalletDto> 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"));
};
}
}
@@ -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) {}
@@ -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<LookSlot, String> equipped) {}
@@ -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) {}
@@ -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<LookSlot, String> 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.
@@ -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<String> profileStats, Set<String> sections) {}
public record ProfileTicket(long userId, Role role, Set<String> profileStats, Set<String> sections) {}
@@ -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
@@ -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 {
@@ -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"));
}
}
@@ -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());
}
}
@@ -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<RewardService.BuyResult> first = buyAfter(go, ShopItem.BORDER_GOLD);
CompletableFuture<RewardService.BuyResult> second = buyAfter(go, ShopItem.FRAME_GOLD);
go.countDown();
List<RewardService.BuyResult> 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<RewardService.BuyResult> 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);
});
}
}
@@ -87,8 +87,13 @@ public final class ContractTokens {
* @param sections null for a ticket without the claim
*/
public static Map<String, Object> ticket(long userId, List<String> profileStats, List<String> sections) {
return ticket(userId, Role.USER, profileStats, sections);
}
public static Map<String, Object> ticket(long userId, Role role, List<String> profileStats, List<String> sections) {
Map<String, Object> 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));
}
@@ -13,6 +13,7 @@
"aud": ["tbp-public-profile"],
"sub": "public-profile",
"profileUserId": "42",
"role": "USER",
"profileStats": ["OVERVIEW"],
"sections": ["SUMMARY"]
},