Public Access
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.
This commit is contained in:
1 parent
837dad8a6f
commit
349fa40272
16 files changed
+397
-10
No files matched your search
@@ -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"]
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user