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

@@ -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"]
},