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 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 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 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. - **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 - **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 (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 | | Method | Path | Auth |
|---|---|---| |---|---|---|
| `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name) | JWT | | `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 | | `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | 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 | | `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/achievements/summary` | JWT |
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | 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/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 | | `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/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 | | `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) { if (userId == null) {
return Optional.empty(); return Optional.empty();
} }
return Optional.of(new UserPrincipal(userId, claims.get("steamId", Long.class), return Optional.of(new UserPrincipal(userId, claims.get("steamId", Long.class), role(claims)));
Role.parse(claims.get(ROLE_CLAIM) instanceof String role ? role : null)));
} catch (Exception e) { } catch (Exception e) {
return Optional.empty(); 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) { private static Set<String> names(List<?> claim) {
return claim.stream().filter(String.class::isInstance).map(String.class::cast).collect(Collectors.toSet()); 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. // 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> profileStats = claims.get("profileStats") instanceof List<?> groups ? names(groups) : Set.of();
Set<String> sections = claims.get("sections") instanceof List<?> shown ? names(shown) : null; 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) { } catch (Exception e) {
return Optional.empty(); 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; 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.api.dto.WalletDto;
import core_service.modules.rewards.domain.LookSlot;
import core_service.modules.rewards.service.RewardService; import core_service.modules.rewards.service.RewardService;
import core_service.shared.api.ErrorResponse;
import core_service.shared.security.UserPrincipal; import core_service.shared.security.UserPrincipal;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping; 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.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -24,4 +34,29 @@ public class RewardController {
public ResponseEntity<WalletDto> getWallet(@AuthenticationPrincipal UserPrincipal principal) { public ResponseEntity<WalletDto> getWallet(@AuthenticationPrincipal UserPrincipal principal) {
return ResponseEntity.ok(WalletDto.of(rewardService.wallet(principal.getIdAsLong(), principal.role()))); 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.RewardEntry;
import core_service.modules.rewards.domain.RewardKind; import core_service.modules.rewards.domain.RewardKind;
import core_service.modules.rewards.domain.RewardRules; 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.RewardFactsRepository;
import core_service.modules.rewards.repo.RewardLedgerRepository; import core_service.modules.rewards.repo.RewardLedgerRepository;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole; import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role; import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -64,6 +66,53 @@ public class RewardService {
ledger.history(userId, HISTORY_SIZE)); 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 * 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. * 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. * 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 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 * @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 @Test
void verifyProfileTicket_ReturnsTheUser() { 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))); 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 @Test
void verifyProfileTicket_ReadsTheSharedStatGroups() { void verifyProfileTicket_ReadsTheSharedStatGroups() {
String ticket = Jwts.builder().subject("public-profile").issuer(JwtService.ISSUER).audience().add(JwtService.PROFILE_TICKET_AUDIENCE).and().claim("profileUserId", "42") 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")) .claim("profileStats", List.of("PILE", "TREND"))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact(); .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 @Test
@@ -128,7 +139,7 @@ class JwtServiceTest extends AbstractUnitTest {
.claim("sections", List.of("PLAYING", "STATS")) .claim("sections", List.of("PLAYING", "STATS"))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact(); .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 @Test
@@ -93,6 +93,12 @@ class PublicEndpointsTest {
.andExpect(jsonPath("$.code").value("INVALID_TICKET")); .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. // Without a login: a class role marker on the way would answer 401.
@Test @Test
void publicProfileWithSharedStats_WorksWithoutALogin() throws Exception { 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; package core_service.modules.rewards.api;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.rewards.service.RewardService; import core_service.modules.rewards.service.RewardService;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc; 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.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 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 { class RewardControllerTest extends AbstractIntegrationTest {
private static final long USER = 1L; private static final long USER = 1L;
private static final long ADMIN = 2L;
private static final long GAME = 4_100_001L; private static final long GAME = 4_100_001L;
@Autowired @Autowired
@@ -34,8 +43,8 @@ class RewardControllerTest extends AbstractIntegrationTest {
@BeforeEach @BeforeEach
void clean() { void clean() {
jdbc.update("delete from reward_ledger 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 = ?", USER); 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_playtime_day where user_id = ?", USER);
jdbc.update("delete from user_game 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); Long sum = jdbc.queryForObject("select coalesce(sum(points), 0) from reward_ledger where user_id = ?", Long.class, USER);
return sum != null ? sum : 0; 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 * @param sections null for a ticket without the claim
*/ */
public static Map<String, Object> ticket(long userId, List<String> profileStats, List<String> sections) { 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); Map<String, Object> claims = claims(PUBLIC_PROFILE);
claims.put("profileUserId", String.valueOf(userId)); claims.put("profileUserId", String.valueOf(userId));
claims.put("role", role.name());
putOrRemove(claims, "profileStats", profileStats); putOrRemove(claims, "profileStats", profileStats);
putOrRemove(claims, "sections", sections); putOrRemove(claims, "sections", sections);
return claims; return claims;
@@ -98,6 +103,10 @@ public final class ContractTokens {
return sign(ticket(userId, profileStats, sections)); 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() { public static String serviceToken() {
return sign(claims(SERVICE)); return sign(claims(SERVICE));
} }
@@ -13,6 +13,7 @@
"aud": ["tbp-public-profile"], "aud": ["tbp-public-profile"],
"sub": "public-profile", "sub": "public-profile",
"profileUserId": "42", "profileUserId": "42",
"role": "USER",
"profileStats": ["OVERVIEW"], "profileStats": ["OVERVIEW"],
"sections": ["SUMMARY"] "sections": ["SUMMARY"]
}, },