|
|
@@ -0,0 +1,294 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
+import com.etotem.cfc.entity.Activity;
|
|
|
+import com.etotem.cfc.entity.ActivityOrder;
|
|
|
+import com.etotem.cfc.entity.ActivityRegistration;
|
|
|
+import com.etotem.cfc.mapper.ActivityMapper;
|
|
|
+import com.etotem.cfc.mapper.ActivityOrderMapper;
|
|
|
+import com.etotem.cfc.mapper.ActivityRegistrationMapper;
|
|
|
+import com.etotem.cfc.service.ActivityRegistrationService;
|
|
|
+import com.etotem.cfc.service.PaymentService;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.concurrent.CountDownLatch;
|
|
|
+import java.util.concurrent.atomic.AtomicInteger;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+import static org.mockito.ArgumentMatchers.*;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
+
|
|
|
+/**
|
|
|
+ * W1 活动报名名额边界用例(unit 层)。
|
|
|
+ *
|
|
|
+ * 现有 service/ 并发测试验证了 occupyQuota/releaseQuota 的条件 UPDATE 形态与
|
|
|
+ * cancel 退款分支;本类补充链路级边界场景:
|
|
|
+ * 1. batchRegister 最后一个名额被抢:只有第一个成功,第二个返回"名额已满"
|
|
|
+ * 2. 两个线程并发抢最后一个名额:恰好一个成功、一个失败
|
|
|
+ * 3. cancel 取消 approved 报名:退款成功才回滚名额;pending 取消不回滚(未占名额)
|
|
|
+ * 4. approve 通过待审核报名:占用名额,名额已满拒绝通过
|
|
|
+ */
|
|
|
+class ActivityRegistrationQuotaUnitTest {
|
|
|
+
|
|
|
+ private ActivityRegistrationService service;
|
|
|
+ private ActivityRegistrationMapper registrationMapper;
|
|
|
+ private ActivityMapper activityMapper;
|
|
|
+ private ActivityOrderMapper orderMapper;
|
|
|
+ private PaymentService paymentService;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() throws Exception {
|
|
|
+ service = new ActivityRegistrationService();
|
|
|
+ registrationMapper = mock(ActivityRegistrationMapper.class);
|
|
|
+ activityMapper = mock(ActivityMapper.class);
|
|
|
+ orderMapper = mock(ActivityOrderMapper.class);
|
|
|
+ paymentService = mock(PaymentService.class);
|
|
|
+ setField(service, "registrationMapper", registrationMapper);
|
|
|
+ setField(service, "activityMapper", activityMapper);
|
|
|
+ setField(service, "orderMapper", orderMapper);
|
|
|
+ setField(service, "paymentService", paymentService);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void setField(Object target, String name, Object value) throws Exception {
|
|
|
+ Field f = target.getClass().getDeclaredField(name);
|
|
|
+ f.setAccessible(true);
|
|
|
+ f.set(target, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== batchRegister:最后一个名额抢占 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void batchRegister_twoRegistrants_lastQuota_secondFails() {
|
|
|
+ Activity activity = publishedActivity(1, 1, 0);
|
|
|
+ when(activityMapper.selectById(1L)).thenReturn(activity);
|
|
|
+
|
|
|
+ // 两次条件 UPDATE:第一次命中(抢到),第二次不命中(名额已满)
|
|
|
+ when(activityMapper.update(isNull(), any(UpdateWrapper.class)))
|
|
|
+ .thenReturn(1)
|
|
|
+ .thenReturn(0);
|
|
|
+ // 两位报名人均未报名
|
|
|
+ when(registrationMapper.selectOne(any())).thenReturn(null);
|
|
|
+
|
|
|
+ Result<Map<String, Object>> result = service.batchRegister(1L, 100L,
|
|
|
+ registrants("张三", 10L, "李四", 20L));
|
|
|
+
|
|
|
+ assertEquals(500, result.getCode(), "第二名抢不到时必须返回错误");
|
|
|
+ assertTrue(result.getMessage().contains("名额已满"), result.getMessage());
|
|
|
+ // 只有第一名成功写入
|
|
|
+ verify(registrationMapper, times(1)).insert(any(ActivityRegistration.class));
|
|
|
+ // 条件 UPDATE 必须携带 approved_count 守卫
|
|
|
+ ArgumentCaptor<UpdateWrapper<Activity>> captor = ArgumentCaptor.forClass(UpdateWrapper.class);
|
|
|
+ verify(activityMapper, times(2)).update(isNull(), captor.capture());
|
|
|
+ for (UpdateWrapper<Activity> uw : captor.getAllValues()) {
|
|
|
+ assertTrue(uw.getSqlSegment().contains("approved_count"), uw.getSqlSegment());
|
|
|
+ assertTrue(uw.getSqlSegment().contains("<"), "必须为 approved_count < max 守卫: " + uw.getSqlSegment());
|
|
|
+ assertTrue(uw.getSqlSet().contains("approved_count = approved_count + 1"), uw.getSqlSet());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void batchRegister_unlimitedQuota_bothSucceed() {
|
|
|
+ Activity activity = publishedActivity(1, null, 0);
|
|
|
+ when(activityMapper.selectById(1L)).thenReturn(activity);
|
|
|
+ when(activityMapper.update(isNull(), any(UpdateWrapper.class))).thenReturn(1);
|
|
|
+ when(registrationMapper.selectOne(any())).thenReturn(null);
|
|
|
+
|
|
|
+ Result<Map<String, Object>> result = service.batchRegister(1L, 100L,
|
|
|
+ registrants("张三", 10L, "李四", 20L));
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ verify(registrationMapper, times(2)).insert(any(ActivityRegistration.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 并发模拟:两个线程抢最后一个名额 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void register_lastQuota_twoThreads_onlyOneSucceeds() throws InterruptedException {
|
|
|
+ Activity activity = publishedActivity(1, 1, 0);
|
|
|
+ when(activityMapper.selectById(1L)).thenReturn(activity);
|
|
|
+ when(registrationMapper.selectOne(any())).thenReturn(null);
|
|
|
+
|
|
|
+ // 并发条件 UPDATE:按调用次序,第 1 次命中返回 1 行,第 2 次不命中返回 0 行
|
|
|
+ AtomicInteger updateCalls = new AtomicInteger();
|
|
|
+ when(activityMapper.update(isNull(), any(UpdateWrapper.class)))
|
|
|
+ .thenAnswer(inv -> updateCalls.incrementAndGet() == 1 ? 1 : 0);
|
|
|
+
|
|
|
+ AtomicInteger success = new AtomicInteger();
|
|
|
+ AtomicInteger failure = new AtomicInteger();
|
|
|
+ CountDownLatch start = new CountDownLatch(1);
|
|
|
+ CountDownLatch done = new CountDownLatch(2);
|
|
|
+
|
|
|
+ Thread t1 = new Thread(() -> registerAndCount(success, failure, start, done, 10L));
|
|
|
+ Thread t2 = new Thread(() -> registerAndCount(success, failure, start, done, 20L));
|
|
|
+ t1.start();
|
|
|
+ t2.start();
|
|
|
+ start.countDown();
|
|
|
+ done.await();
|
|
|
+
|
|
|
+ assertEquals(1, success.get(), "并发抢最后一个名额必须恰好一个成功");
|
|
|
+ assertEquals(1, failure.get(), "并发抢最后一个名额必须恰好一个失败");
|
|
|
+ // 数据库侧只有一次 insert 落库
|
|
|
+ verify(registrationMapper, times(1)).insert(any(ActivityRegistration.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ private void registerAndCount(AtomicInteger success, AtomicInteger failure,
|
|
|
+ CountDownLatch start, CountDownLatch done, Long memberId) {
|
|
|
+ try {
|
|
|
+ start.await();
|
|
|
+ Result<Map<String, Object>> r = service.register(1L, 100L, memberId);
|
|
|
+ if (r.getCode() == 200) success.incrementAndGet();
|
|
|
+ else failure.incrementAndGet();
|
|
|
+ } catch (Exception e) {
|
|
|
+ failure.incrementAndGet();
|
|
|
+ } finally {
|
|
|
+ done.countDown();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== cancel:approved 与 pending 差异处理 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void cancel_approved_paidOrder_refundSucceeds_quotaReleased() {
|
|
|
+ when(registrationMapper.selectOne(any())).thenReturn(approvedReg());
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(paidOrder());
|
|
|
+ when(activityMapper.update(isNull(), any(UpdateWrapper.class))).thenReturn(1);
|
|
|
+
|
|
|
+ Result<Void> result = service.cancel(1L, 100L, 200L);
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ // 原路退款成功后才回滚名额
|
|
|
+ verify(paymentService).refund(eq("ACT999"), eq(99), anyString());
|
|
|
+ verify(activityMapper).update(isNull(), any(UpdateWrapper.class));
|
|
|
+ verify(registrationMapper).updateById(argThat(reg -> "cancelled".equals(reg.getStatus())));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void cancel_approved_noOrder_freeActivity_quotaReleasedNoRefund() {
|
|
|
+ when(registrationMapper.selectOne(any())).thenReturn(approvedReg());
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(null);
|
|
|
+ when(activityMapper.update(isNull(), any(UpdateWrapper.class))).thenReturn(1);
|
|
|
+
|
|
|
+ Result<Void> result = service.cancel(1L, 100L, 200L);
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ verify(paymentService, never()).refund(anyString(), anyInt(), anyString());
|
|
|
+ verify(activityMapper).update(isNull(), any(UpdateWrapper.class));
|
|
|
+ verify(registrationMapper).updateById(argThat(reg -> "cancelled".equals(reg.getStatus())));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void cancel_pending_noQuotaRelease() {
|
|
|
+ // pending 未占名额:取消时不得回滚名额
|
|
|
+ when(registrationMapper.selectOne(any())).thenReturn(pendingReg());
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(null);
|
|
|
+
|
|
|
+ Result<Void> result = service.cancel(1L, 100L, 200L);
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ verify(activityMapper, never()).update(any(), any());
|
|
|
+ verify(paymentService, never()).refund(anyString(), anyInt(), anyString());
|
|
|
+ verify(registrationMapper).updateById(argThat(reg -> "cancelled".equals(reg.getStatus())));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== approve:待审核通过时占用名额 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void approve_pending_quotaAvailable_approveAndOccupy() {
|
|
|
+ Activity activity = publishedActivity(1, 5, 1);
|
|
|
+ when(activityMapper.selectById(1L)).thenReturn(activity);
|
|
|
+ when(activityMapper.update(isNull(), any(UpdateWrapper.class))).thenReturn(1);
|
|
|
+
|
|
|
+ ActivityRegistration reg = new ActivityRegistration();
|
|
|
+ reg.setId(5L);
|
|
|
+ reg.setActivityId(1L);
|
|
|
+ reg.setStatus("pending");
|
|
|
+ when(registrationMapper.selectById(5L)).thenReturn(reg);
|
|
|
+
|
|
|
+ Result<Void> result = service.approve(5L, 100L);
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ verify(activityMapper).update(isNull(), any(UpdateWrapper.class));
|
|
|
+ verify(registrationMapper).updateById(argThat(r -> "approved".equals(r.getStatus())));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void approve_pending_quotaFull_approveRejected() {
|
|
|
+ Activity activity = publishedActivity(1, 1, 1);
|
|
|
+ when(activityMapper.selectById(1L)).thenReturn(activity);
|
|
|
+ // 名额已满:条件不命中,0 行受影响
|
|
|
+ when(activityMapper.update(isNull(), any(UpdateWrapper.class))).thenReturn(0);
|
|
|
+
|
|
|
+ ActivityRegistration reg = new ActivityRegistration();
|
|
|
+ reg.setId(5L);
|
|
|
+ reg.setActivityId(1L);
|
|
|
+ reg.setStatus("pending");
|
|
|
+ when(registrationMapper.selectById(5L)).thenReturn(reg);
|
|
|
+
|
|
|
+ Result<Void> result = service.approve(5L, 100L);
|
|
|
+
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("名额已满"));
|
|
|
+ verify(registrationMapper, never()).updateById(any(ActivityRegistration.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== helpers ====================
|
|
|
+
|
|
|
+ private Activity publishedActivity(long id, Integer maxParticipants, Integer requireReview) {
|
|
|
+ Activity activity = new Activity();
|
|
|
+ activity.setId(id);
|
|
|
+ activity.setMaxParticipants(maxParticipants);
|
|
|
+ activity.setRequireReview(requireReview);
|
|
|
+ activity.setStatus("published");
|
|
|
+ return activity;
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<Map<String, Object>> registrants(String n1, Long m1, String n2, Long m2) {
|
|
|
+ Map<String, Object> a = new HashMap<>();
|
|
|
+ a.put("name", n1);
|
|
|
+ a.put("phone", "13800138000");
|
|
|
+ a.put("memberId", m1);
|
|
|
+ Map<String, Object> b = new HashMap<>();
|
|
|
+ b.put("name", n2);
|
|
|
+ b.put("phone", "13800138001");
|
|
|
+ b.put("memberId", m2);
|
|
|
+ return new ArrayList<>(Arrays.asList(a, b));
|
|
|
+ }
|
|
|
+
|
|
|
+ private ActivityRegistration approvedReg() {
|
|
|
+ ActivityRegistration reg = new ActivityRegistration();
|
|
|
+ reg.setId(10L);
|
|
|
+ reg.setActivityId(1L);
|
|
|
+ reg.setUserId(100L);
|
|
|
+ reg.setChildId(200L);
|
|
|
+ reg.setStatus("approved");
|
|
|
+ return reg;
|
|
|
+ }
|
|
|
+
|
|
|
+ private ActivityRegistration pendingReg() {
|
|
|
+ ActivityRegistration reg = new ActivityRegistration();
|
|
|
+ reg.setId(10L);
|
|
|
+ reg.setActivityId(1L);
|
|
|
+ reg.setUserId(100L);
|
|
|
+ reg.setChildId(200L);
|
|
|
+ reg.setStatus("pending");
|
|
|
+ return reg;
|
|
|
+ }
|
|
|
+
|
|
|
+ private ActivityOrder paidOrder() {
|
|
|
+ ActivityOrder order = new ActivityOrder();
|
|
|
+ order.setOrderNo("ACT999");
|
|
|
+ order.setTotalAmount(99);
|
|
|
+ // resolveRegistrantFee 解析的是 JSON 数组
|
|
|
+ order.setRegistrantsJson("[{\"memberId\":200,\"fee\":99}]");
|
|
|
+ return order;
|
|
|
+ }
|
|
|
+}
|