Просмотр исходного кода

test(activity): T15 P0 W1 复核 补 unit 层名额并发/边界用例

现场核对 W1 已在上游实现:
- ActivityRegistrationService.occupyQuota: 条件 UPDATE (approved_count < max) 原子扣减
- ActivityRegistrationService.releaseQuota: 条件 UPDATE (approved_count > 0) 回滚
- ActivityRegistrationService.cancel: approved 取消 退款+releaseQuota 同事务

新增 unit/ 层 8 个用例补充边界验证:
- batchRegister 双报名人抢最后名额,第二个失败
- 双线程并发 register 抢最后名额,恰一成一败
- cancel approved: 退款成功才回滚名额 / 无订单免费活动直接回滚
- cancel pending: 不占名额故不回滚
- approve pending: 名额可用→占成功 / 名额已满→拒绝
zxyj-web 2 недель назад
Родитель
Сommit
c95d1b14df

+ 294 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ActivityRegistrationQuotaUnitTest.java

@@ -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;
+    }
+}