From 04b9960e35f37448cbf2a90056f634c09a05dd1d Mon Sep 17 00:00:00 2001 From: irving Date: Sat, 1 Nov 2025 15:16:45 -0400 Subject: [PATCH] API-test-in-progress --- .../common/apitest/ApiTestDataSeeder.java | 151 ++++- .../config/BlindBoxConfiguration.java | 14 + .../entity/PlayCommodityInfoEntity.java | 5 + .../admin/api/BlindBoxServiceWeightTest.java | 209 ++++++ .../admin/api/HealthControllerApiTest.java | 1 + .../admin/api/PlayCommodityInfoApiTest.java | 519 +++++++++++++++ .../SysTenantPackageControllerApiTest.java | 2 + .../admin/api/WxCustomGiftOrderApiTest.java | 122 ++++ .../api/WxCustomOrderApiTestSupport.java | 202 ++++++ .../admin/api/WxCustomRandomOrderApiTest.java | 612 ++++++++++++++++++ .../admin/api/WxCustomRewardOrderApiTest.java | 81 +++ .../api/WxCustomSpecifiedOrderApiTest.java | 354 ++++++++++ 12 files changed, 2253 insertions(+), 19 deletions(-) create mode 100644 play-admin/src/main/java/com/starry/admin/modules/blindbox/config/BlindBoxConfiguration.java create mode 100644 play-admin/src/test/java/com/starry/admin/api/BlindBoxServiceWeightTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/api/PlayCommodityInfoApiTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/api/WxCustomGiftOrderApiTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/api/WxCustomOrderApiTestSupport.java create mode 100644 play-admin/src/test/java/com/starry/admin/api/WxCustomRandomOrderApiTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/api/WxCustomRewardOrderApiTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/api/WxCustomSpecifiedOrderApiTest.java diff --git a/play-admin/src/main/java/com/starry/admin/common/apitest/ApiTestDataSeeder.java b/play-admin/src/main/java/com/starry/admin/common/apitest/ApiTestDataSeeder.java index d3ef6bc..85dedfc 100644 --- a/play-admin/src/main/java/com/starry/admin/common/apitest/ApiTestDataSeeder.java +++ b/play-admin/src/main/java/com/starry/admin/common/apitest/ApiTestDataSeeder.java @@ -11,16 +11,22 @@ import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity; import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService; +import com.starry.admin.modules.shop.module.entity.PlayCommodityAndLevelInfoEntity; import com.starry.admin.modules.shop.module.entity.PlayCommodityInfoEntity; +import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity; +import com.starry.admin.modules.shop.service.IPlayCommodityAndLevelInfoService; import com.starry.admin.modules.shop.service.IPlayCommodityInfoService; +import com.starry.admin.modules.shop.service.IPlayGiftInfoService; import com.starry.admin.modules.system.module.entity.SysTenantEntity; import com.starry.admin.modules.system.module.entity.SysTenantPackageEntity; import com.starry.admin.modules.system.module.entity.SysUserEntity; import com.starry.admin.modules.system.service.ISysTenantPackageService; import com.starry.admin.modules.system.service.ISysTenantService; import com.starry.admin.modules.system.service.SysUserService; +import com.starry.admin.modules.weichat.service.WxTokenService; import com.starry.admin.utils.SecurityUtils; import com.starry.common.context.CustomSecurityContextHolder; +import com.starry.common.utils.IdUtils; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.Date; @@ -49,9 +55,18 @@ public class ApiTestDataSeeder implements CommandLineRunner { public static final String DEFAULT_CLERK_LEVEL_ID = "lvl-basic"; public static final String DEFAULT_CLERK_ID = "clerk-apitest"; public static final String DEFAULT_CLERK_OPEN_ID = "openid-clerk-apitest"; + public static final String DEFAULT_COMMODITY_PARENT_ID = "svc-parent"; + public static final String DEFAULT_COMMODITY_PARENT_NAME = "语音陪聊服务"; public static final String DEFAULT_COMMODITY_ID = "svc-basic"; public static final String DEFAULT_CLERK_COMMODITY_ID = "clerk-svc-basic"; public static final String DEFAULT_CUSTOMER_ID = "customer-apitest"; + public static final String DEFAULT_GIFT_ID = "gift-basic"; + public static final String DEFAULT_GIFT_NAME = "API测试礼物"; + public static final BigDecimal DEFAULT_COMMODITY_PRICE = new BigDecimal("120.00"); + private static final String GIFT_TYPE_REGULAR = "1"; + private static final String GIFT_STATE_ACTIVE = "0"; + private static final BigDecimal DEFAULT_CUSTOMER_BALANCE = new BigDecimal("200.00"); + private static final BigDecimal DEFAULT_CUSTOMER_RECHARGE = DEFAULT_CUSTOMER_BALANCE; private final ISysTenantPackageService tenantPackageService; private final ISysTenantService tenantService; @@ -60,9 +75,12 @@ public class ApiTestDataSeeder implements CommandLineRunner { private final IPlayClerkLevelInfoService clerkLevelInfoService; private final IPlayClerkUserInfoService clerkUserInfoService; private final IPlayCommodityInfoService commodityInfoService; + private final IPlayCommodityAndLevelInfoService commodityAndLevelInfoService; + private final IPlayGiftInfoService giftInfoService; private final IPlayClerkCommodityService clerkCommodityService; private final IPlayCustomUserInfoService customUserInfoService; private final PasswordEncoder passwordEncoder; + private final WxTokenService wxTokenService; public ApiTestDataSeeder( ISysTenantPackageService tenantPackageService, @@ -72,9 +90,12 @@ public class ApiTestDataSeeder implements CommandLineRunner { IPlayClerkLevelInfoService clerkLevelInfoService, IPlayClerkUserInfoService clerkUserInfoService, IPlayCommodityInfoService commodityInfoService, + IPlayCommodityAndLevelInfoService commodityAndLevelInfoService, + IPlayGiftInfoService giftInfoService, IPlayClerkCommodityService clerkCommodityService, IPlayCustomUserInfoService customUserInfoService, - PasswordEncoder passwordEncoder) { + PasswordEncoder passwordEncoder, + WxTokenService wxTokenService) { this.tenantPackageService = tenantPackageService; this.tenantService = tenantService; this.sysUserService = sysUserService; @@ -82,9 +103,12 @@ public class ApiTestDataSeeder implements CommandLineRunner { this.clerkLevelInfoService = clerkLevelInfoService; this.clerkUserInfoService = clerkUserInfoService; this.commodityInfoService = commodityInfoService; + this.commodityAndLevelInfoService = commodityAndLevelInfoService; + this.giftInfoService = giftInfoService; this.clerkCommodityService = clerkCommodityService; this.customUserInfoService = customUserInfoService; this.passwordEncoder = passwordEncoder; + this.wxTokenService = wxTokenService; } @Override @@ -99,9 +123,11 @@ public class ApiTestDataSeeder implements CommandLineRunner { seedAdminUser(); seedPersonnelGroup(); seedClerkLevel(); - seedCommodity(); + PlayCommodityInfoEntity commodity = seedCommodityHierarchy(); + seedCommodityPricing(commodity); seedClerk(); seedClerkCommodity(); + seedGift(); seedCustomer(); } finally { if (Objects.nonNull(originalTenant)) { @@ -218,28 +244,70 @@ public class ApiTestDataSeeder implements CommandLineRunner { log.info("Inserted API test clerk level {}", DEFAULT_CLERK_LEVEL_ID); } - private void seedCommodity() { - PlayCommodityInfoEntity commodity = commodityInfoService.getById(DEFAULT_COMMODITY_ID); - if (commodity != null) { + private PlayCommodityInfoEntity seedCommodityHierarchy() { + PlayCommodityInfoEntity parent = commodityInfoService.getById(DEFAULT_COMMODITY_PARENT_ID); + if (parent == null) { + parent = new PlayCommodityInfoEntity(); + parent.setId(DEFAULT_COMMODITY_PARENT_ID); + parent.setTenantId(DEFAULT_TENANT_ID); + parent.setPId("00"); + parent.setItemType("service-category"); + parent.setItemName(DEFAULT_COMMODITY_PARENT_NAME); + parent.setEnableStace("1"); + parent.setSort(1); + commodityInfoService.save(parent); + log.info("Inserted API test commodity parent {}", DEFAULT_COMMODITY_PARENT_ID); + } + + PlayCommodityInfoEntity child = commodityInfoService.getById(DEFAULT_COMMODITY_ID); + if (child != null) { log.info("API test commodity {} already exists", DEFAULT_COMMODITY_ID); + return child; + } + + child = new PlayCommodityInfoEntity(); + child.setId(DEFAULT_COMMODITY_ID); + child.setTenantId(DEFAULT_TENANT_ID); + child.setPId(DEFAULT_COMMODITY_PARENT_ID); + child.setItemType("service"); + child.setItemName("60分钟语音陪聊"); + child.setServiceDuration("60min"); + child.setEnableStace("1"); + child.setSort(1); + commodityInfoService.save(child); + log.info("Inserted API test commodity {}", DEFAULT_COMMODITY_ID); + return child; + } + + private void seedCommodityPricing(PlayCommodityInfoEntity commodity) { + if (commodity == null) { + return; + } + PlayCommodityAndLevelInfoEntity existing = commodityAndLevelInfoService.lambdaQuery() + .eq(PlayCommodityAndLevelInfoEntity::getCommodityId, commodity.getId()) + .eq(PlayCommodityAndLevelInfoEntity::getLevelId, DEFAULT_CLERK_LEVEL_ID) + .one(); + if (existing != null) { + log.info("API test commodity pricing for {} already exists", commodity.getId()); return; } - PlayCommodityInfoEntity entity = new PlayCommodityInfoEntity(); - entity.setId(DEFAULT_COMMODITY_ID); - entity.setTenantId(DEFAULT_TENANT_ID); - entity.setItemType("service"); - entity.setItemName("60分钟语音陪聊"); - entity.setServiceDuration("60min"); - entity.setEnableStace("1"); - entity.setSort(1); - commodityInfoService.save(entity); - log.info("Inserted API test commodity {}", DEFAULT_COMMODITY_ID); + PlayCommodityAndLevelInfoEntity price = new PlayCommodityAndLevelInfoEntity(); + price.setId(IdUtils.getUuid()); + price.setTenantId(DEFAULT_TENANT_ID); + price.setCommodityId(commodity.getId()); + price.setLevelId(DEFAULT_CLERK_LEVEL_ID); + price.setPrice(DEFAULT_COMMODITY_PRICE); + price.setSort(1L); + commodityAndLevelInfoService.save(price); + log.info("Inserted API test commodity pricing for {}", commodity.getId()); } private void seedClerk() { PlayClerkUserInfoEntity clerk = clerkUserInfoService.getById(DEFAULT_CLERK_ID); + String clerkToken = wxTokenService.createWxUserToken(DEFAULT_CLERK_ID); if (clerk != null) { + clerkUserInfoService.updateTokenById(DEFAULT_CLERK_ID, clerkToken); log.info("API test clerk {} already exists", DEFAULT_CLERK_ID); return; } @@ -261,9 +329,11 @@ public class ApiTestDataSeeder implements CommandLineRunner { entity.setOnboardingState("1"); entity.setListingState("1"); entity.setDisplayState("1"); + entity.setOnlineState("1"); entity.setRandomOrderState("1"); entity.setClerkState("1"); entity.setEntryTime(LocalDateTime.now()); + entity.setToken(clerkToken); clerkUserInfoService.save(entity); log.info("Inserted API test clerk {}", DEFAULT_CLERK_ID); } @@ -275,22 +345,64 @@ public class ApiTestDataSeeder implements CommandLineRunner { return; } + String commodityName = DEFAULT_COMMODITY_PARENT_NAME; + PlayCommodityInfoEntity parent = commodityInfoService.getById(DEFAULT_COMMODITY_PARENT_ID); + if (parent != null && parent.getItemName() != null) { + commodityName = parent.getItemName(); + } + PlayClerkCommodityEntity entity = new PlayClerkCommodityEntity(); entity.setId(DEFAULT_CLERK_COMMODITY_ID); entity.setTenantId(DEFAULT_TENANT_ID); entity.setClerkId(DEFAULT_CLERK_ID); entity.setCommodityId(DEFAULT_COMMODITY_ID); - entity.setCommodityName("60分钟语音陪聊"); + entity.setCommodityName(commodityName); entity.setEnablingState("1"); entity.setSort(1); clerkCommodityService.save(entity); log.info("Inserted API test clerk commodity link {}", DEFAULT_CLERK_COMMODITY_ID); } + private void seedGift() { + PlayGiftInfoEntity gift = giftInfoService.getById(DEFAULT_GIFT_ID); + if (gift != null) { + log.info("API test gift {} already exists", DEFAULT_GIFT_ID); + return; + } + + PlayGiftInfoEntity entity = new PlayGiftInfoEntity(); + entity.setId(DEFAULT_GIFT_ID); + entity.setTenantId(DEFAULT_TENANT_ID); + entity.setHistory("0"); + entity.setName(DEFAULT_GIFT_NAME); + entity.setType(GIFT_TYPE_REGULAR); + entity.setUrl("https://example.com/apitest/gift.png"); + entity.setPrice(new BigDecimal("15.00")); + entity.setUnit("CNY"); + entity.setState(GIFT_STATE_ACTIVE); + entity.setListingTime(LocalDateTime.now()); + entity.setRemark("Seeded gift for API tests"); + giftInfoService.save(entity); + log.info("Inserted API test gift {}", DEFAULT_GIFT_ID); + } + private void seedCustomer() { PlayCustomUserInfoEntity customer = customUserInfoService.getById(DEFAULT_CUSTOMER_ID); + String token = wxTokenService.createWxUserToken(DEFAULT_CUSTOMER_ID); if (customer != null) { - log.info("API test customer {} already exists", DEFAULT_CUSTOMER_ID); + customUserInfoService.updateTokenById(DEFAULT_CUSTOMER_ID, token); + customUserInfoService.lambdaUpdate() + .set(PlayCustomUserInfoEntity::getAccountBalance, DEFAULT_CUSTOMER_BALANCE) + .set(PlayCustomUserInfoEntity::getAccumulatedRechargeAmount, DEFAULT_CUSTOMER_RECHARGE) + .set(PlayCustomUserInfoEntity::getAccumulatedConsumptionAmount, BigDecimal.ZERO) + .set(PlayCustomUserInfoEntity::getAccountState, "1") + .set(PlayCustomUserInfoEntity::getSubscribeState, "1") + .set(PlayCustomUserInfoEntity::getPurchaseState, "1") + .set(PlayCustomUserInfoEntity::getMobilePhoneState, "1") + .set(PlayCustomUserInfoEntity::getLastLoginTime, new Date()) + .eq(PlayCustomUserInfoEntity::getId, DEFAULT_CUSTOMER_ID) + .update(); + log.info("API test customer {} already exists, state refreshed", DEFAULT_CUSTOMER_ID); return; } @@ -303,8 +415,8 @@ public class ApiTestDataSeeder implements CommandLineRunner { entity.setSex(1); entity.setPhone("13700000002"); entity.setWeiChatCode("apitest-customer"); - entity.setAccountBalance(new BigDecimal("200.00")); - entity.setAccumulatedRechargeAmount(new BigDecimal("200.00")); + entity.setAccountBalance(DEFAULT_CUSTOMER_BALANCE); + entity.setAccumulatedRechargeAmount(DEFAULT_CUSTOMER_RECHARGE); entity.setAccumulatedConsumptionAmount(BigDecimal.ZERO); entity.setAccountState("1"); entity.setSubscribeState("1"); @@ -312,6 +424,7 @@ public class ApiTestDataSeeder implements CommandLineRunner { entity.setMobilePhoneState("1"); entity.setRegistrationTime(new Date()); entity.setLastLoginTime(new Date()); + entity.setToken(token); customUserInfoService.save(entity); log.info("Inserted API test customer {}", DEFAULT_CUSTOMER_ID); } diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/config/BlindBoxConfiguration.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/config/BlindBoxConfiguration.java new file mode 100644 index 0000000..3f66270 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/config/BlindBoxConfiguration.java @@ -0,0 +1,14 @@ +package com.starry.admin.modules.blindbox.config; + +import java.time.Clock; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class BlindBoxConfiguration { + + @Bean + public Clock systemClock() { + return Clock.systemDefaultZone(); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/shop/module/entity/PlayCommodityInfoEntity.java b/play-admin/src/main/java/com/starry/admin/modules/shop/module/entity/PlayCommodityInfoEntity.java index 7a337f7..328caa0 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/shop/module/entity/PlayCommodityInfoEntity.java +++ b/play-admin/src/main/java/com/starry/admin/modules/shop/module/entity/PlayCommodityInfoEntity.java @@ -46,6 +46,11 @@ public class PlayCommodityInfoEntity extends BaseEntity */ private String serviceDuration; + /** + * 接单后自动结算等待时长(单位:秒,-1 表示不自动结算) + */ + private Integer automaticSettlementDuration; + /** * 启用状态(0:停用,1:启用) */ diff --git a/play-admin/src/test/java/com/starry/admin/api/BlindBoxServiceWeightTest.java b/play-admin/src/test/java/com/starry/admin/api/BlindBoxServiceWeightTest.java new file mode 100644 index 0000000..1874685 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/BlindBoxServiceWeightTest.java @@ -0,0 +1,209 @@ +package com.starry.admin.api; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.starry.admin.common.apitest.ApiTestDataSeeder; +import com.starry.admin.common.exception.CustomException; +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.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.blindbox.service.BlindBoxService; +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.context.CustomSecurityContextHolder; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import org.assertj.core.api.Assertions; +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; + +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 int DRAW_ATTEMPT_COUNT = 1_000; + private static final int PRIMARY_WEIGHT = 80; + private static final int SECONDARY_WEIGHT = 20; + private static final double PRIMARY_RATIO_MIN = 0.68; + 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; + + @Autowired + private BlindBoxService blindBoxService; + + @Autowired + private BlindBoxConfigService blindBoxConfigService; + + @Autowired + private BlindBoxPoolMapper blindBoxPoolMapper; + + @Autowired + private BlindBoxRewardMapper blindBoxRewardMapper; + + @Autowired + private IPlayGiftInfoService giftInfoService; + + @BeforeEach + void setUpTenant() { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + } + + @AfterEach + void clearContext() { + CustomSecurityContextHolder.remove(); + } + + @Test + // 测试用例:在默认租户下补齐盲盒配置以及主、副礼物后,以 80/20 的权重写入奖池, + // 连续执行 1000 次盲盒抽奖,既校验每次抽奖都有奖励返回且两种礼物都被抽中, + // 也验证主礼物命中率位于 68%~88%、副礼物命中率位于 12%~32%,确保抽奖概率符合权重设定。 + void blindBoxDrawRespectsWeightDistribution() { + ensureTenantContext(); + ensureBlindBoxConfig(); + ensurePrimaryGift(); + ensureSecondaryGift(); + resetCustomerBalance(); + + purgeRewards(); + purgePool(); + insertPoolEntry(PRIMARY_GIFT_ID, PRIMARY_WEIGHT); + insertPoolEntry(SECONDARY_GIFT_ID, SECONDARY_WEIGHT); + + Map frequency = new HashMap<>(); + for (int i = 0; i < DRAW_ATTEMPT_COUNT; i++) { + BlindBoxRewardEntity reward = blindBoxService.drawReward( + ApiTestDataSeeder.DEFAULT_TENANT_ID, + "order-" + i, + ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, + TEST_BLIND_BOX_ID, + "seed-" + i); + frequency.merge(reward.getRewardGiftId(), 1, Integer::sum); + } + + int primaryCount = frequency.getOrDefault(PRIMARY_GIFT_ID, 0); + int secondaryCount = frequency.getOrDefault(SECONDARY_GIFT_ID, 0); + int total = primaryCount + secondaryCount; + + Assertions.assertThat(total).isEqualTo(DRAW_ATTEMPT_COUNT); + Assertions.assertThat(primaryCount).isGreaterThan(0); + Assertions.assertThat(secondaryCount).isGreaterThan(0); + + double primaryRatio = primaryCount / (double) total; + double secondaryRatio = secondaryCount / (double) total; + + Assertions.assertThat(primaryRatio).isBetween(PRIMARY_RATIO_MIN, PRIMARY_RATIO_MAX); + Assertions.assertThat(secondaryRatio).isBetween(SECONDARY_RATIO_MIN, SECONDARY_RATIO_MAX); + + purgeRewards(); + purgePool(); + } + + private void ensureBlindBoxConfig() { + BlindBoxConfigEntity config = blindBoxConfigService.getById(TEST_BLIND_BOX_ID); + if (config != null) { + return; + } + BlindBoxConfigEntity entity = new BlindBoxConfigEntity(); + entity.setId(TEST_BLIND_BOX_ID); + entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + entity.setName("API盲盒"); + entity.setCoverUrl("https://example.com/apitest/blindbox-cover.png"); + entity.setDescription("Seeded blind box for integration tests"); + entity.setPrice(new BigDecimal("19.90")); + entity.setStatus(BlindBoxConfigStatus.ENABLED.getCode()); + blindBoxConfigService.save(entity); + } + + 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); + } + + private void ensurePrimaryGift() { + PlayGiftInfoEntity existing = findGift(PRIMARY_GIFT_ID); + if (existing != null) { + return; + } + PlayGiftInfoEntity entity = new PlayGiftInfoEntity(); + entity.setId(PRIMARY_GIFT_ID); + entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + entity.setHistory(GiftHistory.CURRENT.getCode()); + entity.setName(ApiTestDataSeeder.DEFAULT_GIFT_NAME); + entity.setType(GiftType.NORMAL.getCode()); + entity.setUrl("https://example.com/apitest/gift-basic.png"); + entity.setPrice(new BigDecimal("15.00")); + entity.setUnit("CNY"); + entity.setState(GiftState.ACTIVE.getCode()); + entity.setListingTime(LocalDateTime.now()); + entity.setRemark("Seeded default gift for blind box tests"); + giftInfoService.save(entity); + } + + private void insertPoolEntry(String giftId, int weight) { + PlayGiftInfoEntity gift = findGift(giftId); + if (gift == null) { + throw new IllegalStateException("Expected gift to be seeded: " + giftId); + } + BlindBoxPoolEntity entry = new BlindBoxPoolEntity(); + entry.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + entry.setBlindBoxId(TEST_BLIND_BOX_ID); + entry.setRewardGiftId(giftId); + entry.setRewardPrice(gift.getPrice()); + entry.setWeight(weight); + entry.setRemainingStock(null); + entry.setValidFrom(null); + entry.setValidTo(null); + entry.setStatus(BlindBoxPoolStatus.ENABLED.getCode()); + blindBoxPoolMapper.insert(entry); + } + + private PlayGiftInfoEntity findGift(String giftId) { + try { + return giftInfoService.selectPlayGiftInfoById(giftId); + } catch (CustomException ignored) { + return null; + } + } + + private void purgePool() { + blindBoxPoolMapper.delete(new LambdaQueryWrapper() + .eq(BlindBoxPoolEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID) + .eq(BlindBoxPoolEntity::getBlindBoxId, TEST_BLIND_BOX_ID)); + } + + private void purgeRewards() { + blindBoxRewardMapper.delete(new LambdaQueryWrapper() + .eq(BlindBoxRewardEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID) + .eq(BlindBoxRewardEntity::getBlindBoxId, TEST_BLIND_BOX_ID) + .eq(BlindBoxRewardEntity::getCustomerId, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)); + } +} diff --git a/play-admin/src/test/java/com/starry/admin/api/HealthControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/HealthControllerApiTest.java index ec98142..f144cae 100644 --- a/play-admin/src/test/java/com/starry/admin/api/HealthControllerApiTest.java +++ b/play-admin/src/test/java/com/starry/admin/api/HealthControllerApiTest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.Test; class HealthControllerApiTest extends AbstractApiTest { @Test + // 测试用例:模拟带上默认用户与租户请求 /health/ping 接口,校验接口必须返回 200 且 data = "pong",确认健康检查链路畅通。 void pingReturnsPong() throws Exception { mockMvc.perform(get("/health/ping") .header(USER_HEADER, DEFAULT_USER) diff --git a/play-admin/src/test/java/com/starry/admin/api/PlayCommodityInfoApiTest.java b/play-admin/src/test/java/com/starry/admin/api/PlayCommodityInfoApiTest.java new file mode 100644 index 0000000..467458b --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/PlayCommodityInfoApiTest.java @@ -0,0 +1,519 @@ +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.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +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.node.ObjectNode; +import com.starry.admin.common.apitest.ApiTestDataSeeder; +import com.starry.admin.modules.clerk.module.entity.PlayClerkCommodityEntity; +import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity; +import com.starry.admin.modules.clerk.service.IPlayClerkCommodityService; +import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService; +import com.starry.admin.modules.order.module.constant.OrderConstant; +import com.starry.admin.modules.shop.module.entity.PlayCommodityAndLevelInfoEntity; +import com.starry.admin.modules.shop.module.entity.PlayCommodityInfoEntity; +import com.starry.admin.modules.shop.service.IPlayCommodityAndLevelInfoService; +import com.starry.admin.modules.shop.service.IPlayCommodityInfoService; +import com.starry.admin.utils.SecurityUtils; +import com.starry.common.constant.Constants; +import com.starry.common.utils.IdUtils; +import java.math.BigDecimal; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +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 PlayCommodityInfoApiTest extends WxCustomOrderApiTestSupport { + + private static final String ROOT_PARENT_ID = "00"; + private static final long DEFAULT_PRICE_SORT = 1L; + private static final int SINGLE_COMMODITY_QUANTITY = 1; + + private enum SwitchState { + ENABLED("1"), + DISABLED("0"); + + private final String code; + + SwitchState(String code) { + this.code = code; + } + + String getCode() { + return code; + } + } + + private enum HistoryFilter { + INCLUDE("0"), + EXCLUDE("1"); + + private final String code; + + HistoryFilter(String code) { + this.code = code; + } + + String getCode() { + return code; + } + } + + private enum SortPriority { + PRIMARY(1), + SECONDARY(2); + + private final int value; + + SortPriority(int value) { + this.value = value; + } + + int getValue() { + return value; + } + } + + private enum AutomaticSettlementPolicy { + DISABLED(-1), + TEN_MINUTES(600); + + private final int seconds; + + AutomaticSettlementPolicy(int seconds) { + this.seconds = seconds; + } + + int getSeconds() { + return seconds; + } + } + + @Autowired + private IPlayCommodityInfoService commodityInfoService; + + @Autowired + private IPlayCommodityAndLevelInfoService commodityAndLevelInfoService; + + @Autowired + private IPlayClerkCommodityService clerkCommodityService; + + @Autowired + private IPlayClerkLevelInfoService clerkLevelInfoService; + + private final Deque commodityIdsToCleanup = new ArrayDeque<>(); + private final List priceIdsToCleanup = new ArrayList<>(); + private final List clerkCommodityIdsToCleanup = new ArrayList<>(); + + @AfterEach + void tearDown() { + ensureTenantContext(); + if (!priceIdsToCleanup.isEmpty()) { + commodityAndLevelInfoService.removeByIds(priceIdsToCleanup); + priceIdsToCleanup.clear(); + } + if (!clerkCommodityIdsToCleanup.isEmpty()) { + clerkCommodityService.removeByIds(clerkCommodityIdsToCleanup); + clerkCommodityIdsToCleanup.clear(); + } + while (!commodityIdsToCleanup.isEmpty()) { + String commodityId = commodityIdsToCleanup.removeFirst(); + commodityInfoService.removeById(commodityId); + } + } + + @Test + // 测试用例:调用商品表头接口,验证默认店员等级会出现在表头列表中,确保管理端可正确展示等级价格列。 + void tableNameEndpointReturnsClerkLevels() throws Exception { + ensureTenantContext(); + PlayClerkLevelInfoEntity level = + clerkLevelInfoService.getById(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID); + + mockMvc.perform(get("/shop/commodity/getTableName") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data[*].prop", hasItem(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID))) + .andExpect(jsonPath("$.data[*].name", hasItem(level.getName()))); + } + + @Test + // 测试用例:查询所有商品树,验证种子数据的父子节点与对应等级价格一起返回,确保层级结构与定价展示正确。 + void listAllIncludesSeedCommodityWithLevelPrice() throws Exception { + ensureTenantContext(); + MvcResult result = mockMvc.perform(get("/shop/commodity/listAll") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andReturn(); + + JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString()); + JsonNode data = root.get("data"); + assertThat(data.isArray()).isTrue(); + + JsonNode parentNode = findNodeById(data, ApiTestDataSeeder.DEFAULT_COMMODITY_PARENT_ID); + assertThat(parentNode).isNotNull(); + + JsonNode children = parentNode.get("children"); + JsonNode childNode = findNodeById(children, ApiTestDataSeeder.DEFAULT_COMMODITY_ID); + assertThat(childNode).isNotNull(); + + BigDecimal price = new BigDecimal(childNode.get(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID).asText()); + assertThat(price).isEqualByComparingTo(ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE); + } + + @Test + // 测试用例:调用价格更新接口,为新创建的子商品配置等级价格,验证价格落库并可复查,确保后台配置生效。 + void updateInfoAssignsLevelPricing() throws Exception { + ensureTenantContext(); + String parentId = generateId("svc-parent-"); + String childId = generateId("svc-child-"); + + PlayCommodityInfoEntity parent = createCommodity( + parentId, + ROOT_PARENT_ID, + "API测试父类目", + "N/A", + SwitchState.ENABLED, + SortPriority.PRIMARY, + AutomaticSettlementPolicy.DISABLED); + PlayCommodityInfoEntity child = createCommodity( + childId, + parent.getId(), + "API测试子项目", + "45min", + SwitchState.ENABLED, + SortPriority.PRIMARY, + AutomaticSettlementPolicy.DISABLED); + + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("id", child.getId()); + payload.put(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID, "188.50"); + + mockMvc.perform(post("/shop/commodity/updateInfo") + .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)); + + PlayCommodityAndLevelInfoEntity pricing = commodityAndLevelInfoService.lambdaQuery() + .eq(PlayCommodityAndLevelInfoEntity::getCommodityId, child.getId()) + .eq(PlayCommodityAndLevelInfoEntity::getLevelId, ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID) + .one(); + + assertThat(pricing).isNotNull(); + priceIdsToCleanup.add(pricing.getId()); + assertThat(pricing.getPrice()).isEqualByComparingTo(new BigDecimal("188.50")); + } + + @Test + // 测试用例:使用商品修改接口把自动结算等待时长从不限时(-1)调整为10分钟,验证更新后查询返回新的配置。 + void updateEndpointSwitchesAutomaticSettlement() throws Exception { + ensureTenantContext(); + String parentId = generateId("svc-parent-"); + String childId = generateId("svc-child-"); + + PlayCommodityInfoEntity parent = createCommodity( + parentId, + ROOT_PARENT_ID, + "自动结算父类目", + "N/A", + SwitchState.ENABLED, + SortPriority.PRIMARY, + AutomaticSettlementPolicy.DISABLED); + PlayCommodityInfoEntity child = createCommodity( + childId, + parent.getId(), + "自动结算子项", + "30min", + SwitchState.ENABLED, + SortPriority.PRIMARY, + AutomaticSettlementPolicy.DISABLED); + + PlayCommodityInfoEntity beforeUpdate = commodityInfoService.getById(child.getId()); + assertThat(beforeUpdate.getAutomaticSettlementDuration()) + .isEqualTo(AutomaticSettlementPolicy.DISABLED.getSeconds()); + + PlayCommodityInfoEntity updateBody = new PlayCommodityInfoEntity(); + updateBody.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + updateBody.setPId(parent.getId()); + updateBody.setItemType(child.getItemType()); + updateBody.setItemName(child.getItemName()); + updateBody.setServiceDuration("30min"); + updateBody.setAutomaticSettlementDuration(AutomaticSettlementPolicy.TEN_MINUTES.getSeconds()); + updateBody.setEnableStace(SwitchState.ENABLED.getCode()); + updateBody.setSort(SortPriority.PRIMARY.getValue()); + + mockMvc.perform(post("/shop/commodity/update/" + child.getId()) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateBody))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + PlayCommodityInfoEntity updated = commodityInfoService.getById(child.getId()); + assertThat(updated.getAutomaticSettlementDuration()) + .isEqualTo(AutomaticSettlementPolicy.TEN_MINUTES.getSeconds()); + } + + @Test + // 测试用例:为店员构造包含定价与未定价子项的类目,调用按等级查询接口,验证未设置价格的子项被过滤,只返回可售商品。 + void queryClerkAllCommodityByLevelSkipsUnpricedChildren() throws Exception { + ensureTenantContext(); + String parentId = generateId("svc-parent-"); + String pricedChildId = generateId("svc-child-priced-"); + String unpricedChildId = generateId("svc-child-unpriced-"); + + PlayCommodityInfoEntity parent = createCommodity( + parentId, + ROOT_PARENT_ID, + "等级过滤父类目", + "N/A", + SwitchState.ENABLED, + SortPriority.SECONDARY, + AutomaticSettlementPolicy.DISABLED); + PlayCommodityInfoEntity pricedChild = createCommodity( + pricedChildId, + parent.getId(), + "有定价子项", + "60min", + SwitchState.ENABLED, + SortPriority.PRIMARY, + AutomaticSettlementPolicy.DISABLED); + createCommodity( + unpricedChildId, + parent.getId(), + "无定价子项", + "60min", + SwitchState.ENABLED, + SortPriority.SECONDARY, + AutomaticSettlementPolicy.DISABLED); + + PlayCommodityAndLevelInfoEntity price = createPrice(pricedChild.getId(), new BigDecimal("99.90")); + linkCommodityToClerk(parent, SwitchState.ENABLED, SortPriority.PRIMARY); + + MvcResult result = mockMvc.perform(get("/wx/commodity/custom/queryClerkAllCommodityByLevel") + .param("id", ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andReturn(); + + JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString()); + JsonNode data = root.get("data"); + JsonNode parentNode = findNodeById(data, parent.getId()); + assertThat(parentNode).isNotNull(); + + JsonNode children = parentNode.get("child"); + assertThat(children.isArray()).isTrue(); + JsonNode pricedNode = findNodeById(children, pricedChild.getId()); + assertThat(pricedNode).isNotNull(); + assertThat(pricedNode.get("price").decimalValue()).isEqualByComparingTo(new BigDecimal("99.90")); + + JsonNode removedNode = findNodeById(children, unpricedChildId); + assertThat(removedNode).isNull(); + } + + @Test + // 测试用例:为店员同时配置上架与下架的类目,调用顾客查询接口,确认停用类目被隐藏,避免顾客看到不可售项目。 + void customerCommodityQueryFiltersDisabledParents() throws Exception { + ensureTenantContext(); + String enabledParentId = generateId("svc-enabled-parent-"); + String disabledParentId = generateId("svc-disabled-parent-"); + + PlayCommodityInfoEntity enabledParent = createCommodity( + enabledParentId, + ROOT_PARENT_ID, + "顾客可见父类目", + "N/A", + SwitchState.ENABLED, + SortPriority.PRIMARY, + AutomaticSettlementPolicy.DISABLED); + PlayCommodityInfoEntity enabledChild = createCommodity( + generateId("svc-enabled-child-"), + enabledParent.getId(), + "顾客可见子项", + "30min", + SwitchState.ENABLED, + SortPriority.PRIMARY, + AutomaticSettlementPolicy.DISABLED); + PlayCommodityAndLevelInfoEntity enabledPrice = createPrice(enabledChild.getId(), new BigDecimal("45.00")); + + PlayCommodityInfoEntity disabledParent = createCommodity( + disabledParentId, + ROOT_PARENT_ID, + "顾客屏蔽父类目", + "N/A", + SwitchState.ENABLED, + SortPriority.SECONDARY, + AutomaticSettlementPolicy.DISABLED); + PlayCommodityInfoEntity disabledChild = createCommodity( + generateId("svc-disabled-child-"), + disabledParent.getId(), + "顾客屏蔽子项", + "30min", + SwitchState.ENABLED, + SortPriority.PRIMARY, + AutomaticSettlementPolicy.DISABLED); + PlayCommodityAndLevelInfoEntity disabledPrice = createPrice(disabledChild.getId(), new BigDecimal("55.00")); + + linkCommodityToClerk(enabledParent, SwitchState.ENABLED, SortPriority.PRIMARY); + linkCommodityToClerk(disabledParent, SwitchState.DISABLED, SortPriority.SECONDARY); + + MvcResult result = mockMvc.perform(get("/wx/commodity/custom/queryClerkAllCommodity") + .param("id", ApiTestDataSeeder.DEFAULT_CLERK_ID) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andReturn(); + + JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString()); + JsonNode data = root.get("data"); + + JsonNode enabledNode = findNodeById(data, enabledParent.getId()); + assertThat(enabledNode).isNotNull(); + + JsonNode disabledNode = findNodeById(data, disabledParent.getId()); + assertThat(disabledNode).isNull(); + } + + @Test + // 测试用例:顾客随机下单选择未配置价格的商品时,接口应返回“服务项目不存在”的业务错误,验证错误商品被拒单。 + void randomOrderRejectsCommodityWithoutPricing() throws Exception { + ensureTenantContext(); + resetCustomerBalance(); + String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); + customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken); + + PlayCommodityInfoEntity parent = createCommodity( + generateId("svc-parent-"), + ROOT_PARENT_ID, + "未定价父类目", + "N/A", + SwitchState.ENABLED, + SortPriority.PRIMARY, + AutomaticSettlementPolicy.DISABLED); + PlayCommodityInfoEntity child = createCommodity( + generateId("svc-child-"), + parent.getId(), + "未定价子项", + "45min", + SwitchState.ENABLED, + SortPriority.PRIMARY, + AutomaticSettlementPolicy.DISABLED); + + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("sex", OrderConstant.Gender.FEMALE.getCode()); + payload.put("levelId", ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID); + payload.put("commodityId", child.getId()); + payload.put("commodityQuantity", SINGLE_COMMODITY_QUANTITY); + payload.put("weiChatCode", "apitest-customer-wx"); + payload.put("excludeHistory", HistoryFilter.INCLUDE.getCode()); + payload.putArray("couponIds"); + payload.put("remark", "未定价拒单校验"); + + mockMvc.perform(post("/wx/custom/order/random") + .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.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(500)) + .andExpect(jsonPath("$.message").value("服务项目不存在")); + } + + private JsonNode findNodeById(JsonNode arrayNode, String id) { + if (arrayNode == null || !arrayNode.isArray()) { + return null; + } + for (JsonNode node : arrayNode) { + if (node.has("id") && id.equals(node.get("id").asText())) { + return node; + } + } + return null; + } + + private PlayCommodityInfoEntity createCommodity( + String id, + String parentId, + String name, + String duration, + SwitchState switchState, + SortPriority sortPriority, + AutomaticSettlementPolicy settlementPolicy) { + ensureTenantContext(); + PlayCommodityInfoEntity entity = new PlayCommodityInfoEntity(); + entity.setId(id); + entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + entity.setPId(parentId); + entity.setItemType("api-test"); + entity.setItemName(name); + entity.setServiceDuration(duration); + entity.setAutomaticSettlementDuration(settlementPolicy.getSeconds()); + entity.setEnableStace(switchState.getCode()); + entity.setSort(sortPriority.getValue()); + commodityInfoService.save(entity); + commodityIdsToCleanup.addFirst(entity.getId()); + return entity; + } + + private PlayCommodityAndLevelInfoEntity createPrice(String commodityId, BigDecimal price) { + ensureTenantContext(); + PlayCommodityAndLevelInfoEntity entity = new PlayCommodityAndLevelInfoEntity(); + entity.setId(IdUtils.getUuid()); + entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + entity.setCommodityId(commodityId); + entity.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID); + entity.setPrice(price); + entity.setSort(DEFAULT_PRICE_SORT); + commodityAndLevelInfoService.save(entity); + priceIdsToCleanup.add(entity.getId()); + return entity; + } + + private PlayClerkCommodityEntity linkCommodityToClerk( + PlayCommodityInfoEntity parent, + SwitchState switchState, + SortPriority sortPriority) { + ensureTenantContext(); + PlayClerkCommodityEntity entity = new PlayClerkCommodityEntity(); + entity.setId(IdUtils.getUuid()); + entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID); + entity.setCommodityId(parent.getId()); + entity.setCommodityName(parent.getItemName()); + entity.setEnablingState(switchState.getCode()); + entity.setSort(sortPriority.getValue()); + clerkCommodityService.save(entity); + clerkCommodityIdsToCleanup.add(entity.getId()); + return entity; + } + + private String generateId(String prefix) { + return prefix + IdUtils.getUuid().replace("-", ""); + } + + @Override + protected void ensureTenantContext() { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + } +} diff --git a/play-admin/src/test/java/com/starry/admin/api/SysTenantPackageControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/SysTenantPackageControllerApiTest.java index 1e1fa1e..271522f 100644 --- a/play-admin/src/test/java/com/starry/admin/api/SysTenantPackageControllerApiTest.java +++ b/play-admin/src/test/java/com/starry/admin/api/SysTenantPackageControllerApiTest.java @@ -10,6 +10,8 @@ import org.junit.jupiter.api.Test; class SysTenantPackageControllerApiTest extends AbstractApiTest { @Test + // 测试用例:以默认平台管理员身份拉取套餐精简列表,应返回 200, + // 并且数据集中必须包含种子数据 pkg-basic,验证套餐列表接口能正确曝光基础套餐。 void getSimpleListReturnsSeededPackage() throws Exception { mockMvc.perform(get("/platform/package/get-simple-list") .header(USER_HEADER, DEFAULT_USER) diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCustomGiftOrderApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCustomGiftOrderApiTest.java new file mode 100644 index 0000000..481b8d6 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/WxCustomGiftOrderApiTest.java @@ -0,0 +1,122 @@ +package com.starry.admin.api; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +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.starry.admin.common.apitest.ApiTestDataSeeder; +import com.starry.admin.modules.custom.module.entity.PlayCustomGiftInfoEntity; +import com.starry.admin.modules.custom.service.IPlayCustomGiftInfoService; +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.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.modules.withdraw.entity.EarningsLineEntity; +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 org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; + +class WxCustomGiftOrderApiTest extends WxCustomOrderApiTestSupport { + + @Autowired + private IPlayGiftInfoService playGiftInfoService; + + @Autowired + private IPlayCustomGiftInfoService playCustomGiftInfoService; + + @Autowired + private IPlayClerkGiftInfoService playClerkGiftInfoService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + // 测试用例:用户余额充足且携带有效登录态时,请求 /wx/custom/order/gift 下单指定礼物, + // 期望生成已完成的礼物奖励订单、产生对应收益记录,同时校验用户/陪玩师礼物计数与账户余额随订单金额同步更新。 + void giftOrderCreatesCompletedRewardAndUpdatesGiftCounters() throws Exception { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + try { + resetCustomerBalance(); + String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); + customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken); + + PlayGiftInfoEntity gift = playGiftInfoService.selectPlayGiftInfoById(ApiTestDataSeeder.DEFAULT_GIFT_ID); + Assertions.assertThat(gift).as("seeded gift should exist").isNotNull(); + + int giftQuantity = 2; + String remark = "API gift order " + IdUtils.getUuid(); + BigDecimal totalAmount = gift.getPrice().multiply(BigDecimal.valueOf(giftQuantity)); + BigDecimal initialBalance = customUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID).getAccountBalance(); + + String payload = "{" + + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + + "\"giftId\":\"" + ApiTestDataSeeder.DEFAULT_GIFT_ID + "\"," + + "\"giftQuantity\":" + giftQuantity + "," + + "\"weiChatCode\":\"apitest-customer-wx\"," + + "\"remark\":\"" + remark + "\"" + + "}"; + + String response = mockMvc.perform(post("/wx/custom/order/gift") + .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").isNotEmpty()) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode root = objectMapper.readTree(response); + String orderId = root.path("data").asText(); + + ensureTenantContext(); + PlayOrderInfoEntity order = playOrderInfoService.selectOrderInfoById(orderId); + Assertions.assertThat(order).isNotNull(); + Assertions.assertThat(order.getRemark()).isEqualTo(remark); + Assertions.assertThat(order.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.COMPLETED.getCode()); + Assertions.assertThat(order.getPlaceType()).isEqualTo(OrderConstant.PlaceType.REWARD.getCode()); + Assertions.assertThat(order.getRewardType()).isEqualTo(OrderConstant.RewardType.GIFT.getCode()); + Assertions.assertThat(order.getFinalAmount()).isEqualByComparingTo(totalAmount); + + ensureTenantContext(); + long earningsCount = earningsService.lambdaQuery() + .eq(EarningsLineEntity::getOrderId, orderId) + .count(); + Assertions.assertThat(earningsCount).isEqualTo(1); + + ensureTenantContext(); + PlayCustomGiftInfoEntity customerGift = playCustomGiftInfoService.lambdaQuery() + .eq(PlayCustomGiftInfoEntity::getCustomId, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .eq(PlayCustomGiftInfoEntity::getGiffId, ApiTestDataSeeder.DEFAULT_GIFT_ID) + .one(); + Assertions.assertThat(customerGift).isNotNull(); + Assertions.assertThat(customerGift.getGiffNumber()).isEqualTo((long) giftQuantity); + + ensureTenantContext(); + PlayClerkGiftInfoEntity clerkGift = playClerkGiftInfoService.lambdaQuery() + .eq(PlayClerkGiftInfoEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID) + .eq(PlayClerkGiftInfoEntity::getGiffId, ApiTestDataSeeder.DEFAULT_GIFT_ID) + .one(); + Assertions.assertThat(clerkGift).isNotNull(); + Assertions.assertThat(clerkGift.getGiffNumber()).isEqualTo((long) giftQuantity); + + BigDecimal finalBalance = customUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .getAccountBalance(); + Assertions.assertThat(finalBalance).isEqualByComparingTo(initialBalance.subtract(totalAmount)); + } finally { + CustomSecurityContextHolder.remove(); + } + } +} diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderApiTestSupport.java b/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderApiTestSupport.java new file mode 100644 index 0000000..f04a5ed --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderApiTestSupport.java @@ -0,0 +1,202 @@ +package com.starry.admin.api; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.starry.admin.common.apitest.ApiTestDataSeeder; +import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity; +import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService; +import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; +import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; +import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; +import com.starry.admin.modules.order.module.constant.OrderConstant; +import com.starry.admin.modules.order.module.constant.OrderConstant.PlaceType; +import com.starry.admin.modules.order.service.IPlayOrderInfoService; +import com.starry.admin.modules.shop.module.constant.CouponUseState; +import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity; +import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity; +import com.starry.admin.modules.shop.module.enums.CouponClaimConditionType; +import com.starry.admin.modules.shop.module.enums.CouponDiscountType; +import com.starry.admin.modules.shop.module.enums.CouponOnlineState; +import com.starry.admin.modules.shop.module.enums.CouponValidityPeriodType; +import com.starry.admin.modules.shop.service.IPlayCouponDetailsService; +import com.starry.admin.modules.shop.service.IPlayCouponInfoService; +import com.starry.admin.modules.weichat.service.WxTokenService; +import com.starry.admin.modules.withdraw.service.IEarningsService; +import com.starry.admin.modules.withdraw.service.IFreezePolicyService; +import com.starry.admin.utils.SecurityUtils; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.util.StringUtils; + +abstract class WxCustomOrderApiTestSupport extends AbstractApiTest { + + protected static final DateTimeFormatter DATE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + protected final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + protected WxTokenService wxTokenService; + + @Autowired + protected IPlayOrderInfoService playOrderInfoService; + + @Autowired + protected IPlayCustomUserInfoService customUserInfoService; + + @Autowired + protected IPlayClerkUserInfoService clerkUserInfoService; + + @Autowired + protected IEarningsService earningsService; + + @Autowired + protected IFreezePolicyService freezePolicyService; + + @Autowired + protected IPlayClerkLevelInfoService clerkLevelInfoService; + + @Autowired + protected IPlayCouponInfoService couponInfoService; + + @Autowired + protected IPlayCouponDetailsService couponDetailsService; + + protected void resetCustomerBalance() { + BigDecimal balance = new BigDecimal("200.00"); + customUserInfoService.updateAccountBalanceById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, balance); + customUserInfoService.lambdaUpdate() + .set(PlayCustomUserInfoEntity::getAccumulatedConsumptionAmount, BigDecimal.ZERO) + .set(PlayCustomUserInfoEntity::getAccumulatedRechargeAmount, balance) + .set(PlayCustomUserInfoEntity::getAccountState, "1") + .set(PlayCustomUserInfoEntity::getSubscribeState, "1") + .set(PlayCustomUserInfoEntity::getPurchaseState, "1") + .set(PlayCustomUserInfoEntity::getMobilePhoneState, "1") + .eq(PlayCustomUserInfoEntity::getId, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .update(); + } + + protected void ensureTenantContext() { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + } + + protected String createCouponId(String prefix) { + String raw = com.starry.common.utils.IdUtils.getUuid().replace("-", ""); + String suffix = raw.length() > 10 ? raw.substring(0, 10) : raw; + return prefix + suffix; + } + + protected String ensureFixedReductionCoupon( + String couponId, + OrderConstant.PlaceType placeType, + BigDecimal discountAmount, + BigDecimal minAmount, + String attributionDiscounts) { + ensureTenantContext(); + PlayCouponInfoEntity existing = couponInfoService.getById(couponId); + if (existing != null) { + return couponId; + } + + PlayCouponInfoEntity coupon = new PlayCouponInfoEntity(); + coupon.setId(couponId); + coupon.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + coupon.setCouponName(couponId); + coupon.setValidityPeriodType(CouponValidityPeriodType.PERMANENT.getCode()); + coupon.setUseMinAmount(minAmount); + coupon.setDiscountType(CouponDiscountType.FULL_REDUCTION.getCode()); + coupon.setDiscountAmount(discountAmount); + coupon.setDiscountContent("立减" + discountAmount.setScale(2, RoundingMode.HALF_UP) + "元"); + coupon.setAttributionDiscounts(attributionDiscounts); + coupon.setPlaceType(Collections.singletonList(placeType.getCode())); + coupon.setClerkType("0"); + coupon.setCouponQuantity(200); + coupon.setIssuedQuantity(0); + coupon.setRemainingQuantity(200); + coupon.setClerkObtainedMaxQuantity(5); + coupon.setClaimConditionType(CouponClaimConditionType.ALL.code()); + coupon.setCustomLevelCheckType("0"); + coupon.setCustomSexCheckType("0"); + coupon.setCustomLevel(Collections.emptyList()); + coupon.setCustomSex(Collections.emptyList()); + coupon.setCustomFollowStatusCheckType("0"); + coupon.setCustomFollowStatus("1"); + coupon.setNewUser("0"); + coupon.setCouponOnLineState(CouponOnlineState.ONLINE.getCode()); + couponInfoService.save(coupon); + return couponId; + } + + protected String claimCouponForOrder( + String couponInfoId, + PlaceType placeType, + String customerToken) throws Exception { + ensureTenantContext(); + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/wx/coupon/custom/obtainCoupon") + .param("id", couponInfoId) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(com.starry.common.constant.Constants.CUSTOM_USER_LOGIN_TOKEN, + com.starry.common.constant.Constants.TOKEN_PREFIX + customerToken)) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.code").value(200)) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.data.success").value(true)); + + String levelId = placeType == PlaceType.SPECIFIED ? "" : ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID; + String clerkId = placeType == PlaceType.SPECIFIED ? ApiTestDataSeeder.DEFAULT_CLERK_ID : ""; + String queryPayload = "{" + + "\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," + + "\"levelId\":\"" + levelId + "\"," + + "\"clerkId\":\"" + clerkId + "\"," + + "\"placeType\":\"" + placeType.getCode() + "\"," + + "\"commodityQuantity\":1" + + "}"; + + MvcResult result = mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/wx/coupon/custom/queryByOrder") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(com.starry.common.constant.Constants.CUSTOM_USER_LOGIN_TOKEN, + com.starry.common.constant.Constants.TOKEN_PREFIX + customerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(queryPayload)) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.code").value(200)) + .andReturn(); + + String responseBody = result.getResponse().getContentAsString(); + JsonNode dataNode = objectMapper.readTree(responseBody).path("data"); + if (!dataNode.isArray()) { + throw new AssertionError("未返回优惠券列表"); + } + String detailId = null; + for (JsonNode node : dataNode) { + if (!couponInfoId.equals(node.path("couponName").asText())) { + continue; + } + if (!"1".equals(node.path("available").asText())) { + throw new AssertionError("优惠券不可用: " + node.path("reasonForUnavailableUse").asText()); + } + detailId = node.path("id").asText(); + if (!StringUtils.hasText(detailId)) { + throw new AssertionError("优惠券详情ID缺失"); + } + return detailId; + } + throw new AssertionError("未在可用列表中找到优惠券 " + couponInfoId + ",响应:" + responseBody); + } + + protected void assertCouponUsed(String couponDetailId) { + ensureTenantContext(); + PlayCouponDetailsEntity detail = couponDetailsService.getById(couponDetailId); + if (detail == null) { + throw new AssertionError("优惠券详情不存在: " + couponDetailId); + } + if (!CouponUseState.USED.getCode().equals(detail.getUseState())) { + throw new AssertionError("优惠券未标记为已使用"); + } + } +} diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCustomRandomOrderApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCustomRandomOrderApiTest.java new file mode 100644 index 0000000..59ccba6 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/WxCustomRandomOrderApiTest.java @@ -0,0 +1,612 @@ +package com.starry.admin.api; + +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +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.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.common.task.OverdueOrderHandlerTask; +import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity; +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.CouponUseState; +import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity; +import com.starry.admin.modules.shop.module.vo.PlayCouponDetailsReturnVo; +import com.starry.admin.modules.weichat.service.WxCustomMpService; +import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; +import com.starry.admin.modules.withdraw.enums.EarningsType; +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.math.RoundingMode; +import java.time.LocalDateTime; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; + +class WxCustomRandomOrderApiTest extends WxCustomOrderApiTestSupport { + + @MockBean + private WxCustomMpService wxCustomMpService; + + @MockBean + private OverdueOrderHandlerTask overdueOrderHandlerTask; + + @org.springframework.beans.factory.annotation.Autowired + private com.starry.admin.modules.order.service.IPlayOrderInfoService orderInfoService; + + @Test + // 测试用例:客户带随机下单请求命中默认等级与服务,接口应返回成功文案, + // 并新增一条处于待接单状态的随机订单,金额、商品信息与 remark 与提交参数保持一致。 + void randomOrderCreatesPendingOrder() throws Exception { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + try { + resetCustomerBalance(); + String rawToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); + customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, rawToken); + + String remark = "API test random order " + IdUtils.getUuid(); + ensureTenantContext(); + long beforeCount = playOrderInfoService.lambdaQuery() + .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .eq(PlayOrderInfoEntity::getPlaceType, OrderConstant.PlaceType.RANDOM.getCode()) + .count(); + + String payload = "{" + + "\"sex\":\"2\"," + + "\"levelId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID + "\"," + + "\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," + + "\"commodityQuantity\":1," + + "\"weiChatCode\":\"apitest-customer-wx\"," + + "\"excludeHistory\":\"0\"," + + "\"couponIds\":[]," + + "\"remark\":\"" + remark + "\"" + + "}"; + + mockMvc.perform(post("/wx/custom/order/random") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + rawToken) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").value("下单成功")); + + ensureTenantContext(); + long afterCount = playOrderInfoService.lambdaQuery() + .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .eq(PlayOrderInfoEntity::getPlaceType, OrderConstant.PlaceType.RANDOM.getCode()) + .count(); + + Assertions.assertThat(afterCount).isEqualTo(beforeCount + 1); + + PlayOrderInfoEntity latest = playOrderInfoService.lambdaQuery() + .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .eq(PlayOrderInfoEntity::getPlaceType, OrderConstant.PlaceType.RANDOM.getCode()) + .eq(PlayOrderInfoEntity::getRemark, remark) + .orderByDesc(PlayOrderInfoEntity::getCreatedTime) + .last("limit 1") + .one(); + + Assertions.assertThat(latest).isNotNull(); + Assertions.assertThat(latest.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.PENDING.getCode()); + Assertions.assertThat(latest.getCommodityId()).isEqualTo(ApiTestDataSeeder.DEFAULT_COMMODITY_ID); + Assertions.assertThat(latest.getOrderMoney()).isNotNull(); + } finally { + CustomSecurityContextHolder.remove(); + } + } + + @Test + void randomOrderCancellationReleasesCoupon() throws Exception { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + String remark = "API random cancel coupon " + IdUtils.getUuid(); + BigDecimal discount = new BigDecimal("15.00"); + try { + resetCustomerBalance(); + String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); + customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken); + + String couponInfoId = createCouponId("cpn-rc-"); + ensureFixedReductionCoupon( + couponInfoId, + OrderConstant.PlaceType.RANDOM, + discount, + new BigDecimal("60.00"), + "0"); + String couponDetailId = claimCouponForOrder(couponInfoId, OrderConstant.PlaceType.RANDOM, customerToken); + + String payload = "{" + + "\"sex\":\"2\"," + + "\"levelId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID + "\"," + + "\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," + + "\"commodityQuantity\":1," + + "\"weiChatCode\":\"apitest-customer-wx\"," + + "\"excludeHistory\":\"0\"," + + "\"couponIds\":[\"" + couponDetailId + "\"]," + + "\"remark\":\"" + remark + "\"" + + "}"; + + mockMvc.perform(post("/wx/custom/order/random") + .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)); + + ensureTenantContext(); + PlayOrderInfoEntity order = playOrderInfoService.lambdaQuery() + .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .eq(PlayOrderInfoEntity::getRemark, remark) + .orderByDesc(PlayOrderInfoEntity::getCreatedTime) + .last("limit 1") + .one(); + Assertions.assertThat(order).isNotNull(); + + String cancelPayload = "{" + + "\"orderId\":\"" + order.getId() + "\"," + + "\"refundReason\":\"测试取消\"," + + "\"images\":[]" + + "}"; + mockMvc.perform(post("/wx/custom/order/cancellation") + .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(cancelPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").value("取消成功")); + + ensureTenantContext(); + PlayOrderInfoEntity cancelled = playOrderInfoService.selectOrderInfoById(order.getId()); + Assertions.assertThat(cancelled.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.CANCELLED.getCode()); + + PlayCouponDetailsEntity detail = couponDetailsService.getById(couponDetailId); + Assertions.assertThat(detail).isNotNull(); + Assertions.assertThat(detail.getUseState()) + .as("取消订单后优惠券应恢复为未使用") + .isEqualTo(CouponUseState.UNUSED.getCode()); + } finally { + CustomSecurityContextHolder.remove(); + } + } + + @Test + // 测试用例:订单已接单后由管理员强制取消,也应释放所使用的优惠券 + void randomOrderForceCancelReleasesCoupon() throws Exception { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + String remark = "API random force cancel " + IdUtils.getUuid(); + BigDecimal discount = new BigDecimal("12.00"); + try { + reset(wxCustomMpService); + resetCustomerBalance(); + + String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); + customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken); + String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID); + clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken); + + String couponInfoId = createCouponId("cpn-rf-"); + ensureFixedReductionCoupon( + couponInfoId, + OrderConstant.PlaceType.RANDOM, + discount, + new BigDecimal("60.00"), + "0"); + String couponDetailId = claimCouponForOrder(couponInfoId, OrderConstant.PlaceType.RANDOM, customerToken); + + String payload = "{" + + "\"sex\":\"2\"," + + "\"levelId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID + "\"," + + "\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," + + "\"commodityQuantity\":1," + + "\"weiChatCode\":\"apitest-customer-wx\"," + + "\"excludeHistory\":\"0\"," + + "\"couponIds\":[\"" + couponDetailId + "\"]," + + "\"remark\":\"" + remark + "\"" + + "}"; + + mockMvc.perform(post("/wx/custom/order/random") + .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)); + + ensureTenantContext(); + PlayOrderInfoEntity order = playOrderInfoService.lambdaQuery() + .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .eq(PlayOrderInfoEntity::getRemark, remark) + .orderByDesc(PlayOrderInfoEntity::getCreatedTime) + .last("limit 1") + .one(); + Assertions.assertThat(order).isNotNull(); + + mockMvc.perform(get("/wx/clerk/order/accept") + .param("id", order.getId()) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").value("成功")); + + ensureTenantContext(); + orderInfoService.forceCancelOngoingOrder( + "2", + ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID, + order.getId(), + order.getFinalAmount(), + "管理员强制取消", + java.util.Collections.emptyList()); + + ensureTenantContext(); + PlayOrderInfoEntity cancelled = playOrderInfoService.selectOrderInfoById(order.getId()); + Assertions.assertThat(cancelled.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.CANCELLED.getCode()); + + PlayCouponDetailsEntity detail = couponDetailsService.getById(couponDetailId); + Assertions.assertThat(detail).isNotNull(); + Assertions.assertThat(detail.getUseState()) + .as("强制取消订单后优惠券应恢复为未使用") + .isEqualTo(CouponUseState.UNUSED.getCode()); + } finally { + CustomSecurityContextHolder.remove(); + } + } + + @Test + // 测试用例:随机单携带店铺承担的满减券下单,需依据折后金额推送通知, + // 完整履约后优惠券应置为已使用且收益与预计工资保持一致。 + void randomOrderLifecycleWithCouponAdjustsRevenueAndNotifications() throws Exception { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + String remark = "API random coupon " + IdUtils.getUuid(); + BigDecimal discount = new BigDecimal("20.00"); + try { + reset(wxCustomMpService); + resetCustomerBalance(); + + String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); + customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken); + String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID); + clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken); + + BigDecimal grossAmount = ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE; + String couponInfoId = createCouponId("cpn-r-"); + ensureFixedReductionCoupon( + couponInfoId, + OrderConstant.PlaceType.RANDOM, + discount, + new BigDecimal("60.00"), + "0"); + String couponDetailId = claimCouponForOrder(couponInfoId, OrderConstant.PlaceType.RANDOM, customerToken); + + String payload = "{" + + "\"sex\":\"2\"," + + "\"levelId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID + "\"," + + "\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," + + "\"commodityQuantity\":1," + + "\"weiChatCode\":\"apitest-customer-wx\"," + + "\"excludeHistory\":\"0\"," + + "\"couponIds\":[\"" + couponDetailId + "\"]," + + "\"remark\":\"" + remark + "\"" + + "}"; + + mockMvc.perform(post("/wx/custom/order/random") + .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").value("下单成功")); + + ensureTenantContext(); + PlayOrderInfoEntity order = playOrderInfoService.lambdaQuery() + .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .eq(PlayOrderInfoEntity::getRemark, remark) + .orderByDesc(PlayOrderInfoEntity::getCreatedTime) + .last("limit 1") + .one(); + + Assertions.assertThat(order).isNotNull(); + Assertions.assertThat(order.getCouponIds()).contains(couponDetailId); + + BigDecimal expectedNet = grossAmount.subtract(discount).setScale(2, RoundingMode.HALF_UP); + Assertions.assertThat(order.getFinalAmount()).isEqualByComparingTo(expectedNet); + Assertions.assertThat(order.getDiscountAmount()).isEqualByComparingTo(discount); + + verify(wxCustomMpService).sendCreateOrderMessageBatch( + anyList(), + eq(order.getOrderNo()), + eq(expectedNet.toString()), + eq(order.getCommodityName()), + eq(order.getId())); + + String orderId = order.getId(); + + mockMvc.perform(get("/wx/clerk/order/accept") + .param("id", orderId) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").value("成功")); + + verify(wxCustomMpService).sendOrderMessageAsync(argThat(o -> orderId.equals(o.getId()))); + + mockMvc.perform(get("/wx/clerk/order/start") + .param("id", orderId) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").value("成功")); + + mockMvc.perform(get("/wx/custom/order/end") + .param("id", orderId) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").value("成功")); + + verify(wxCustomMpService).sendOrderFinishMessageAsync(argThat(o -> orderId.equals(o.getId()))); + + ensureTenantContext(); + PlayOrderInfoEntity completedOrder = playOrderInfoService.selectOrderInfoById(orderId); + int ratio = completedOrder.getEstimatedRevenueRatio(); + BigDecimal baseRevenue = grossAmount + .multiply(BigDecimal.valueOf(ratio)) + .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + PlayCouponDetailsReturnVo detail = couponDetailsService.selectPlayCouponDetailsById(couponDetailId); + BigDecimal clerkDiscount = BigDecimal.ZERO; + if (detail != null && "0".equals(detail.getAttributionDiscounts())) { + BigDecimal discountAmount = detail.getDiscountAmount() == null ? BigDecimal.ZERO : detail.getDiscountAmount(); + clerkDiscount = discountAmount; + } + BigDecimal expectedRevenue = baseRevenue.subtract(clerkDiscount).max(BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP); + Assertions.assertThat(completedOrder.getEstimatedRevenue()).isEqualByComparingTo(expectedRevenue); + + EarningsLineEntity earningsLine = earningsService.lambdaQuery() + .eq(EarningsLineEntity::getOrderId, orderId) + .last("limit 1") + .one(); + Assertions.assertThat(earningsLine).isNotNull(); + Assertions.assertThat(earningsLine.getAmount()).isEqualByComparingTo(expectedRevenue); + + assertCouponUsed(couponDetailId); + } finally { + CustomSecurityContextHolder.remove(); + } + } + + @Test + // 测试用例:模拟随机订单完整生命周期——客户下单、陪玩师接单/开局、客户完结, + // 期间验证微信通知被触发、收益记录生成、冻结解冻时间正确,并校准日终统计接口返回的订单数、GMV 与预计收益。 + void randomOrderLifecycleGeneratesEarningsAndNotifications() throws Exception { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + String remark = "API random lifecycle " + IdUtils.getUuid(); + try { + resetCustomerBalance(); + String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); + customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken); + String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID); + clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken); + + LocalDateTime overviewWindowStart = LocalDateTime.now().minusMinutes(5); + LocalDateTime overviewWindowEnd = LocalDateTime.now().plusMinutes(5); + OverviewSnapshot overviewBefore = fetchOverview(overviewWindowStart, overviewWindowEnd); + + String orderId = placeRandomOrder(remark, customerToken); + + reset(wxCustomMpService); + + ensureTenantContext(); + mockMvc.perform(get("/wx/clerk/order/accept") + .param("id", orderId) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").value("成功")); + + verify(wxCustomMpService).sendOrderMessageAsync(argThat(order -> order.getId().equals(orderId))); + + reset(wxCustomMpService); + + ensureTenantContext(); + mockMvc.perform(get("/wx/clerk/order/start") + .param("id", orderId) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").value("成功")); + + ensureTenantContext(); + long earningsBefore = earningsService.lambdaQuery() + .eq(EarningsLineEntity::getOrderId, orderId) + .count(); + Assertions.assertThat(earningsBefore).isZero(); + + ensureTenantContext(); + mockMvc.perform(get("/wx/custom/order/end") + .param("id", orderId) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").value("成功")); + + verify(wxCustomMpService).sendOrderFinishMessageAsync(argThat(order -> order.getId().equals(orderId))); + + ensureTenantContext(); + long earningsAfter = earningsService.lambdaQuery() + .eq(EarningsLineEntity::getOrderId, orderId) + .count(); + Assertions.assertThat(earningsAfter).isEqualTo(1); + + ensureTenantContext(); + PlayOrderInfoEntity completedOrder = playOrderInfoService.selectOrderInfoById(orderId); + Assertions.assertThat(completedOrder.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.COMPLETED.getCode()); + Assertions.assertThat(completedOrder.getEstimatedRevenue()).isNotNull(); + + EarningsLineEntity earningsLine = earningsService.lambdaQuery() + .eq(EarningsLineEntity::getOrderId, orderId) + .one(); + Assertions.assertThat(earningsLine).isNotNull(); + Assertions.assertThat(earningsLine.getAmount()).isEqualByComparingTo(completedOrder.getEstimatedRevenue()); + int freezeHours = freezePolicyService.resolveFreezeHours( + ApiTestDataSeeder.DEFAULT_TENANT_ID, + ApiTestDataSeeder.DEFAULT_CLERK_ID); + LocalDateTime expectedUnlock = completedOrder.getOrderEndTime().plusHours(freezeHours); + Assertions.assertThat(earningsLine.getUnlockTime()).isEqualTo(expectedUnlock); + Assertions.assertThat(earningsLine.getUnlockTime()).isAfter(LocalDateTime.now().minusMinutes(5)); + Assertions.assertThat(earningsLine.getEarningType()).isEqualTo(EarningsType.ORDER); + + OverviewSnapshot overviewAfter = fetchOverview(overviewWindowStart, overviewWindowEnd); + Assertions.assertThat(overviewAfter.totalOrderCount - overviewBefore.totalOrderCount) + .isEqualTo(1); + Assertions.assertThat(overviewAfter.totalGmv.subtract(overviewBefore.totalGmv).setScale(2, RoundingMode.HALF_UP)) + .isEqualByComparingTo(completedOrder.getFinalAmount()); + Assertions.assertThat(overviewAfter.totalEstimatedRevenue.subtract(overviewBefore.totalEstimatedRevenue).setScale(2, RoundingMode.HALF_UP)) + .isEqualByComparingTo(completedOrder.getEstimatedRevenue()); + Assertions.assertThat(overviewAfter.clerkOrderCount - overviewBefore.clerkOrderCount) + .isEqualTo(1); + Assertions.assertThat(overviewAfter.clerkGmv.subtract(overviewBefore.clerkGmv).setScale(2, RoundingMode.HALF_UP)) + .isEqualByComparingTo(completedOrder.getFinalAmount()); + } finally { + CustomSecurityContextHolder.remove(); + } + } + + private OverviewSnapshot fetchOverview(LocalDateTime start, LocalDateTime end) throws Exception { + String payload = "{" + + "\"includeSummary\":true," + + "\"includeRankings\":true," + + "\"limit\":5," + + "\"endOrderTime\":[\"" + start.format(DATE_TIME_FORMATTER) + "\",\"" + + end.format(DATE_TIME_FORMATTER) + "\"]" + + "}"; + + MvcResult result = mockMvc.perform(post("/statistics/performance/overview") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andReturn(); + + JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).path("data"); + OverviewSnapshot snapshot = new OverviewSnapshot(); + JsonNode summary = data.path("summary"); + snapshot.totalOrderCount = summary.path("totalOrderCount").asInt(); + snapshot.totalGmv = new BigDecimal(summary.path("totalGmv").asText("0")); + snapshot.totalEstimatedRevenue = new BigDecimal(summary.path("totalEstimatedRevenue").asText("0")); + + JsonNode rankings = data.path("rankings"); + if (rankings.isArray()) { + for (JsonNode node : rankings) { + if (ApiTestDataSeeder.DEFAULT_CLERK_ID.equals(node.path("clerkId").asText())) { + snapshot.clerkOrderCount = node.path("orderCount").asInt(); + snapshot.clerkGmv = new BigDecimal(node.path("gmv").asText("0")); + break; + } + } + } + return snapshot; + } + + private static class OverviewSnapshot { + private int totalOrderCount; + private BigDecimal totalGmv = BigDecimal.ZERO; + private BigDecimal totalEstimatedRevenue = BigDecimal.ZERO; + private int clerkOrderCount; + private BigDecimal clerkGmv = BigDecimal.ZERO; + } + + private String placeRandomOrder(String remark, String customerToken) throws Exception { + ensureTenantContext(); + long beforeCount = playOrderInfoService.lambdaQuery() + .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .count(); + + String payload = "{" + + "\"sex\":\"2\"," + + "\"levelId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID + "\"," + + "\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," + + "\"commodityQuantity\":1," + + "\"weiChatCode\":\"apitest-customer-wx\"," + + "\"excludeHistory\":\"0\"," + + "\"couponIds\":[]," + + "\"remark\":\"" + remark + "\"" + + "}"; + + mockMvc.perform(post("/wx/custom/order/random") + .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").value("下单成功")); + + ensureTenantContext(); + PlayOrderInfoEntity order = playOrderInfoService.lambdaQuery() + .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .eq(PlayOrderInfoEntity::getRemark, remark) + .orderByDesc(PlayOrderInfoEntity::getCreatedTime) + .last("limit 1") + .one(); + + Assertions.assertThat(order).isNotNull(); + Assertions.assertThat(order.getPlaceType()).isEqualTo(OrderConstant.PlaceType.RANDOM.getCode()); + + ensureTenantContext(); + long afterCount = playOrderInfoService.lambdaQuery() + .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .count(); + Assertions.assertThat(afterCount).isEqualTo(beforeCount + 1); + + verify(wxCustomMpService).sendCreateOrderMessageBatch( + anyList(), + eq(order.getOrderNo()), + eq(order.getFinalAmount().toString()), + eq(order.getCommodityName()), + eq(order.getId())); + verify(overdueOrderHandlerTask).enqueue(order.getId() + "_" + ApiTestDataSeeder.DEFAULT_TENANT_ID); + + reset(wxCustomMpService); + reset(overdueOrderHandlerTask); + + return order.getId(); + } + +} diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCustomRewardOrderApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCustomRewardOrderApiTest.java new file mode 100644 index 0000000..d4e91be --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/WxCustomRewardOrderApiTest.java @@ -0,0 +1,81 @@ +package com.starry.admin.api; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.starry.admin.common.apitest.ApiTestDataSeeder; +import com.starry.admin.modules.order.module.constant.OrderConstant; +import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; +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 org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +class WxCustomRewardOrderApiTest extends WxCustomOrderApiTestSupport { + + @Test + // 测试用例:客户指定打赏金额下单时,应即时扣减账户余额、生成已完成的打赏订单并同步收益记录, + // 同时校验订单归属陪玩师正确且金额与输入一致,确保余额打赏流程闭环。 + void rewardOrderConsumesBalanceAndGeneratesEarnings() throws Exception { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + try { + resetCustomerBalance(); + String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); + customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken); + BigDecimal initialBalance = customUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID).getAccountBalance(); + + String remark = "API reward order " + IdUtils.getUuid(); + BigDecimal rewardAmount = new BigDecimal("18.75"); + + String payload = "{" + + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + + "\"money\":\"" + rewardAmount + "\"," + + "\"weiChatCode\":\"apitest-customer-wx\"," + + "\"remark\":\"" + remark + "\"" + + "}"; + + mockMvc.perform(post("/wx/custom/order/reward") + .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").value("成功")); + + ensureTenantContext(); + PlayOrderInfoEntity order = playOrderInfoService.lambdaQuery() + .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .eq(PlayOrderInfoEntity::getRemark, remark) + .orderByDesc(PlayOrderInfoEntity::getCreatedTime) + .last("limit 1") + .one(); + + Assertions.assertThat(order).isNotNull(); + Assertions.assertThat(order.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.COMPLETED.getCode()); + Assertions.assertThat(order.getPlaceType()).isEqualTo(OrderConstant.PlaceType.REWARD.getCode()); + Assertions.assertThat(order.getRewardType()).isEqualTo(OrderConstant.RewardType.BALANCE.getCode()); + Assertions.assertThat(order.getAcceptBy()).isEqualTo(ApiTestDataSeeder.DEFAULT_CLERK_ID); + Assertions.assertThat(order.getFinalAmount()).isEqualByComparingTo(rewardAmount); + + ensureTenantContext(); + long earningsCount = earningsService.lambdaQuery() + .eq(EarningsLineEntity::getOrderId, order.getId()) + .count(); + Assertions.assertThat(earningsCount).isEqualTo(1); + + BigDecimal expectedBalance = initialBalance.subtract(rewardAmount); + Assertions.assertThat(customUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID).getAccountBalance()) + .isEqualByComparingTo(expectedBalance); + } finally { + CustomSecurityContextHolder.remove(); + } + } +} diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCustomSpecifiedOrderApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCustomSpecifiedOrderApiTest.java new file mode 100644 index 0000000..4159c05 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/WxCustomSpecifiedOrderApiTest.java @@ -0,0 +1,354 @@ +package com.starry.admin.api; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +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.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.starry.admin.common.apitest.ApiTestDataSeeder; +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.CouponUseState; +import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity; +import com.starry.admin.modules.shop.module.vo.PlayCouponDetailsReturnVo; +import com.starry.admin.modules.weichat.service.WxCustomMpService; +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.math.RoundingMode; +import java.util.Collections; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; + +class WxCustomSpecifiedOrderApiTest extends WxCustomOrderApiTestSupport { + + @MockBean + private WxCustomMpService wxCustomMpService; + + @org.springframework.beans.factory.annotation.Autowired + private com.starry.admin.modules.order.service.IPlayOrderInfoService orderInfoService; + + @org.springframework.beans.factory.annotation.Autowired + private com.starry.admin.modules.weichat.service.WxTokenService clerkWxTokenService; + + @Test + // 测试用例:指定单取消后优惠券应恢复为未使用 + void specifiedOrderCancellationReleasesCoupon() throws Exception { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + String remark = "API specified cancel " + IdUtils.getUuid(); + BigDecimal discount = new BigDecimal("10.00"); + try { + resetCustomerBalance(); + String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); + customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken); + + String couponInfoId = createCouponId("cpn-sc-"); + ensureFixedReductionCoupon( + couponInfoId, + OrderConstant.PlaceType.SPECIFIED, + discount, + new BigDecimal("60.00"), + "0"); + String couponDetailId = claimCouponForOrder(couponInfoId, OrderConstant.PlaceType.SPECIFIED, customerToken); + + String payload = "{" + + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + + "\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," + + "\"commodityQuantity\":1," + + "\"weiChatCode\":\"apitest-customer-wx\"," + + "\"couponIds\":[\"" + couponDetailId + "\"]," + + "\"remark\":\"" + remark + "\"" + + "}"; + + mockMvc.perform(post("/wx/custom/order/commodity") + .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)); + + ensureTenantContext(); + PlayOrderInfoEntity order = playOrderInfoService.lambdaQuery() + .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .eq(PlayOrderInfoEntity::getRemark, remark) + .orderByDesc(PlayOrderInfoEntity::getCreatedTime) + .last("limit 1") + .one(); + Assertions.assertThat(order).isNotNull(); + + String cancelPayload = "{" + + "\"orderId\":\"" + order.getId() + "\"," + + "\"refundReason\":\"测试取消\"," + + "\"images\":[]" + + "}"; + mockMvc.perform(post("/wx/custom/order/cancellation") + .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(cancelPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").value("取消成功")); + + ensureTenantContext(); + PlayOrderInfoEntity cancelled = playOrderInfoService.selectOrderInfoById(order.getId()); + Assertions.assertThat(cancelled.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.CANCELLED.getCode()); + + PlayCouponDetailsEntity detail = couponDetailsService.getById(couponDetailId); + Assertions.assertThat(detail.getUseState()) + .as("取消指定单后优惠券应恢复为未使用") + .isEqualTo(CouponUseState.UNUSED.getCode()); + } finally { + CustomSecurityContextHolder.remove(); + } + } + + @Test + // 测试用例:指定单使用满减券下单后,应按折后金额推送创建通知, + // 并将预计收益扣除优惠金额,同时把优惠券详情标记为已使用。 + void specifiedOrderWithCouponAdjustsAmountAndRevenue() throws Exception { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + String remark = "API specified coupon " + IdUtils.getUuid(); + BigDecimal discount = new BigDecimal("15.00"); + try { + reset(wxCustomMpService); + resetCustomerBalance(); + + String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); + customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken); + + BigDecimal grossAmount = ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE; + String couponInfoId = createCouponId("cpn-s-"); + ensureFixedReductionCoupon( + couponInfoId, + OrderConstant.PlaceType.SPECIFIED, + discount, + new BigDecimal("60.00"), + "0"); + String couponDetailId = claimCouponForOrder(couponInfoId, OrderConstant.PlaceType.SPECIFIED, customerToken); + + String payload = "{" + + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + + "\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," + + "\"commodityQuantity\":1," + + "\"weiChatCode\":\"apitest-customer-wx\"," + + "\"couponIds\":[\"" + couponDetailId + "\"]," + + "\"remark\":\"" + remark + "\"" + + "}"; + + mockMvc.perform(post("/wx/custom/order/commodity") + .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").value("成功")); + + ensureTenantContext(); + PlayOrderInfoEntity order = playOrderInfoService.lambdaQuery() + .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .eq(PlayOrderInfoEntity::getRemark, remark) + .orderByDesc(PlayOrderInfoEntity::getCreatedTime) + .last("limit 1") + .one(); + + Assertions.assertThat(order).isNotNull(); + Assertions.assertThat(order.getCouponIds()).contains(couponDetailId); + + BigDecimal expectedNet = grossAmount.subtract(discount).setScale(2, RoundingMode.HALF_UP); + Assertions.assertThat(order.getFinalAmount()).isEqualByComparingTo(expectedNet); + Assertions.assertThat(order.getDiscountAmount()).isEqualByComparingTo(discount); + + verify(wxCustomMpService).sendCreateOrderMessage( + eq(ApiTestDataSeeder.DEFAULT_TENANT_ID), + eq(ApiTestDataSeeder.DEFAULT_CLERK_OPEN_ID), + anyString(), + eq(expectedNet.toString()), + eq(order.getCommodityName()), + eq(order.getId())); + + int ratio = order.getEstimatedRevenueRatio(); + BigDecimal baseRevenue = grossAmount + .multiply(BigDecimal.valueOf(ratio)) + .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + PlayCouponDetailsReturnVo detail = couponDetailsService.selectPlayCouponDetailsById(couponDetailId); + BigDecimal clerkDiscount = BigDecimal.ZERO; + if (detail != null && "0".equals(detail.getAttributionDiscounts())) { + BigDecimal discountAmount = detail.getDiscountAmount() == null ? BigDecimal.ZERO : detail.getDiscountAmount(); + clerkDiscount = discountAmount; + } + BigDecimal expectedRevenue = baseRevenue.subtract(clerkDiscount).max(BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP); + Assertions.assertThat(order.getEstimatedRevenue()).isEqualByComparingTo(expectedRevenue); + + assertCouponUsed(couponDetailId); + } finally { + CustomSecurityContextHolder.remove(); + } + } + + @Test + // 测试用例:指定单在接单后由管理员强制取消时,优惠券应恢复为未使用 + void specifiedOrderForceCancelReleasesCoupon() throws Exception { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + String remark = "API specified force cancel " + IdUtils.getUuid(); + BigDecimal discount = new BigDecimal("8.00"); + try { + reset(wxCustomMpService); + resetCustomerBalance(); + + String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); + customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken); + String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID); + clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken); + + String couponInfoId = createCouponId("cpn-sf-"); + ensureFixedReductionCoupon( + couponInfoId, + OrderConstant.PlaceType.SPECIFIED, + discount, + new BigDecimal("60.00"), + "0"); + String couponDetailId = claimCouponForOrder(couponInfoId, OrderConstant.PlaceType.SPECIFIED, customerToken); + + String payload = "{" + + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + + "\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," + + "\"commodityQuantity\":1," + + "\"weiChatCode\":\"apitest-customer-wx\"," + + "\"couponIds\":[\"" + couponDetailId + "\"]," + + "\"remark\":\"" + remark + "\"" + + "}"; + + mockMvc.perform(post("/wx/custom/order/commodity") + .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)); + + ensureTenantContext(); + PlayOrderInfoEntity order = playOrderInfoService.lambdaQuery() + .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .eq(PlayOrderInfoEntity::getRemark, remark) + .orderByDesc(PlayOrderInfoEntity::getCreatedTime) + .last("limit 1") + .one(); + Assertions.assertThat(order).isNotNull(); + + mockMvc.perform(get("/wx/clerk/order/accept") + .param("id", order.getId()) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").value("成功")); + + ensureTenantContext(); + orderInfoService.forceCancelOngoingOrder( + "2", + ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID, + order.getId(), + order.getFinalAmount(), + "管理员强制取消", + java.util.Collections.emptyList()); + + ensureTenantContext(); + PlayOrderInfoEntity cancelled = playOrderInfoService.selectOrderInfoById(order.getId()); + Assertions.assertThat(cancelled.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.CANCELLED.getCode()); + + PlayCouponDetailsEntity detail = couponDetailsService.getById(couponDetailId); + Assertions.assertThat(detail.getUseState()) + .as("强制取消指定单后优惠券应恢复为未使用") + .isEqualTo(CouponUseState.UNUSED.getCode()); + } finally { + CustomSecurityContextHolder.remove(); + } + } + + @Test + // 测试用例:客户携带指定陪玩师和服务下单时,接口需返回成功,并生成待支付状态的指定订单, + // 验证订单金额与种子服务价格一致、陪玩师被正确指派,同时触发微信创建订单通知。 + void specifiedOrderCreatesPendingOrder() throws Exception { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + try { + resetCustomerBalance(); + String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); + customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken); + + String remark = "API specified order " + IdUtils.getUuid(); + ensureTenantContext(); + long beforeCount = playOrderInfoService.lambdaQuery() + .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .eq(PlayOrderInfoEntity::getPlaceType, OrderConstant.PlaceType.SPECIFIED.getCode()) + .count(); + + String payload = "{" + + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + + "\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," + + "\"commodityQuantity\":1," + + "\"weiChatCode\":\"apitest-customer-wx\"," + + "\"couponIds\":[]," + + "\"remark\":\"" + remark + "\"" + + "}"; + + reset(wxCustomMpService); + + mockMvc.perform(post("/wx/custom/order/commodity") + .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").value("成功")); + + ensureTenantContext(); + PlayOrderInfoEntity order = playOrderInfoService.lambdaQuery() + .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .eq(PlayOrderInfoEntity::getRemark, remark) + .orderByDesc(PlayOrderInfoEntity::getCreatedTime) + .last("limit 1") + .one(); + + Assertions.assertThat(order).isNotNull(); + Assertions.assertThat(order.getPlaceType()).isEqualTo(OrderConstant.PlaceType.SPECIFIED.getCode()); + Assertions.assertThat(order.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.PENDING.getCode()); + Assertions.assertThat(order.getAcceptBy()).isEqualTo(ApiTestDataSeeder.DEFAULT_CLERK_ID); + Assertions.assertThat(order.getFinalAmount()).isEqualByComparingTo(ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE); + + verify(wxCustomMpService).sendCreateOrderMessage( + eq(ApiTestDataSeeder.DEFAULT_TENANT_ID), + eq(ApiTestDataSeeder.DEFAULT_CLERK_OPEN_ID), + anyString(), + eq(order.getFinalAmount().toString()), + eq(order.getCommodityName()), + eq(order.getId())); + + ensureTenantContext(); + long afterCount = playOrderInfoService.lambdaQuery() + .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .eq(PlayOrderInfoEntity::getPlaceType, OrderConstant.PlaceType.SPECIFIED.getCode()) + .count(); + Assertions.assertThat(afterCount).isEqualTo(beforeCount + 1); + } finally { + CustomSecurityContextHolder.remove(); + } + } +}