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(), 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(); } @Test void queryByIdFromClerkControllerHidesCustomerInfoForPendingRandomOrders() throws Exception { String marker = "privacy-leak-" + LocalDateTime.now().toString(); String orderId = createRandomOrder(marker); // Access via the generic Clerk Order Detail endpoint (which the notification likely links to) MvcResult result = mockMvc.perform(get("/wx/clerk/order/queryById") .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()); String nickname = data.path("customNickname").asText(); assertThat(nickname.equals("匿名用户") || "匿名用户".equals(nickname)).isTrue(); assertThat(data.path("customAvatar").asText()).isEmpty(); } @Test void queryByIdForAcceptedRandomOrderOwnedByCurrentClerkRetainsCustomerInfoAndStatus() throws Exception { String marker = "random-owned-" + LocalDateTime.now(); String orderId = createRandomOrder(marker); // Mark the random order as accepted by the current test clerk ensureTenantContext(); playOrderInfoService.lambdaUpdate() .eq(PlayOrderInfoEntity::getId, orderId) .set(PlayOrderInfoEntity::getAcceptBy, ApiTestDataSeeder.DEFAULT_CLERK_ID) .set(PlayOrderInfoEntity::getOrderStatus, OrderConstant.OrderStatus.ACCEPTED.getCode()) .update(); MvcResult result = mockMvc.perform(get("/wx/clerk/order/queryById") .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("placeType").asText()).isEqualTo(OrderConstant.PlaceType.RANDOM.getCode()); assertThat(data.path("acceptBy").asText()).isEqualTo(ApiTestDataSeeder.DEFAULT_CLERK_ID); assertThat(data.path("orderStatus").asText()).isEqualTo(OrderConstant.OrderStatus.ACCEPTED.getCode()); // For the owning clerk, customer info should not be forced to anonymous String nickname = data.path("customNickname").asText(); assertThat(nickname).isNotEmpty(); assertThat(nickname).isNotIn("匿名用户", "匿名用户"); assertThat(data.path("weiChatCode").asText()) .withFailMessage("Owner clerk should see customer wechat for accepted random order") .isNotEmpty(); } @Test void queryByIdForRandomOrderAcceptedByAnotherClerkHidesCustomerInfoAndOrderStatus() throws Exception { String marker = "random-other-clerk-" + 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/clerk/order/queryById") .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("placeType").asText()).isEqualTo(OrderConstant.PlaceType.RANDOM.getCode()); assertThat(data.path("acceptBy").asText()).isEqualTo(OTHER_CLERK_ID); // Order status must be masked for random orders owned by another clerk assertThat(data.path("orderStatus").asText()).isEmpty(); 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(); } @Test void queryByIdForNonRandomOrderAcceptedByAnotherClerkDoesNotMaskStatus() throws Exception { String marker = "specified-other-clerk-" + LocalDateTime.now(); String orderId = createRandomOrder(marker); // Re-tag the order as a specified order to ensure non-random logic ensureTenantContext(); playOrderInfoService.lambdaUpdate() .eq(PlayOrderInfoEntity::getId, orderId) .set(PlayOrderInfoEntity::getPlaceType, OrderConstant.PlaceType.SPECIFIED.getCode()) .set(PlayOrderInfoEntity::getAcceptBy, OTHER_CLERK_ID) .set(PlayOrderInfoEntity::getOrderStatus, OrderConstant.OrderStatus.ACCEPTED.getCode()) .update(); MvcResult result = mockMvc.perform(get("/wx/clerk/order/queryById") .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("placeType").asText()).isEqualTo(OrderConstant.PlaceType.SPECIFIED.getCode()); // Status should remain the real value for non-random orders assertThat(data.path("orderStatus").asText()).isEqualTo(OrderConstant.OrderStatus.ACCEPTED.getCode()); // We still expect customer info to not be forcibly anonymized by the random-order privacy rules String nickname = data.path("customNickname").asText(); assertThat(nickname).isNotIn("匿名用户", "匿名用户"); } @Test void clerkOrderListHidesCustomerInfoForPendingRandomOrders() throws Exception { String marker = "privacy-list-" + LocalDateTime.now().toString(); String orderId = createRandomOrder(marker); // Ensure the created pending random order appears in the clerk's own order list ensureTenantContext(); playOrderInfoService.lambdaUpdate() .eq(PlayOrderInfoEntity::getId, orderId) .set(PlayOrderInfoEntity::getAcceptBy, ApiTestDataSeeder.DEFAULT_CLERK_ID) .update(); ObjectNode payload = mapper.createObjectNode(); payload.put("pageNum", 1); payload.put("pageSize", 10); payload.put("orderStatus", OrderConstant.OrderStatus.PENDING.getCode()); payload.put("placeType", OrderConstant.PlaceType.RANDOM.getCode()); MvcResult result = mockMvc.perform(post("/wx/clerk/order/queryByPage") .contentType(MediaType.APPLICATION_JSON) .header(USER_HEADER, DEFAULT_USER) .header(TENANT_HEADER, DEFAULT_TENANT) .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken) .content(payload.toString())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andReturn(); JsonNode root = mapper.readTree(result.getResponse().getContentAsString()); JsonNode data = root.path("data"); JsonNode records = data.isArray() ? data : data.path("records"); assertThat(records.isArray()).isTrue(); assertThat(records.size()).isGreaterThan(0); boolean found = false; for (JsonNode node : records) { assertThat(node.path("placeType").asText()).isEqualTo(OrderConstant.PlaceType.RANDOM.getCode()); assertThat(node.path("orderStatus").asText()).isEqualTo(OrderConstant.OrderStatus.PENDING.getCode()); if (orderId.equals(node.path("id").asText())) { found = true; String nickname = node.path("customNickname").asText(); assertThat(nickname.equals("匿名用户") || "匿名用户".equals(nickname)).isTrue(); assertThat(node.path("customAvatar").asText()).isEmpty(); assertThat(node.path("customId").asText()).isEmpty(); } } assertThat(found).as("Pending random order should appear in clerk list response").isTrue(); } 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)); } }