|
|
@@ -0,0 +1,197 @@
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.etotem.cfc.dto.ReportAnalyzeQuotaDTO;
|
|
|
+import com.etotem.cfc.entity.MembershipLevel;
|
|
|
+import com.etotem.cfc.mapper.MembershipLevelMapper;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
+import org.mockito.Mock;
|
|
|
+import org.mockito.MockitoAnnotations;
|
|
|
+import org.springframework.data.redis.core.RedisTemplate;
|
|
|
+import org.springframework.data.redis.core.SetOperations;
|
|
|
+import org.springframework.data.redis.core.ValueOperations;
|
|
|
+
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.time.Duration;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.anyString;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.times;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.verifyNoInteractions;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * ReportAnalyzeQuotaService 单元测试(纯 Mockito,无 SpringBootTest、无真实 Redis)。
|
|
|
+ *
|
|
|
+ * 覆盖验收口径:
|
|
|
+ * <ul>
|
|
|
+ * <li>免费版当日第 2 次被拒(文案「今日已用完」,与付费版「请升级会员」可区分);</li>
|
|
|
+ * <li>计数 key 到期(次日新 key)后免费额度自动恢复;key 格式 report:analyze:{userId}:{yyyyMMdd};</li>
|
|
|
+ * <li>付费版多轮:同一 conversationId 只计 1 次;</li>
|
|
|
+ * <li>付费版超限被拒(文案「请升级会员」)且回滚会话标记;</li>
|
|
|
+ * <li>付费版上限 0 = 不限次:放行且不落计数器;</li>
|
|
|
+ * <li>只读查询不扣减次数。</li>
|
|
|
+ * </ul>
|
|
|
+ */
|
|
|
+public class ReportAnalyzeQuotaServiceTest {
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private RedisTemplate<String, Object> redisTemplate;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private ValueOperations<String, Object> valueOps;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private SetOperations<String, Object> setOps;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private MembershipService membershipService;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private MembershipLevelMapper membershipLevelMapper;
|
|
|
+
|
|
|
+ private ReportAnalyzeQuotaService service;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ public void setUp() {
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
+ ReportAnalyzeQuotaService raw = new ReportAnalyzeQuotaService();
|
|
|
+ setField(raw, "redisTemplate", redisTemplate);
|
|
|
+ setField(raw, "membershipService", membershipService);
|
|
|
+ setField(raw, "membershipLevelMapper", membershipLevelMapper);
|
|
|
+ service = raw;
|
|
|
+
|
|
|
+ when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
|
|
+ when(redisTemplate.opsForSet()).thenReturn(setOps);
|
|
|
+ when(redisTemplate.expire(anyString(), any(Duration.class))).thenReturn(Boolean.TRUE);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void setField(Object target, String fieldName, Object value) {
|
|
|
+ try {
|
|
|
+ Field f = target.getClass().getDeclaredField(fieldName);
|
|
|
+ f.setAccessible(true);
|
|
|
+ f.set(target, value);
|
|
|
+ } catch (Exception e) {
|
|
|
+ throw new RuntimeException(e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private MembershipLevel level(String code, Integer maxDailyAnalyze) {
|
|
|
+ MembershipLevel lv = new MembershipLevel();
|
|
|
+ lv.setLevelCode(code);
|
|
|
+ lv.setMaxDailyAnalyze(maxDailyAnalyze);
|
|
|
+ return lv;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 免费版 ─────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void freeUser_secondCallSameDay_rejectedWithTodayExhaustedMessage() {
|
|
|
+ when(membershipService.getMemberLevel(1L)).thenReturn("FREE");
|
|
|
+ when(valueOps.increment(anyString())).thenReturn(1L, 2L);
|
|
|
+
|
|
|
+ ReportAnalyzeQuotaDTO first = service.consume(1L, null);
|
|
|
+ assertTrue(first.isAllowed());
|
|
|
+ assertEquals(1, first.getTotal());
|
|
|
+ assertEquals(0, first.getRemaining());
|
|
|
+
|
|
|
+ ReportAnalyzeQuotaDTO second = service.consume(1L, null);
|
|
|
+ assertFalse(second.isAllowed());
|
|
|
+ assertTrue(second.getMessage().contains("今日已用完"));
|
|
|
+ assertTrue(second.getMessage().contains("免费版"));
|
|
|
+ assertFalse(second.getMessage().contains("请升级会员"));
|
|
|
+ assertEquals(ReportAnalyzeQuotaService.UPGRADE_URL, second.getActionUrl());
|
|
|
+ assertTrue(second.isExhausted());
|
|
|
+ // 免费版读等级配置为硬编码 1,不查配置表
|
|
|
+ verify(membershipLevelMapper, never()).selectOne(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void freeUser_counterExpired_nextDay_allowedAgain_withDatedKey() {
|
|
|
+ when(membershipService.getMemberLevel(1L)).thenReturn("FREE");
|
|
|
+ // key 到期失效后重新 INCR 从 1 开始(次日为新 key)
|
|
|
+ when(valueOps.increment(anyString())).thenReturn(1L, 1L);
|
|
|
+
|
|
|
+ assertTrue(service.consume(1L, null).isAllowed());
|
|
|
+ assertTrue(service.consume(1L, null).isAllowed());
|
|
|
+
|
|
|
+ ArgumentCaptor<String> keyCaptor = ArgumentCaptor.forClass(String.class);
|
|
|
+ verify(valueOps, times(2)).increment(keyCaptor.capture());
|
|
|
+ assertTrue(keyCaptor.getAllValues().get(0).matches("report:analyze:1:\\d{8}"),
|
|
|
+ "计数器 key 应为 report:analyze:{userId}:{yyyyMMdd},实际=" + keyCaptor.getAllValues().get(0));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 付费版 ─────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void paidBasic_sameConversation_multiRound_chargedOnlyOnce() {
|
|
|
+ when(membershipService.getMemberLevel(2L)).thenReturn("BASIC");
|
|
|
+ when(membershipLevelMapper.selectOne(any())).thenReturn(level("BASIC", 3));
|
|
|
+ // SADD:首次加入返回 1(已计费),同一会话再次加入返回 0(已计过费)
|
|
|
+ when(setOps.add(anyString(), any())).thenReturn(1L, 0L);
|
|
|
+ when(valueOps.increment(anyString())).thenReturn(1L);
|
|
|
+ when(valueOps.get(anyString())).thenReturn(1);
|
|
|
+
|
|
|
+ ReportAnalyzeQuotaDTO first = service.consume(2L, "conv-1");
|
|
|
+ assertTrue(first.isAllowed());
|
|
|
+ assertEquals(2, first.getRemaining());
|
|
|
+
|
|
|
+ // 同一会话第二轮:放行但不重复扣次数
|
|
|
+ ReportAnalyzeQuotaDTO second = service.consume(2L, "conv-1");
|
|
|
+ assertTrue(second.isAllowed());
|
|
|
+ assertEquals(2, second.getRemaining());
|
|
|
+ verify(valueOps, times(1)).increment(anyString());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void paidBasic_quotaExhausted_rejectedWithUpgradeMessage_andSessionRolledBack() {
|
|
|
+ when(membershipService.getMemberLevel(3L)).thenReturn("BASIC");
|
|
|
+ when(membershipLevelMapper.selectOne(any())).thenReturn(level("BASIC", 3));
|
|
|
+ when(setOps.add(anyString(), any())).thenReturn(1L);
|
|
|
+ when(valueOps.increment(anyString())).thenReturn(4L);
|
|
|
+
|
|
|
+ ReportAnalyzeQuotaDTO result = service.consume(3L, "conv-x");
|
|
|
+ assertFalse(result.isAllowed());
|
|
|
+ assertTrue(result.getMessage().contains("请升级会员"));
|
|
|
+ assertFalse(result.getMessage().contains("今日已用完"));
|
|
|
+ assertEquals(ReportAnalyzeQuotaService.UPGRADE_URL, result.getActionUrl());
|
|
|
+ // 本次未扣成功 → 回滚会话标记,同一会话仍可再次作为新一轮计费
|
|
|
+ verify(setOps).remove(anyString(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void paidFamily_limitZero_unlimited_allowedWithoutCounting() {
|
|
|
+ when(membershipService.getMemberLevel(4L)).thenReturn("FAMILY");
|
|
|
+ when(membershipLevelMapper.selectOne(any())).thenReturn(level("FAMILY", 0));
|
|
|
+
|
|
|
+ ReportAnalyzeQuotaDTO result = service.consume(4L, "conv-1");
|
|
|
+ assertTrue(result.isAllowed());
|
|
|
+ assertTrue(result.isUnlimited());
|
|
|
+ assertEquals(-1, result.getRemaining());
|
|
|
+ assertFalse(result.isExhausted());
|
|
|
+ verify(valueOps, never()).increment(anyString());
|
|
|
+ verifyNoInteractions(setOps);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 只读查询 ───────────────────────────────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void getStatus_reportsRemaining_withoutConsuming() {
|
|
|
+ when(membershipService.getMemberLevel(5L)).thenReturn("FREE");
|
|
|
+ when(valueOps.get(anyString())).thenReturn(0);
|
|
|
+
|
|
|
+ ReportAnalyzeQuotaDTO status = service.getStatus(5L);
|
|
|
+ assertEquals("FREE", status.getLevelCode());
|
|
|
+ assertEquals(1, status.getTotal());
|
|
|
+ assertEquals(1, status.getRemaining());
|
|
|
+ assertFalse(status.isExhausted());
|
|
|
+ assertTrue(status.isAllowed());
|
|
|
+ verify(valueOps, never()).increment(anyString());
|
|
|
+ }
|
|
|
+}
|