fix: allow editing blind box pools referencing inactive gifts
Some checks failed
Build and Push Backend / docker (push) Failing after 7s

This commit is contained in:
irving
2025-11-10 21:59:59 -05:00
parent 4fdcf6ddbd
commit 7b6943d391
6 changed files with 698 additions and 27 deletions

View File

@@ -0,0 +1,257 @@
package com.starry.admin.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.hasItem;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
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;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.starry.admin.common.apitest.ApiTestDataSeeder;
import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
import com.starry.admin.modules.blindbox.module.constant.BlindBoxConfigStatus;
import com.starry.admin.modules.blindbox.module.constant.BlindBoxPoolStatus;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity;
import com.starry.admin.modules.blindbox.service.BlindBoxConfigService;
import com.starry.admin.modules.shop.module.constant.GiftHistory;
import com.starry.admin.modules.shop.module.constant.GiftState;
import com.starry.admin.modules.shop.module.constant.GiftType;
import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity;
import com.starry.admin.modules.shop.service.IPlayGiftInfoService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
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.http.MediaType;
import org.springframework.test.web.servlet.MvcResult;
class BlindBoxPoolControllerApiTest extends AbstractApiTest {
private static final String TEST_BLIND_BOX_ID = "blindbox-admin-api";
private static final DateTimeFormatter DATE_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Autowired
private BlindBoxConfigService blindBoxConfigService;
@Autowired
private IPlayGiftInfoService giftInfoService;
@Autowired
private BlindBoxPoolMapper blindBoxPoolMapper;
private final ObjectMapper objectMapper = new ObjectMapper();
private final List<Long> poolIdsToCleanup = new ArrayList<>();
private final List<String> giftIdsToCleanup = new ArrayList<>();
@BeforeEach
void setUp() {
ensureTenantContext();
ensureBlindBoxConfig();
}
@AfterEach
void tearDown() {
ensureTenantContext();
if (!poolIdsToCleanup.isEmpty()) {
blindBoxPoolMapper.deleteBatchIds(poolIdsToCleanup);
poolIdsToCleanup.clear();
}
if (!giftIdsToCleanup.isEmpty()) {
giftInfoService.removeByIds(giftIdsToCleanup);
giftIdsToCleanup.clear();
}
}
@Test
// 测试用例:校验奖池管理 API 的新增、更新(禁用/修改权重时间库存)、删除以及礼物选项查询功能,
// 确保后台盲盒奖池的所有按钮都能正常调用并持久化。
void adminCanCreateUpdateToggleAndDeletePoolEntries() throws Exception {
PlayGiftInfoEntity freshlyAddedGift = seedGift("API盲盒新礼物");
giftIdsToCleanup.add(freshlyAddedGift.getId());
mockMvc.perform(get("/play/blind-box/pool/gifts")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.param("keyword", "盲盒新礼物"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data[*].id", hasItem(freshlyAddedGift.getId())));
LocalDateTime now = LocalDateTime.now().withNano(0);
long configurableEntryId = createPoolEntryViaApi(
ApiTestDataSeeder.DEFAULT_GIFT_ID,
new BigDecimal("25.88"),
40,
8,
BlindBoxPoolStatus.ENABLED.getCode(),
now.minusDays(1),
now.plusDays(5));
LocalDateTime updatedFrom = now.minusHours(2);
LocalDateTime updatedTo = now.plusDays(10);
ObjectNode updatePayload = objectMapper.createObjectNode();
updatePayload.put("blindBoxId", TEST_BLIND_BOX_ID);
updatePayload.put("rewardGiftId", ApiTestDataSeeder.DEFAULT_GIFT_ID);
updatePayload.put("rewardPrice", "35.66");
updatePayload.put("weight", 75);
updatePayload.put("remainingStock", 3);
updatePayload.put("status", BlindBoxPoolStatus.DISABLED.getCode());
updatePayload.put("validFrom", updatedFrom.format(DATE_TIME_FORMATTER));
updatePayload.put("validTo", updatedTo.format(DATE_TIME_FORMATTER));
mockMvc.perform(put("/play/blind-box/pool/" + configurableEntryId)
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.contentType(MediaType.APPLICATION_JSON)
.content(updatePayload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.status").value(BlindBoxPoolStatus.DISABLED.getCode()))
.andExpect(jsonPath("$.data.weight").value(75))
.andExpect(jsonPath("$.data.remainingStock").value(3));
ensureTenantContext();
BlindBoxPoolEntity updated = blindBoxPoolMapper.selectById(configurableEntryId);
assertThat(updated).isNotNull();
assertThat(updated.getStatus()).isEqualTo(BlindBoxPoolStatus.DISABLED.getCode());
assertThat(updated.getWeight()).isEqualTo(75);
assertThat(updated.getRemainingStock()).isEqualTo(3);
assertThat(updated.getValidFrom()).isEqualTo(updatedFrom);
assertThat(updated.getValidTo()).isEqualTo(updatedTo);
long reusableEntryId = createPoolEntryViaApi(
freshlyAddedGift.getId(),
new BigDecimal("18.50"),
10,
1,
BlindBoxPoolStatus.ENABLED.getCode(),
now.minusDays(2),
now.plusDays(3));
updateGiftState(freshlyAddedGift.getId(), GiftState.OFF_SHELF);
ObjectNode inactiveUpdate = objectMapper.createObjectNode();
inactiveUpdate.put("blindBoxId", TEST_BLIND_BOX_ID);
inactiveUpdate.put("rewardGiftId", freshlyAddedGift.getId());
inactiveUpdate.put("weight", 15);
inactiveUpdate.put("status", BlindBoxPoolStatus.ENABLED.getCode());
inactiveUpdate.putNull("remainingStock");
mockMvc.perform(put("/play/blind-box/pool/" + reusableEntryId)
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.contentType(MediaType.APPLICATION_JSON)
.content(inactiveUpdate.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.weight").value(15));
mockMvc.perform(delete("/play/blind-box/pool/" + reusableEntryId)
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
BlindBoxPoolEntity deleted = blindBoxPoolMapper.selectById(reusableEntryId);
assertThat(deleted).isNull();
poolIdsToCleanup.remove(reusableEntryId);
}
private long createPoolEntryViaApi(
String giftId,
BigDecimal rewardPrice,
int weight,
Integer remainingStock,
int status,
LocalDateTime validFrom,
LocalDateTime validTo) throws Exception {
ObjectNode payload = objectMapper.createObjectNode();
payload.put("blindBoxId", TEST_BLIND_BOX_ID);
payload.put("rewardGiftId", giftId);
payload.put("rewardPrice", rewardPrice.setScale(2, RoundingMode.HALF_UP).toPlainString());
payload.put("weight", weight);
if (remainingStock != null) {
payload.put("remainingStock", remainingStock);
} else {
payload.putNull("remainingStock");
}
payload.put("status", status);
payload.put("validFrom", validFrom.format(DATE_TIME_FORMATTER));
payload.put("validTo", validTo.format(DATE_TIME_FORMATTER));
MvcResult result = mockMvc.perform(post("/play/blind-box/pool")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.contentType(MediaType.APPLICATION_JSON)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.id").isNumber())
.andReturn();
JsonNode response = objectMapper.readTree(result.getResponse().getContentAsString());
long id = response.path("data").path("id").asLong();
poolIdsToCleanup.add(id);
return id;
}
private void ensureBlindBoxConfig() {
BlindBoxConfigEntity existing = blindBoxConfigService.getById(TEST_BLIND_BOX_ID);
if (existing != null) {
return;
}
BlindBoxConfigEntity entity = new BlindBoxConfigEntity();
entity.setId(TEST_BLIND_BOX_ID);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setName("API盲盒Admin");
entity.setPrice(new BigDecimal("19.90"));
entity.setStatus(BlindBoxConfigStatus.ENABLED.getCode());
blindBoxConfigService.save(entity);
}
private PlayGiftInfoEntity seedGift(String name) {
PlayGiftInfoEntity gift = new PlayGiftInfoEntity();
gift.setId("gift-admin-" + IdUtils.getUuid().substring(0, 12));
gift.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
gift.setHistory(GiftHistory.CURRENT.getCode());
gift.setName(name);
gift.setType(GiftType.NORMAL.getCode());
gift.setUrl("https://example.com/assets/" + gift.getId() + ".png");
gift.setPrice(new BigDecimal("58.80"));
gift.setUnit("CNY");
gift.setState(GiftState.ACTIVE.getCode());
gift.setListingTime(LocalDateTime.now().minusDays(1));
gift.setRemark("Seeded for blind box pool admin test");
giftInfoService.save(gift);
return gift;
}
private void updateGiftState(String giftId, GiftState state) {
ensureTenantContext();
PlayGiftInfoEntity gift = giftInfoService.getById(giftId);
if (gift != null) {
gift.setState(state.getCode());
giftInfoService.updateById(gift);
}
}
protected void ensureTenantContext() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
}
}

View File

@@ -7,6 +7,7 @@ import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
import com.starry.admin.modules.blindbox.mapper.BlindBoxRewardMapper;
import com.starry.admin.modules.blindbox.module.constant.BlindBoxConfigStatus;
import com.starry.admin.modules.blindbox.module.constant.BlindBoxPoolStatus;
import com.starry.admin.modules.blindbox.module.dto.BlindBoxCandidate;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity;
@@ -34,6 +35,11 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport {
private static final String TEST_BLIND_BOX_ID = "blindbox-apitest";
private static final String PRIMARY_GIFT_ID = ApiTestDataSeeder.DEFAULT_GIFT_ID;
private static final String SECONDARY_GIFT_ID = "gift-blindbox-secondary";
private static final String MIXED_GIFT_A_ID = "gift-blindbox-mixed-a";
private static final String MIXED_GIFT_B_ID = "gift-blindbox-mixed-b";
private static final String MIXED_GIFT_C_ID = "gift-blindbox-mixed-c";
private static final String MIXED_GIFT_D_ID = "gift-blindbox-mixed-d";
private static final String MIXED_GIFT_E_ID = "gift-blindbox-mixed-e";
private static final int DRAW_ATTEMPT_COUNT = 1_000;
private static final int PRIMARY_WEIGHT = 80;
private static final int SECONDARY_WEIGHT = 20;
@@ -41,6 +47,11 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport {
private static final double PRIMARY_RATIO_MAX = 0.88;
private static final double SECONDARY_RATIO_MIN = 0.12;
private static final double SECONDARY_RATIO_MAX = 0.32;
private static final int MIXED_TOTAL_DRAWS = 400;
private static final int MIXED_GIFT_A_STOCK = 10;
private static final int MIXED_GIFT_B_STOCK = 5;
private static final int MIXED_GIFT_C_STOCK = 0;
private static final int MIXED_GIFT_E_STOCK = 2;
@Autowired
private BlindBoxService blindBoxService;
@@ -112,6 +123,66 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport {
purgePool();
}
@Test
// 测试用例:混合 5 种礼物(部分限量、部分不限量),验证限量礼物被抽满后不再出现,
// 不限量礼物可继续抽取,且库存为 0 的礼物永远不会返回。
void blindBoxDrawHandlesMixedInventory() {
ensureTenantContext();
ensureBlindBoxConfig();
ensureMixedGifts();
resetCustomerBalance();
purgeRewards();
purgePool();
insertPoolEntry(MIXED_GIFT_A_ID, 40, MIXED_GIFT_A_STOCK);
insertPoolEntry(MIXED_GIFT_B_ID, 25, MIXED_GIFT_B_STOCK);
insertPoolEntry(MIXED_GIFT_C_ID, 15, MIXED_GIFT_C_STOCK);
insertPoolEntry(MIXED_GIFT_D_ID, 10, null);
insertPoolEntry(MIXED_GIFT_E_ID, 10, MIXED_GIFT_E_STOCK);
Map<String, Integer> frequency = new HashMap<>();
for (int i = 0; i < MIXED_TOTAL_DRAWS; i++) {
BlindBoxRewardEntity reward = blindBoxService.drawReward(
ApiTestDataSeeder.DEFAULT_TENANT_ID,
"mixed-order-" + i,
ApiTestDataSeeder.DEFAULT_CUSTOMER_ID,
TEST_BLIND_BOX_ID,
"mixed-seed-" + i);
frequency.merge(reward.getRewardGiftId(), 1, Integer::sum);
}
Assertions.assertThat(frequency.getOrDefault(MIXED_GIFT_C_ID, 0))
.as("库存为 0 的礼物永远不应被抽中")
.isEqualTo(0);
Assertions.assertThat(frequency.getOrDefault(MIXED_GIFT_A_ID, 0))
.as("限量礼物 A 应被精确抽完")
.isEqualTo(MIXED_GIFT_A_STOCK);
Assertions.assertThat(frequency.getOrDefault(MIXED_GIFT_B_ID, 0))
.as("限量礼物 B 应被精确抽完")
.isEqualTo(MIXED_GIFT_B_STOCK);
Assertions.assertThat(frequency.getOrDefault(MIXED_GIFT_E_ID, 0))
.as("限量礼物 E 应被精确抽完")
.isEqualTo(MIXED_GIFT_E_STOCK);
int unlimitedCount = frequency.getOrDefault(MIXED_GIFT_D_ID, 0);
int finiteTotal = MIXED_GIFT_A_STOCK + MIXED_GIFT_B_STOCK + MIXED_GIFT_E_STOCK;
Assertions.assertThat(unlimitedCount)
.as("不限量礼物承担剩余抽奖次数")
.isEqualTo(MIXED_TOTAL_DRAWS - finiteTotal)
.isGreaterThan(0);
// 抽完后,奖池中仅剩不限量礼物
java.util.List<BlindBoxCandidate> remaining = blindBoxPoolMapper.listActiveEntries(
ApiTestDataSeeder.DEFAULT_TENANT_ID, TEST_BLIND_BOX_ID, LocalDateTime.now());
Assertions.assertThat(remaining)
.as("只剩不限量礼物可用")
.hasSize(1);
Assertions.assertThat(remaining.get(0).getRewardGiftId()).isEqualTo(MIXED_GIFT_D_ID);
purgeRewards();
purgePool();
}
private void ensureBlindBoxConfig() {
BlindBoxConfigEntity config = blindBoxConfigService.getById(TEST_BLIND_BOX_ID);
if (config != null) {
@@ -129,46 +200,81 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport {
}
private void ensureSecondaryGift() {
PlayGiftInfoEntity existing = findGift(SECONDARY_GIFT_ID);
if (existing != null) {
return;
}
PlayGiftInfoEntity entity = new PlayGiftInfoEntity();
entity.setId(SECONDARY_GIFT_ID);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setHistory(GiftHistory.CURRENT.getCode());
entity.setName("API盲盒奖励");
entity.setType(GiftType.NORMAL.getCode());
entity.setUrl("https://example.com/apitest/blindbox.png");
entity.setPrice(new BigDecimal("9.99"));
entity.setUnit("CNY");
entity.setState(GiftState.ACTIVE.getCode());
entity.setListingTime(LocalDateTime.now());
entity.setRemark("Seeded secondary gift for blind box tests");
giftInfoService.save(entity);
ensureGift(
SECONDARY_GIFT_ID,
"API盲盒奖励",
new BigDecimal("9.99"),
"https://example.com/apitest/blindbox.png",
"Seeded secondary gift for blind box tests");
}
private void ensurePrimaryGift() {
PlayGiftInfoEntity existing = findGift(PRIMARY_GIFT_ID);
ensureGift(
PRIMARY_GIFT_ID,
ApiTestDataSeeder.DEFAULT_GIFT_NAME,
new BigDecimal("15.00"),
"https://example.com/apitest/gift-basic.png",
"Seeded default gift for blind box tests");
}
private void ensureMixedGifts() {
ensureGift(
MIXED_GIFT_A_ID,
"API盲盒混合A",
new BigDecimal("11.11"),
"https://example.com/apitest/mixed-a.png",
"Mixed blind box gift A");
ensureGift(
MIXED_GIFT_B_ID,
"API盲盒混合B",
new BigDecimal("22.22"),
"https://example.com/apitest/mixed-b.png",
"Mixed blind box gift B");
ensureGift(
MIXED_GIFT_C_ID,
"API盲盒混合C",
new BigDecimal("33.33"),
"https://example.com/apitest/mixed-c.png",
"Mixed blind box gift C");
ensureGift(
MIXED_GIFT_D_ID,
"API盲盒混合D",
new BigDecimal("44.44"),
"https://example.com/apitest/mixed-d.png",
"Mixed blind box gift D");
ensureGift(
MIXED_GIFT_E_ID,
"API盲盒混合E",
new BigDecimal("55.55"),
"https://example.com/apitest/mixed-e.png",
"Mixed blind box gift E");
}
private void ensureGift(String giftId, String name, BigDecimal price, String imageUrl, String remark) {
PlayGiftInfoEntity existing = findGift(giftId);
if (existing != null) {
return;
}
PlayGiftInfoEntity entity = new PlayGiftInfoEntity();
entity.setId(PRIMARY_GIFT_ID);
entity.setId(giftId);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setHistory(GiftHistory.CURRENT.getCode());
entity.setName(ApiTestDataSeeder.DEFAULT_GIFT_NAME);
entity.setName(name);
entity.setType(GiftType.NORMAL.getCode());
entity.setUrl("https://example.com/apitest/gift-basic.png");
entity.setPrice(new BigDecimal("15.00"));
entity.setUrl(imageUrl);
entity.setPrice(price);
entity.setUnit("CNY");
entity.setState(GiftState.ACTIVE.getCode());
entity.setListingTime(LocalDateTime.now());
entity.setRemark("Seeded default gift for blind box tests");
entity.setRemark(remark);
giftInfoService.save(entity);
}
private void insertPoolEntry(String giftId, int weight) {
insertPoolEntry(giftId, weight, null);
}
private void insertPoolEntry(String giftId, int weight, Integer remainingStock) {
PlayGiftInfoEntity gift = findGift(giftId);
if (gift == null) {
throw new IllegalStateException("Expected gift to be seeded: " + giftId);
@@ -179,7 +285,7 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport {
entry.setRewardGiftId(giftId);
entry.setRewardPrice(gift.getPrice());
entry.setWeight(weight);
entry.setRemainingStock(null);
entry.setRemainingStock(remainingStock);
entry.setValidFrom(null);
entry.setValidTo(null);
entry.setStatus(BlindBoxPoolStatus.ENABLED.getCode());

View File

@@ -4,17 +4,32 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.starry.admin.common.apitest.ApiTestDataSeeder;
import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
import com.starry.admin.modules.blindbox.mapper.BlindBoxRewardMapper;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity;
import com.starry.admin.modules.blindbox.service.BlindBoxConfigService;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.shop.module.constant.GiftHistory;
import com.starry.admin.modules.shop.module.constant.GiftState;
import com.starry.admin.modules.shop.module.constant.GiftType;
import com.starry.admin.modules.shop.module.entity.PlayClerkGiftInfoEntity;
import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity;
import com.starry.admin.modules.shop.service.IPlayClerkGiftInfoService;
import com.starry.admin.modules.shop.service.IPlayGiftInfoService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.constant.Constants;
import com.starry.common.context.CustomSecurityContextHolder;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Objects;
import org.assertj.core.api.Assertions;
import org.assertj.core.api.SoftAssertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
@@ -23,6 +38,14 @@ class WxBlindBoxOrderApiTest extends WxCustomOrderApiTestSupport {
@Autowired
private BlindBoxConfigService blindBoxConfigService;
@Autowired
private BlindBoxPoolMapper blindBoxPoolMapper;
@Autowired
private IPlayGiftInfoService giftInfoService;
@Autowired
private BlindBoxRewardMapper blindBoxRewardMapper;
@Autowired
private IPlayClerkGiftInfoService clerkGiftInfoService;
@Test
void blindBoxPurchaseFailsWhenBalanceInsufficient() throws Exception {
@@ -73,4 +96,116 @@ class WxBlindBoxOrderApiTest extends WxCustomOrderApiTestSupport {
CustomSecurityContextHolder.remove();
}
}
@Test
void blindBoxPurchaseAndDispatchSucceedWhenGiftInactive() throws Exception {
String configId = "blind-inactive-" + IdUtils.getUuid().substring(0, 6);
String giftId = "gift-inactive-" + IdUtils.getUuid().substring(0, 6);
Long poolId = null;
String rewardId = null;
try {
ensureTenantContext();
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId(configId);
config.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
config.setName("下架礼物测试盲盒");
config.setPrice(new BigDecimal("19.90"));
config.setStatus(1);
blindBoxConfigService.save(config);
PlayGiftInfoEntity gift = new PlayGiftInfoEntity();
gift.setId(giftId);
gift.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
gift.setName("盲盒下架礼物");
gift.setHistory(GiftHistory.CURRENT.getCode());
gift.setState(GiftState.ACTIVE.getCode());
gift.setType(GiftType.NORMAL.getCode());
gift.setUrl("https://example.com/apitest/blindbox-off.png");
gift.setPrice(new BigDecimal("9.99"));
giftInfoService.save(gift);
BlindBoxPoolEntity entry = new BlindBoxPoolEntity();
entry.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entry.setBlindBoxId(configId);
entry.setRewardGiftId(giftId);
entry.setRewardPrice(gift.getPrice());
entry.setWeight(100);
entry.setRemainingStock(1);
entry.setStatus(1);
entry.setValidFrom(LocalDateTime.now().minusDays(1));
entry.setValidTo(LocalDateTime.now().plusDays(1));
blindBoxPoolMapper.insert(entry);
poolId = entry.getId();
gift.setState(GiftState.OFF_SHELF.getCode());
giftInfoService.updateById(gift);
resetCustomerBalance();
String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
String payload = objectMapper.createObjectNode()
.put("blindBoxId", configId)
.put("clerkId", ApiTestDataSeeder.DEFAULT_CLERK_ID)
.put("weiChatCode", "apitest-customer-wx")
.toString();
MvcResult purchaseResult = mockMvc.perform(post("/wx/blind-box/order/purchase")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.reward.rewardId").isString())
.andReturn();
JsonNode rewardNode = objectMapper.readTree(purchaseResult.getResponse().getContentAsString())
.path("data").path("reward");
rewardId = rewardNode.path("rewardId").asText();
Assertions.assertThat(rewardId).isNotBlank();
SoftAssertions softly = new SoftAssertions();
softly.assertThat(rewardNode.path("rewardGiftId").asText()).isEqualTo(giftId);
softly.assertThat(rewardNode.path("status").asText()).isEqualTo("UNUSED");
softly.assertAll();
PlayClerkGiftInfoEntity before = clerkGiftInfoService.selectByGiftIdAndClerkId(giftId, ApiTestDataSeeder.DEFAULT_CLERK_ID);
long beforeCount = before != null && before.getGiffNumber() != null ? before.getGiffNumber() : 0L;
mockMvc.perform(post("/wx/blind-box/reward/" + rewardId + "/dispatch")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.createObjectNode()
.put("clerkId", ApiTestDataSeeder.DEFAULT_CLERK_ID)
.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.status").value("USED"));
ensureTenantContext();
BlindBoxRewardEntity storedReward = blindBoxRewardMapper.selectById(rewardId);
Assertions.assertThat(storedReward).isNotNull();
Assertions.assertThat(storedReward.getStatus()).isEqualTo("USED");
Assertions.assertThat(storedReward.getUsedClerkId()).isEqualTo(ApiTestDataSeeder.DEFAULT_CLERK_ID);
PlayClerkGiftInfoEntity after = clerkGiftInfoService.selectByGiftIdAndClerkId(giftId, ApiTestDataSeeder.DEFAULT_CLERK_ID);
long afterCount = after != null && after.getGiffNumber() != null ? after.getGiffNumber() : 0L;
Assertions.assertThat(afterCount).isEqualTo(beforeCount + 1);
} finally {
ensureTenantContext();
if (Objects.nonNull(poolId)) {
blindBoxPoolMapper.deleteById(poolId);
}
blindBoxConfigService.removeById(configId);
giftInfoService.removeById(giftId);
if (rewardId != null) {
blindBoxRewardMapper.deleteById(rewardId);
}
CustomSecurityContextHolder.remove();
}
}
}

View File

@@ -1,8 +1,11 @@
package com.starry.admin.modules.blindbox.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -20,6 +23,7 @@ import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity;
import com.starry.admin.utils.SecurityUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
@@ -185,6 +189,84 @@ class BlindBoxPoolAdminServiceTest {
assertEquals("超值娃娃", options.get(0).getName());
}
@Test
void shouldOverwriteRemainingStockOnReimport() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
PlayGiftInfoEntity reward = new PlayGiftInfoEntity();
reward.setId("gift-2");
reward.setName("超值娃娃");
reward.setType("1");
reward.setPrice(BigDecimal.valueOf(9.9));
when(playGiftInfoMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(Collections.singletonList(reward));
when(blindBoxPoolMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(1);
List<BlindBoxPoolEntity> inserted = new ArrayList<>();
when(blindBoxPoolMapper.insert(any(BlindBoxPoolEntity.class))).thenAnswer(invocation -> {
BlindBoxPoolEntity entity = invocation.getArgument(0);
BlindBoxPoolEntity snapshot = new BlindBoxPoolEntity();
snapshot.setBlindBoxId(entity.getBlindBoxId());
snapshot.setRewardGiftId(entity.getRewardGiftId());
snapshot.setRemainingStock(entity.getRemainingStock());
inserted.add(snapshot);
return 1;
});
BlindBoxPoolImportRow first = new BlindBoxPoolImportRow();
first.setRewardGiftName("超值娃娃");
first.setWeight(50);
first.setRemainingStock(5);
first.setStatus(1);
BlindBoxPoolImportRow second = new BlindBoxPoolImportRow();
second.setRewardGiftName("超值娃娃");
second.setWeight(60);
second.setRemainingStock(1);
second.setStatus(1);
blindBoxPoolAdminService.replacePool("blind-1", Collections.singletonList(first));
blindBoxPoolAdminService.replacePool("blind-1", Collections.singletonList(second));
assertEquals(2, inserted.size());
assertEquals(Integer.valueOf(5), inserted.get(0).getRemainingStock());
assertEquals(Integer.valueOf(1), inserted.get(1).getRemainingStock());
}
@Test
void shouldKeepUnlimitedStockWhenRemainingStockBlank() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
PlayGiftInfoEntity reward = new PlayGiftInfoEntity();
reward.setId("gift-2");
reward.setName("超值娃娃");
reward.setType("1");
reward.setPrice(BigDecimal.valueOf(9.9));
when(playGiftInfoMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(Collections.singletonList(reward));
when(blindBoxPoolMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(1);
ArgumentCaptor<BlindBoxPoolEntity> captor = ArgumentCaptor.forClass(BlindBoxPoolEntity.class);
when(blindBoxPoolMapper.insert(any(BlindBoxPoolEntity.class))).thenReturn(1);
BlindBoxPoolImportRow importRow = new BlindBoxPoolImportRow();
importRow.setRewardGiftName("超值娃娃");
importRow.setWeight(30);
importRow.setStatus(1);
blindBoxPoolAdminService.replacePool("blind-1", Collections.singletonList(importRow));
verify(blindBoxPoolMapper).insert(captor.capture());
BlindBoxPoolEntity saved = captor.getValue();
assertNull(saved.getRemainingStock());
}
@Test
void shouldCreatePoolEntry() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
@@ -271,4 +353,68 @@ class BlindBoxPoolAdminServiceTest {
assertEquals("超级公仔", view.getRewardGiftName());
verify(blindBoxPoolMapper).updateById(existing);
}
@Test
void shouldAllowUpdateWhenGiftInactiveOrHistorical() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
PlayGiftInfoEntity inactiveGift = new PlayGiftInfoEntity();
inactiveGift.setId("gift-offline");
inactiveGift.setTenantId("tenant-1");
inactiveGift.setHistory("1");
inactiveGift.setState("1");
inactiveGift.setType("1");
inactiveGift.setPrice(BigDecimal.valueOf(66.6));
inactiveGift.setName("下架礼物");
when(playGiftInfoMapper.selectById("gift-offline")).thenReturn(inactiveGift);
BlindBoxPoolEntity existing = new BlindBoxPoolEntity();
existing.setId(500L);
existing.setTenantId("tenant-1");
existing.setBlindBoxId("blind-1");
existing.setRewardGiftId("gift-on");
existing.setStatus(1);
when(blindBoxPoolMapper.selectById(500L)).thenReturn(existing);
when(blindBoxPoolMapper.updateById(existing)).thenReturn(1);
BlindBoxPoolUpsertRequest request = new BlindBoxPoolUpsertRequest();
request.setBlindBoxId("blind-1");
request.setRewardGiftId("gift-offline");
request.setWeight(10);
request.setStatus(1);
blindBoxPoolAdminService.update(500L, request);
verify(blindBoxPoolMapper).updateById(existing);
assertEquals("gift-offline", existing.getRewardGiftId());
}
@Test
void shouldRejectCreateWhenGiftInactive() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
PlayGiftInfoEntity inactiveGift = new PlayGiftInfoEntity();
inactiveGift.setId("gift-off");
inactiveGift.setTenantId("tenant-1");
inactiveGift.setHistory("1");
inactiveGift.setState("1");
inactiveGift.setType("1");
when(playGiftInfoMapper.selectById("gift-off")).thenReturn(inactiveGift);
BlindBoxPoolUpsertRequest request = new BlindBoxPoolUpsertRequest();
request.setBlindBoxId("blind-1");
request.setRewardGiftId("gift-off");
request.setWeight(10);
CustomException ex = assertThrows(CustomException.class,
() -> blindBoxPoolAdminService.create("blind-1", request));
assertTrue(ex.getMessage().contains("中奖礼物不存在或已下架"));
verify(blindBoxPoolMapper, times(0)).insert(any());
}
}

View File

@@ -171,6 +171,21 @@ class BlindBoxServiceTest {
verify(rewardMapper, times(0)).markUsed(any(), any(), any(), any());
}
@Test
void shouldRejectWhenNoEligibleRewardExists() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
when(configService.requireById("blind-1")).thenReturn(config);
when(poolMapper.listActiveEntries(eq("tenant-1"), eq("blind-1"), any(LocalDateTime.class)))
.thenReturn(Collections.emptyList());
CustomException ex = assertThrows(CustomException.class,
() -> blindBoxService.drawReward("tenant-1", "order-404", "customer-9", "blind-1", "seed-out"));
assertTrue(ex.getMessage().contains("奖池暂无可用奖励"));
verify(inventoryService, times(0)).reserveRewardStock(any(), any(), any());
}
private BlindBoxRewardEntity buildRewardEntity() {
BlindBoxRewardEntity reward = new BlindBoxRewardEntity();
reward.setId("reward-1");