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
@@ -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"]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user