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)); ensureTenantContext(); 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)); ensureTenantContext(); 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); } }