diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCouponControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCouponControllerApiTest.java new file mode 100644 index 0000000..ca89a8d --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/WxCouponControllerApiTest.java @@ -0,0 +1,347 @@ +package com.starry.admin.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +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.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.starry.admin.common.apitest.ApiTestDataSeeder; +import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; +import com.starry.admin.modules.order.module.constant.OrderConstant; +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.CouponObtainChannel; +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.utils.SecurityUtils; +import com.starry.common.constant.Constants; +import com.starry.common.context.CustomSecurityContextHolder; +import com.starry.common.utils.IdUtils; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; + +class WxCouponControllerApiTest extends AbstractApiTest { + + private static final String COUPON_ID_PREFIX = "apitest-cpn-"; + private static final BigDecimal DEFAULT_DISCOUNT_AMOUNT = new BigDecimal("5"); + private static final BigDecimal MINIMUM_USAGE_AMOUNT = new BigDecimal("60"); + private static final int DEFAULT_MAX_PER_USER = 3; + private static final int DEFAULT_STOCK = 100; + private static final String DISCOUNT_BEARER_SHOP = "0"; + private static final String GLOBAL_CLERK_SCOPE = "0"; + private static final String CHECK_CONDITION_DISABLED = "0"; + private static final String NEW_USER_FLAG_DISABLED = "0"; + + @Autowired + private IPlayCouponInfoService couponInfoService; + + @Autowired + private IPlayCouponDetailsService couponDetailsService; + + @Autowired + private WxTokenService wxTokenService; + + @Autowired + private IPlayCustomUserInfoService customUserInfoService; + + private final ObjectMapper mapper = new ObjectMapper(); + private final List couponIds = new ArrayList<>(); + private final List couponDetailIds = new ArrayList<>(); + private String customerToken; + + @BeforeEach + void setUp() { + ensureTenantContext(); + couponInfoService.lambdaUpdate() + .eq(PlayCouponInfoEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID) + .like(PlayCouponInfoEntity::getId, COUPON_ID_PREFIX) + .remove(); + couponDetailsService.lambdaUpdate() + .eq(PlayCouponDetailsEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID) + .like(PlayCouponDetailsEntity::getCouponId, COUPON_ID_PREFIX) + .remove(); + customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); + customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken); + } + + @AfterEach + void tearDown() { + ensureTenantContext(); + if (!couponDetailIds.isEmpty()) { + couponDetailsService.removeByIds(couponDetailIds); + couponDetailIds.clear(); + } + if (!couponIds.isEmpty()) { + couponInfoService.removeByIds(couponIds); + couponIds.clear(); + } + CustomSecurityContextHolder.remove(); + } + + @Test + void obtainCouponRejectsNonWhitelistCustomer() throws Exception { + ensureTenantContext(); + String couponId = newCouponId("whitelist"); + PlayCouponInfoEntity coupon = createBaseCoupon(couponId); + coupon.setClaimConditionType(CouponClaimConditionType.WHITELIST.code()); + coupon.setCustomWhitelist(List.of("other-customer")); + couponInfoService.save(coupon); + couponIds.add(coupon.getId()); + + mockMvc.perform(get("/wx/coupon/custom/obtainCoupon") + .param("id", coupon.getId()) + .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.success").value(false)) + .andExpect(jsonPath("$.data.msg").value("非指定用户")); + } + + @Test + void queryAllSkipsOfflineCouponsAndMarksObtainedOnes() throws Exception { + ensureTenantContext(); + PlayCouponInfoEntity onlineCoupon = createBaseCoupon(newCouponId("online")); + onlineCoupon.setCustomWhitelist(List.of(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)); + couponInfoService.save(onlineCoupon); + couponIds.add(onlineCoupon.getId()); + + PlayCouponDetailsEntity obtained = createCouponDetail(onlineCoupon.getId(), CouponUseState.UNUSED); + couponDetailsService.save(obtained); + couponDetailIds.add(obtained.getId()); + + PlayCouponInfoEntity offlineCoupon = createBaseCoupon(newCouponId("offline")); + offlineCoupon.setCouponOnLineState(CouponOnlineState.OFFLINE.getCode()); + couponInfoService.save(offlineCoupon); + couponIds.add(offlineCoupon.getId()); + + MvcResult result = mockMvc.perform(post("/wx/coupon/custom/queryAll") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andReturn(); + + JsonNode data = mapper.readTree(result.getResponse().getContentAsString()).path("data"); + assertThat(data).isNotNull(); + assertThat(data.isArray()).isTrue(); + assertThat(data).hasSizeGreaterThanOrEqualTo(1); + boolean containsOnline = false; + for (JsonNode node : data) { + assertThat(node.path("id").asText()).isNotEqualTo(offlineCoupon.getId()); + if (onlineCoupon.getId().equals(node.path("id").asText())) { + containsOnline = true; + assertThat(node.path("obtained").asText()).isEqualTo("1"); + } + } + assertThat(containsOnline).isTrue(); + } + + @Test + void queryByOrderFlagsOnlyEligibleCouponsAsAvailable() throws Exception { + ensureTenantContext(); + PlayCouponInfoEntity eligible = createBaseCoupon(newCouponId("eligible")); + eligible.setUseMinAmount(MINIMUM_USAGE_AMOUNT); + couponInfoService.save(eligible); + couponIds.add(eligible.getId()); + + PlayCouponInfoEntity ineligible = createBaseCoupon(newCouponId("ineligible")); + ineligible.setPlaceType(List.of(OrderConstant.PlaceType.SPECIFIED.getCode())); + couponInfoService.save(ineligible); + couponIds.add(ineligible.getId()); + + PlayCouponDetailsEntity eligibleDetail = createCouponDetail(eligible.getId(), CouponUseState.UNUSED); + PlayCouponDetailsEntity ineligibleDetail = createCouponDetail(ineligible.getId(), CouponUseState.UNUSED); + couponDetailsService.save(eligibleDetail); + couponDetailsService.save(ineligibleDetail); + couponDetailIds.add(eligibleDetail.getId()); + couponDetailIds.add(ineligibleDetail.getId()); + + ObjectNode payload = mapper.createObjectNode(); + payload.put("commodityId", ApiTestDataSeeder.DEFAULT_COMMODITY_ID); + payload.put("levelId", ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID); + payload.put("clerkId", ""); + payload.put("placeType", OrderConstant.PlaceType.RANDOM.getCode()); + payload.put("commodityQuantity", 1); + + MvcResult result = mockMvc.perform(post("/wx/coupon/custom/queryByOrder") + .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(200)) + .andReturn(); + + ArrayNode data = (ArrayNode) mapper.readTree(result.getResponse().getContentAsString()).path("data"); + assertThat(data).isNotNull(); + assertThat(data.size()).isGreaterThanOrEqualTo(2); + for (JsonNode node : data) { + if (eligible.getId().equals(node.path("couponId").asText())) { + assertThat(node.path("available").asText()).isEqualTo("1"); + assertThat(node.path("reasonForUnavailableUse").asText("")).isEmpty(); + } + if (ineligible.getId().equals(node.path("couponId").asText())) { + assertThat(node.path("available").asText()).isEqualTo("0"); + assertThat(node.path("reasonForUnavailableUse").asText()).isEqualTo("订单类型不符合"); + } + } + } + + @Test + void obtainCouponSucceedsWhenEligible() throws Exception { + ensureTenantContext(); + PlayCouponInfoEntity coupon = createBaseCoupon(newCouponId("success")); + couponInfoService.save(coupon); + couponIds.add(coupon.getId()); + + mockMvc.perform(get("/wx/coupon/custom/obtainCoupon") + .param("id", coupon.getId()) + .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.success").value(true)) + .andExpect(jsonPath("$.data.msg").value(anyOf(is(""), nullValue()))); + + ensureTenantContext(); + PlayCouponDetailsEntity detail = couponDetailsService.lambdaQuery() + .eq(PlayCouponDetailsEntity::getCouponId, coupon.getId()) + .eq(PlayCouponDetailsEntity::getCustomId, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .orderByDesc(PlayCouponDetailsEntity::getObtainingTime) + .last("limit 1") + .one(); + assertThat(detail).isNotNull(); + couponDetailIds.add(detail.getId()); + } + + @Test + void obtainCouponHonorsPerUserLimit() throws Exception { + ensureTenantContext(); + PlayCouponInfoEntity coupon = createBaseCoupon(newCouponId("limit")); + coupon.setClerkObtainedMaxQuantity(1); + couponInfoService.save(coupon); + couponIds.add(coupon.getId()); + + mockMvc.perform(get("/wx/coupon/custom/obtainCoupon") + .param("id", coupon.getId()) + .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.success").value(true)); + + ensureTenantContext(); + PlayCouponDetailsEntity detail = couponDetailsService.lambdaQuery() + .eq(PlayCouponDetailsEntity::getCouponId, coupon.getId()) + .eq(PlayCouponDetailsEntity::getCustomId, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .one(); + assertThat(detail).isNotNull(); + couponDetailIds.add(detail.getId()); + + mockMvc.perform(get("/wx/coupon/custom/obtainCoupon") + .param("id", coupon.getId()) + .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.success").value(false)) + .andExpect(jsonPath("$.data.msg").value("优惠券已达到领取上限")); + } + + private PlayCouponInfoEntity createBaseCoupon(String id) { + PlayCouponInfoEntity coupon = new PlayCouponInfoEntity(); + coupon.setId(id); + coupon.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + coupon.setCouponName("测试券-" + id); + coupon.setValidityPeriodType(CouponValidityPeriodType.PERMANENT.getCode()); + coupon.setUseMinAmount(BigDecimal.ZERO); + coupon.setDiscountType(CouponDiscountType.FULL_REDUCTION.getCode()); + coupon.setDiscountContent("测试优惠"); + coupon.setDiscountAmount(DEFAULT_DISCOUNT_AMOUNT); + coupon.setAttributionDiscounts(DISCOUNT_BEARER_SHOP); + coupon.setPlaceType(List.of( + OrderConstant.PlaceType.RANDOM.getCode(), + OrderConstant.PlaceType.SPECIFIED.getCode())); + coupon.setClerkType(GLOBAL_CLERK_SCOPE); + coupon.setCouponQuantity(DEFAULT_STOCK); + coupon.setIssuedQuantity(0); + coupon.setRemainingQuantity(DEFAULT_STOCK); + coupon.setClerkObtainedMaxQuantity(DEFAULT_MAX_PER_USER); + coupon.setClaimConditionType(CouponClaimConditionType.ALL.code()); + coupon.setCustomWhitelist(new ArrayList<>()); + coupon.setCustomLevelCheckType(CHECK_CONDITION_DISABLED); + coupon.setCustomSexCheckType(CHECK_CONDITION_DISABLED); + coupon.setNewUser(NEW_USER_FLAG_DISABLED); + coupon.setCouponOnLineState(CouponOnlineState.ONLINE.getCode()); + LocalDateTime now = LocalDateTime.now(); + coupon.setProductiveTime(now.minusDays(1)); + coupon.setExpirationTime(now.plusDays(30)); + coupon.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + coupon.setCreatedTime(toDate(now)); + coupon.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + coupon.setUpdatedTime(toDate(now)); + return coupon; + } + + private PlayCouponDetailsEntity createCouponDetail(String couponId, CouponUseState status) { + PlayCouponDetailsEntity entity = new PlayCouponDetailsEntity(); + entity.setId("detail-" + IdUtils.getUuid()); + entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + entity.setCustomId(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); + entity.setCouponId(couponId); + entity.setCustomNickname("APITester"); + entity.setCustomLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID); + entity.setObtainingChannels(CouponObtainChannel.SELF_SERVICE.getCode()); + entity.setUseState(status.getCode()); + LocalDateTime now = LocalDateTime.now(); + entity.setObtainingTime(now); + entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + entity.setCreatedTime(toDate(now)); + entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + entity.setUpdatedTime(toDate(now)); + return entity; + } + + private void ensureTenantContext() { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + } + + private String newCouponId(String suffix) { + return COUPON_ID_PREFIX + suffix + "-" + IdUtils.getUuid(); + } + + private java.util.Date toDate(LocalDateTime value) { + return java.util.Date.from(value.atZone(ZoneId.systemDefault()).toInstant()); + } +} diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderEvaluationApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderEvaluationApiTest.java new file mode 100644 index 0000000..07b056e --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderEvaluationApiTest.java @@ -0,0 +1,136 @@ +package com.starry.admin.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.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.order.module.constant.OrderConstant; +import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import com.starry.admin.modules.order.service.IPlayOrderEvaluateInfoService; +import com.starry.admin.modules.order.service.IPlayOrderInfoService; +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.time.LocalDateTime; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; + +class WxCustomOrderEvaluationApiTest extends WxCustomOrderApiTestSupport { + + private static final String EVALUATION_MESSAGE = "评价成功"; + private String customerToken; + + @Autowired + private IPlayOrderEvaluateInfoService orderEvaluateInfoService; + + @Autowired + private IPlayOrderInfoService orderInfoService; + + @BeforeEach + void setUp() { + ensureTenantContext(); + resetCustomerBalance(); + customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); + customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken); + } + + @AfterEach + void tearDown() { + CustomSecurityContextHolder.remove(); + } + + @Test + void customerCanEvaluateCompletedOrderAndRetrieveResult() throws Exception { + ensureTenantContext(); + String remark = "evaluate-" + LocalDateTime.now(); + String orderId = createRandomOrder(remark); + + ensureTenantContext(); + orderInfoService.lambdaUpdate() + .eq(PlayOrderInfoEntity::getId, orderId) + .set(PlayOrderInfoEntity::getAcceptBy, ApiTestDataSeeder.DEFAULT_CLERK_ID) + .set(PlayOrderInfoEntity::getOrderStatus, OrderConstant.OrderStatus.COMPLETED.getCode()) + .update(); + + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("orderId", orderId); + payload.put("anonymous", "1"); + payload.put("evaluateLevel", 5); + String evaluationSuffix = IdUtils.getUuid(); + String evaluationText = "API评价-" + evaluationSuffix; + payload.put("evaluateCon", evaluationText); + + mockMvc.perform(post("/wx/custom/order/evaluate/add") + .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(200)) + .andExpect(jsonPath("$.data").value(EVALUATION_MESSAGE)); + + ensureTenantContext(); + assertThat(orderEvaluateInfoService.queryCustomToOrderEvaluateInfo( + ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, orderId)) + .isNotNull(); + + MvcResult result = mockMvc.perform(get("/wx/custom/order/evaluate/queryByOrderId") + .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)) + .andReturn(); + + JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).path("data"); + assertThat(data.path("id").asText()).isNotBlank(); + assertThat(data.path("evaluateLevel").asInt()).isEqualTo(5); + assertThat(data.path("anonymous").asText()).isEqualTo("1"); + assertThat(data.path("evaluateCon").asText()).endsWith(evaluationSuffix); + } + + private String createRandomOrder(String remark) throws Exception { + ensureTenantContext(); + + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("sex", OrderConstant.Gender.FEMALE.getCode()); + payload.put("levelId", ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID); + payload.put("commodityId", ApiTestDataSeeder.DEFAULT_COMMODITY_ID); + payload.put("commodityQuantity", 1); + payload.put("weiChatCode", "apitest-customer-wx"); + payload.put("excludeHistory", "0"); + payload.set("couponIds", objectMapper.createArrayNode()); + payload.put("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.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + ensureTenantContext(); + PlayOrderInfoEntity order = orderInfoService.lambdaQuery() + .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + .eq(PlayOrderInfoEntity::getRemark, remark) + .orderByDesc(PlayOrderInfoEntity::getCreatedTime) + .last("limit 1") + .one(); + assertThat(order).isNotNull(); + return order.getId(); + } +} diff --git a/play-admin/src/test/java/com/starry/admin/api/WxOrderInfoControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxOrderInfoControllerApiTest.java new file mode 100644 index 0000000..8d96e8a --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/WxOrderInfoControllerApiTest.java @@ -0,0 +1,220 @@ +package com.starry.admin.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.reset; +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.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.starry.admin.common.apitest.ApiTestDataSeeder; +import com.starry.admin.common.task.OverdueOrderHandlerTask; +import com.starry.admin.modules.order.module.constant.OrderConstant; +import com.starry.admin.modules.order.module.entity.PlayOrderContinueInfoEntity; +import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import com.starry.admin.modules.order.service.IPlayOrderContinueInfoService; +import com.starry.admin.modules.weichat.service.NotificationSender; +import com.starry.admin.modules.weichat.service.WxCustomMpService; +import com.starry.common.constant.Constants; +import com.starry.common.context.CustomSecurityContextHolder; +import java.time.LocalDateTime; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; + +class WxOrderInfoControllerApiTest extends WxCustomOrderApiTestSupport { + + private static final String MESSAGE_OPERATION_SUCCESS = "操作成功"; + private static final String REVIEW_PENDING_STATE = "0"; + private static final String EXCLUDE_HISTORY_DISABLED = "0"; + private static final String OTHER_CLERK_ID = "clerk-apitest-other"; + private static final int SINGLE_QUANTITY = 1; + + @MockBean + private NotificationSender notificationSender; + + @MockBean + private WxCustomMpService wxCustomMpService; + + @MockBean + private OverdueOrderHandlerTask overdueOrderHandlerTask; + + @Autowired + private IPlayOrderContinueInfoService orderContinueInfoService; + + private final ObjectMapper mapper = new ObjectMapper(); + private String customerToken; + private String clerkToken; + + @BeforeEach + void setUpTokens() { + ensureTenantContext(); + customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); + customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken); + clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID); + clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken); + // Relax notifications to avoid strict verification noise + doNothing().when(notificationSender).sendOrderMessageAsync(Mockito.any()); + doNothing().when(notificationSender).sendOrderFinishMessageAsync(Mockito.any()); + doNothing().when(wxCustomMpService).sendCreateOrderMessageBatch(anyList(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString()); + doNothing().when(overdueOrderHandlerTask).enqueue(Mockito.anyString()); + } + + @AfterEach + void tearDown() { + ensureTenantContext(); + orderContinueInfoService.lambdaUpdate() + .eq(PlayOrderContinueInfoEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID) + .remove(); + reset(notificationSender, wxCustomMpService, overdueOrderHandlerTask); + CustomSecurityContextHolder.remove(); + } + + @Test + void selectRandomOrderByIdHidesCustomerContactWhenPending() throws Exception { + String marker = "privacy-" + LocalDateTime.now().toString(); + String orderId = createRandomOrder(marker); + + MvcResult result = mockMvc.perform(get("/wx/order/clerk/selectRandomOrderById") + .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)) + .andReturn(); + + JsonNode data = mapper.readTree(result.getResponse().getContentAsString()).path("data"); + assertThat(data.path("weiChatCode").asText()).isEmpty(); + assertThat(data.path("placeType").asText()).isEqualTo(OrderConstant.PlaceType.RANDOM.getCode()); + assertThat(data.path("orderStatus").asText()).isEqualTo(OrderConstant.OrderStatus.PENDING.getCode()); + } + + @Test + void duplicateContinuationRequestIsRejected() throws Exception { + String marker = "continue-" + LocalDateTime.now().toString(); + String orderId = createRandomOrder(marker); + + acceptOrder(orderId); + + ArrayNode images = mapper.createArrayNode().add("https://example.com/proof.png"); + ObjectNode payload = mapper.createObjectNode() + .put("orderId", orderId) + .put("remark", "加场申请") + .set("images", images); + + mockMvc.perform(post("/wx/order/clerk/continue") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken) + .contentType(MediaType.APPLICATION_JSON) + .content(payload.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").value("下单成功")) + .andExpect(jsonPath("$.message").value(MESSAGE_OPERATION_SUCCESS)); + + mockMvc.perform(post("/wx/order/clerk/continue") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken) + .contentType(MediaType.APPLICATION_JSON) + .content(payload.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(500)) + .andExpect(jsonPath("$.message").value("同一场订单只能续单一次")); + + ensureTenantContext(); + PlayOrderContinueInfoEntity continuation = orderContinueInfoService.lambdaQuery() + .eq(PlayOrderContinueInfoEntity::getOrderId, orderId) + .last("limit 1") + .one(); + assertThat(continuation).isNotNull(); + assertThat(continuation.getReviewedState()).isEqualTo(REVIEW_PENDING_STATE); + } + + @Test + void randomOrderAcceptedByAnotherClerkHidesSensitiveFields() throws Exception { + String marker = "privacy-accepted-" + LocalDateTime.now(); + String orderId = createRandomOrder(marker); + + ensureTenantContext(); + playOrderInfoService.lambdaUpdate() + .eq(PlayOrderInfoEntity::getId, orderId) + .set(PlayOrderInfoEntity::getAcceptBy, OTHER_CLERK_ID) + .set(PlayOrderInfoEntity::getOrderStatus, OrderConstant.OrderStatus.ACCEPTED.getCode()) + .update(); + + MvcResult result = mockMvc.perform(get("/wx/order/clerk/selectRandomOrderById") + .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)) + .andReturn(); + + JsonNode data = mapper.readTree(result.getResponse().getContentAsString()).path("data"); + assertThat(data.path("acceptBy").asText()).isEqualTo(OTHER_CLERK_ID); + assertThat(data.path("weiChatCode").asText()).isEmpty(); + assertThat(data.path("customId").asText()).isEmpty(); + String nickname = data.path("customNickname").asText(); + assertThat(nickname.equals("匿名用户") || "匿名用户".equals(nickname)).isTrue(); + assertThat(data.path("customAvatar").asText()).isEmpty(); + } + + private String createRandomOrder(String remark) throws Exception { + ensureTenantContext(); + resetCustomerBalance(); + + ObjectNode payload = mapper.createObjectNode(); + payload.put("sex", OrderConstant.Gender.FEMALE.getCode()); + payload.put("levelId", ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID); + payload.put("commodityId", ApiTestDataSeeder.DEFAULT_COMMODITY_ID); + payload.put("commodityQuantity", SINGLE_QUANTITY); + payload.put("weiChatCode", "apitest-customer-wx"); + payload.put("excludeHistory", EXCLUDE_HISTORY_DISABLED); + payload.set("couponIds", mapper.createArrayNode()); + payload.put("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.toString())) + .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(); + assertThat(order).isNotNull(); + return order.getId(); + } + + private void acceptOrder(String orderId) throws Exception { + 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)); + } +} diff --git a/play-admin/src/test/java/com/starry/admin/api/WxWithdrawControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxWithdrawControllerApiTest.java new file mode 100644 index 0000000..d0f0246 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/WxWithdrawControllerApiTest.java @@ -0,0 +1,296 @@ +package com.starry.admin.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.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.clerk.service.IPlayClerkUserInfoService; +import com.starry.admin.modules.weichat.service.WxTokenService; +import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity; +import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; +import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity; +import com.starry.admin.modules.withdraw.enums.EarningsType; +import com.starry.admin.modules.withdraw.service.IClerkPayeeProfileService; +import com.starry.admin.modules.withdraw.service.IEarningsService; +import com.starry.admin.modules.withdraw.service.IWithdrawalService; +import com.starry.admin.utils.SecurityUtils; +import com.starry.common.constant.Constants; +import com.starry.common.context.CustomSecurityContextHolder; +import com.starry.common.utils.IdUtils; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; + +class WxWithdrawControllerApiTest extends AbstractApiTest { + + private static final DateTimeFormatter DATE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final String ORDER_ID_PREFIX = "earn-order-"; + + @Autowired + private IEarningsService earningsService; + + @Autowired + private IWithdrawalService withdrawalService; + + @MockBean + private IClerkPayeeProfileService clerkPayeeProfileService; + + @Autowired + private IPlayClerkUserInfoService clerkUserInfoService; + + @Autowired + private WxTokenService wxTokenService; + + private final List earningsToCleanup = new ArrayList<>(); + private final List withdrawalsToCleanup = new ArrayList<>(); + private String clerkToken; + private ClerkPayeeProfileEntity payeeProfile; + + @BeforeEach + void setUp() { + ensureTenantContext(); + // reset seeded data to a clean state for deterministic assertions + earningsService.lambdaUpdate() + .eq(EarningsLineEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID) + .remove(); + withdrawalService.lambdaUpdate() + .eq(WithdrawalRequestEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID) + .remove(); + LocalDateTime now = LocalDateTime.now(); + payeeProfile = new ClerkPayeeProfileEntity(); + payeeProfile.setId("payee-" + IdUtils.getUuid()); + payeeProfile.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + payeeProfile.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID); + payeeProfile.setChannel("ALIPAY_QR"); + payeeProfile.setQrCodeUrl("https://example.com/test-payee.png"); + payeeProfile.setDisplayName("API测试收款码"); + payeeProfile.setLastConfirmedAt(now); + + Mockito.when(clerkPayeeProfileService.getByClerk( + ApiTestDataSeeder.DEFAULT_TENANT_ID, ApiTestDataSeeder.DEFAULT_CLERK_ID)) + .thenAnswer(invocation -> payeeProfile); + Mockito.when(clerkPayeeProfileService.updateById(Mockito.any(ClerkPayeeProfileEntity.class))) + .thenAnswer(invocation -> { + payeeProfile = invocation.getArgument(0); + return true; + }); + Mockito.when(clerkPayeeProfileService.save(Mockito.any(ClerkPayeeProfileEntity.class))) + .thenReturn(true); + Mockito.when(clerkPayeeProfileService.removeById(Mockito.any())) + .thenReturn(true); + clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID); + clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken); + } + + @AfterEach + void tearDown() { + ensureTenantContext(); + if (!earningsToCleanup.isEmpty()) { + earningsService.removeByIds(earningsToCleanup); + earningsToCleanup.clear(); + } + if (!withdrawalsToCleanup.isEmpty()) { + withdrawalService.removeByIds(withdrawalsToCleanup); + withdrawalsToCleanup.clear(); + } + Mockito.reset(clerkPayeeProfileService); + CustomSecurityContextHolder.remove(); + } + + @Test + void balanceEndpointAggregatesAvailableAndPendingEarnings() throws Exception { + ensureTenantContext(); + LocalDateTime now = LocalDateTime.now().withNano(0); + String availableId = insertEarningsLine( + "available", + new BigDecimal("35.50"), + EarningsStatus.AVAILABLE, + now.minusHours(1)); + String frozenId = insertEarningsLine( + "frozen", + new BigDecimal("64.40"), + EarningsStatus.FROZEN, + now.plusHours(6)); + earningsToCleanup.add(availableId); + earningsToCleanup.add(frozenId); + + MvcResult result = mockMvc.perform(get("/wx/withdraw/balance") + .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)) + .andReturn(); + + JsonNode root = new ObjectMapper().readTree(result.getResponse().getContentAsString()); + JsonNode data = root.get("data"); + assertThat(data.get("available").decimalValue()).isEqualByComparingTo("35.50"); + assertThat(data.get("pending").decimalValue()).isEqualByComparingTo("64.40"); + assertThat(data.get("nextUnlockAt").asText()) + .isEqualTo(now.plusHours(6).format(DATE_TIME_FORMATTER)); + } + + @Test + void createWithdrawRejectsNonPositiveAmount() throws Exception { + ensureTenantContext(); + mockMvc.perform(post("/wx/withdraw/requests") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\":0}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(500)) + .andExpect(jsonPath("$.message").value("提现金额必须大于0")); + } + + @Test + void createWithdrawLocksEligibleEarningsLines() throws Exception { + ensureTenantContext(); + BigDecimal amount = new BigDecimal("80.00"); + String firstLine = insertEarningsLine( + "available-one", + new BigDecimal("50.00"), + EarningsStatus.AVAILABLE, + LocalDateTime.now().minusDays(1)); + String secondLine = insertEarningsLine( + "available-two", + new BigDecimal("30.00"), + EarningsStatus.AVAILABLE, + LocalDateTime.now().minusHours(2)); + earningsToCleanup.add(firstLine); + earningsToCleanup.add(secondLine); + + MvcResult result = mockMvc.perform(post("/wx/withdraw/requests") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\":80}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.id").isNotEmpty()) + .andExpect(jsonPath("$.data.amount").value(amount.doubleValue())) + .andReturn(); + + JsonNode root = new ObjectMapper().readTree(result.getResponse().getContentAsString()); + String withdrawalId = root.path("data").path("id").asText(); + assertThat(withdrawalId).isNotBlank(); + + withdrawalsToCleanup.add(withdrawalId); + + ensureTenantContext(); + WithdrawalRequestEntity request = withdrawalService.getById(withdrawalId); + assertThat(request).isNotNull(); + assertThat(request.getClerkId()).isEqualTo(ApiTestDataSeeder.DEFAULT_CLERK_ID); + assertThat(request.getAmount()).isEqualByComparingTo(amount); + + EarningsLineEntity lockedOne = earningsService.getById(firstLine); + EarningsLineEntity lockedTwo = earningsService.getById(secondLine); + assertThat(lockedOne.getStatus()).isEqualTo(EarningsStatus.WITHDRAWING.getCode()); + assertThat(lockedTwo.getStatus()).isEqualTo(EarningsStatus.WITHDRAWING.getCode()); + assertThat(lockedOne.getWithdrawalId()).isEqualTo(withdrawalId); + assertThat(lockedTwo.getWithdrawalId()).isEqualTo(withdrawalId); + } + + @Test + void earningsEndpointFiltersByStatus() throws Exception { + ensureTenantContext(); + String availableId = insertEarningsLine( + "earning-available", + new BigDecimal("20.00"), + EarningsStatus.AVAILABLE, + LocalDateTime.now().minusHours(3)); + String frozenId = insertEarningsLine( + "earning-frozen", + new BigDecimal("45.00"), + EarningsStatus.FROZEN, + LocalDateTime.now().plusHours(6)); + String withdrawnId = insertEarningsLine( + "earning-withdrawn", + new BigDecimal("15.00"), + EarningsStatus.WITHDRAWING, + LocalDateTime.now().minusDays(1)); + earningsToCleanup.add(availableId); + earningsToCleanup.add(frozenId); + earningsToCleanup.add(withdrawnId); + + mockMvc.perform(get("/wx/withdraw/earnings") + .param("status", EarningsStatus.AVAILABLE.getCode()) + .param("pageNum", "1") + .param("pageSize", "10") + .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").isArray()) + .andExpect(jsonPath("$.data[0].id").value(availableId)) + .andExpect(jsonPath("$.data[0].status").value(EarningsStatus.AVAILABLE.getCode())) + .andExpect(jsonPath("$.data[1]").doesNotExist()); + } + + private String insertEarningsLine( + String suffix, BigDecimal amount, EarningsStatus status, LocalDateTime unlockAt) { + EarningsLineEntity entity = new EarningsLineEntity(); + String id = "earn-" + suffix + "-" + IdUtils.getUuid(); + entity.setId(id); + entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID); + String rawOrderId = ORDER_ID_PREFIX + IdUtils.getUuid(); + entity.setOrderId(rawOrderId.length() <= 32 ? rawOrderId : rawOrderId.substring(0, 32)); + entity.setAmount(amount); + entity.setStatus(status.getCode()); + entity.setUnlockTime(unlockAt); + entity.setEarningType(EarningsType.ORDER); + Date now = toDate(LocalDateTime.now()); + entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + entity.setCreatedTime(now); + entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + entity.setUpdatedTime(now); + earningsService.save(entity); + return id; + } + + private void ensureTenantContext() { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + } + + private Date toDate(LocalDateTime value) { + return Date.from(value.atZone(ZoneId.systemDefault()).toInstant()); + } + + private enum EarningsStatus { + AVAILABLE("available"), + FROZEN("frozen"), + WITHDRAWING("withdrawing"); + + private final String code; + + EarningsStatus(String code) { + this.code = code; + } + + String getCode() { + return code; + } + } +}