Selaa lähdekoodia

test(S-cfc-295,activity.checkin.boost): RED 契约测试——活动打卡/助力进度累计+幂等+反刷量

SC-P-77 活动打卡/助力(营销):打卡→进度累计→助力;重复打卡幂等;
刷量/机器行为拦截;CF 值预估(SC-X-22)。
契约宿主 ActivityCheckinBoostService 尚未实现,14 用例 13 红 1 标记不适用。
zxyj-langgraph 1 päivä sitten
vanhempi
sitoutus
f2255182dd

+ 276 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ActivityCheckinBoostContractTest.java

@@ -0,0 +1,276 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+
+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.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:活动打卡/助力(营销)进度累计与反刷量(能力键 {@code activity.checkin.boost},单元 activity)。
+ *
+ * <p><b>需求/设计锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-77(活动打卡/助力·营销)——
+ *       主流程「打卡 → 进度累计 → 助力」;异常/边界「重复打卡幂等;刷量/机器行为拦截」;
+ *       CF 值预估「打卡/助力为平台 CF 值产出点,操作入口须按 SC-X-22 展示『本操作预计 +N CF』」</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.7 健康打卡(/api/daily/checkin、
+ *       /api/mind/checkin 情绪日记同步)+ 活动打卡(AdminActivityCheckinController
+ *       /api/admin/activity-checkins);模块:活动({@code ActivityService.checkin} 现状为
+ *       一次性签到任务,无进度累计/助力/反刷量/CF 预估)</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为</b></p>
+ * <ul>
+ *   <li>打卡 → 进度累计:同一活动下某成员打卡一次,进度 +1;连续打卡逐次累计</li>
+ *   <li>助力 → 进度累计:他人为目标成员助力一次,目标进度 +1;打卡后再助力继续累计</li>
+ *   <li>重复打卡幂等:同一 (activityId, memberId) 重复打卡,进度不重复累计,返回既有进度</li>
+ *   <li>刷量/机器行为拦截:窗口内高频打卡判为刷量应拒绝;同一设备短时关联多账号判为机器行为应拒绝</li>
+ *   <li>CF 值预估(SC-X-22):打卡/助力入口应返回「本操作预计 +N CF」的预估(非负,规则未配置可降级为 0)</li>
+ * </ul>
+ *
+ * <p><b>宿主契约(TDD-RED 锚点)</b>:{@code com.etotem.cfc.service.ActivityCheckinBoostService} ——
+ * 承载活动打卡/助力营销进度的服务类,当前(2026-11-14)<b>尚未实现</b>。本测试通过反射锚定该
+ * 宿主契约方法(避免直接 import 不存在类导致编译失败),运行时宿主类缺失即断言失败(红),
+ * 暴露"实现未落地"这一真实缺陷;实现落地后转为验证真实行为(GREEN)。</p>
+ *
+ * <p><b>契约方法签名(实现方按此落地)</b></p>
+ * <ul>
+ *   <li>{@code int checkin(long activityId, long userId, long memberId, String deviceId)}
+ *       —— 打卡一次,进度 +1,返回该成员本次活动累计进度</li>
+ *   <li>{@code int boost(long activityId, long targetUserId, long helperUserId, String deviceId)}
+ *       —— 助力目标成员,目标进度 +1,返回目标成员累计进度</li>
+ *   <li>{@code int checkinProgress(long activityId, long memberId)}
+ *       —— 查询该成员本次活动累计进度</li>
+ *   <li>{@code int expectedCf(long activityId, String actionType)}
+ *       —— SC-X-22 CF 值预估:actionType ∈ {@code checkin}/{@code boost},返回本操作预计获得 CF
+ *       (规则未配置可降级为 0,不得为负)</li>
+ *   <li>{@code boolean shouldRejectCheckin(int checkinsInWindow, int windowSeconds)}
+ *       —— 刷量拦截判定(纯函数):窗口内打卡次数超过阈值(如 60s 内 ≥ 5 次)应返回 true</li>
+ *   <li>{@code boolean shouldRejectMachineAbuse(int accountsOnDeviceInWindow, int windowHours)}
+ *       —— 机器行为拦截判定(纯函数):同一设备窗口内关联账号数超过阈值(如 1h 内 ≥ 3 账号)应返回 true</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,无 Spring / 无 DB / 无外部服务依赖(纯 Mockito/反射风格,
+ * 与同批已合入主链的 {@code TeamStarMultiplierContractTest} / {@code TeamProfitHookContractTest} 一致)。</p>
+ *
+ * <p><b>权限流说明</b>:SC-P-77 营销打卡/助力面向已报名活动成员,本契约聚焦进度累计/幂等/反刷量
+ * 与 CF 预估行为,无独立角色权限分支(权限校验由活动报名与身份门禁承载,属 {@code activity.enroll.join}
+ * 范围),故权限流以说明性用例标注不适用。</p>
+ *
+ * <p><b>RED 预期</b>:宿主 {@code ActivityCheckinBoostService} 未实现,全部用例在反射探测处失败(红),
+ * 断言原文为「RED[activity.checkin.boost] 契约宿主 ActivityCheckinBoostService 未实现(实现未落地)」。</p>
+ */
+class ActivityCheckinBoostContractTest {
+
+    /** 规格设计宿主类(未实现)。 */
+    private static final String HOST_CLASS = "com.etotem.cfc.service.ActivityCheckinBoostService";
+
+    // ==================== 契约常量(SC-P-77 边界) ====================
+
+    /** 示例活动 ID(营销活动)。 */
+    private static final long ACTIVITY_ID = 9001L;
+    /** 打卡成员(memberId)。 */
+    private static final long MEMBER_ID = 70001L;
+    /** 打卡操作账号(userId)。 */
+    private static final long USER_ID = 60001L;
+    /** 助力人账号(helperUserId)。 */
+    private static final long HELPER_USER_ID = 60002L;
+    /** 目标成员(targetUserId 对应的 memberId)。 */
+    private static final long TARGET_MEMBER_ID = 70002L;
+    /** 设备指纹(刷量/机器行为拦截维度)。 */
+    private static final String DEVICE_ID = "device-a1b2c3";
+
+    /** 刷量阈值锚点:60 秒窗口内 5 次及以上判为刷量(SC-P-77 反刷量)。 */
+    private static final int ABUSE_CHECKINS = 5;
+    private static final int ABUSE_WINDOW_SECONDS = 60;
+    /** 机器行为阈值锚点:1 小时窗口内同设备 3 个及以上账号判为机器行为。 */
+    private static final int MACHINE_ACCOUNTS = 3;
+    private static final int MACHINE_WINDOW_HOURS = 1;
+
+    // ==================== 正常流:打卡 → 进度累计 ====================
+
+    @Test
+    @DisplayName("正常流-1:首次打卡进度累计为 1")
+    void checkin_firstTime_progressIsOne() {
+        Object host = contractHost();
+        int progress = callInt(host, "checkin", ACTIVITY_ID, USER_ID, MEMBER_ID, DEVICE_ID);
+        assertEquals(1, progress, "首次打卡后累计进度应为 1");
+    }
+
+    @Test
+    @DisplayName("正常流-2:连续打卡进度逐次累计")
+    void checkin_consecutive_progressAccumulates() {
+        Object host = contractHost();
+        callInt(host, "checkin", ACTIVITY_ID, USER_ID, MEMBER_ID, DEVICE_ID);
+        callInt(host, "checkin", ACTIVITY_ID, USER_ID, MEMBER_ID, DEVICE_ID);
+        int progress = callInt(host, "checkin", ACTIVITY_ID, USER_ID, MEMBER_ID, DEVICE_ID);
+        assertEquals(3, progress, "连续打卡三次后累计进度应为 3");
+    }
+
+    @Test
+    @DisplayName("正常流-3:checkinProgress 查询返回累计值")
+    void checkinProgress_reflectsAccumulatedValue() {
+        Object host = contractHost();
+        callInt(host, "checkin", ACTIVITY_ID, USER_ID, MEMBER_ID, DEVICE_ID);
+        callInt(host, "checkin", ACTIVITY_ID, USER_ID, MEMBER_ID, DEVICE_ID);
+        int progress = callInt(host, "checkinProgress", ACTIVITY_ID, MEMBER_ID);
+        assertEquals(2, progress, "查询进度应与两次打卡累计一致(2)");
+    }
+
+    // ==================== 正常流:助力 → 目标进度累计 ====================
+
+    @Test
+    @DisplayName("正常流-4:助力使目标成员进度 +1")
+    void boost_addsOneToTargetProgress() {
+        Object host = contractHost();
+        callInt(host, "checkin", ACTIVITY_ID, USER_ID, TARGET_MEMBER_ID, DEVICE_ID);
+        int progress = callInt(host, "boost", ACTIVITY_ID, USER_ID, HELPER_USER_ID, DEVICE_ID);
+        assertEquals(2, progress, "目标打卡 1 次后再被助力 1 次,目标进度应为 2");
+    }
+
+    @Test
+    @DisplayName("正常流-5:打卡后再助力进度继续累计")
+    void checkinThenBoost_continuesAccumulation() {
+        Object host = contractHost();
+        callInt(host, "checkin", ACTIVITY_ID, USER_ID, MEMBER_ID, DEVICE_ID);
+        callInt(host, "checkin", ACTIVITY_ID, USER_ID, MEMBER_ID, DEVICE_ID);
+        int progress = callInt(host, "boost", ACTIVITY_ID, USER_ID, HELPER_USER_ID, DEVICE_ID);
+        assertEquals(3, progress, "打卡两次后再助力一次,累计进度应为 3");
+    }
+
+    // ==================== 边界流:重复打卡幂等 ====================
+
+    @Test
+    @DisplayName("边界流-1:重复打卡幂等,进度不重复累计")
+    void duplicateCheckin_idempotent_progressNotDoubleCounted() {
+        Object host = contractHost();
+        callInt(host, "checkin", ACTIVITY_ID, USER_ID, MEMBER_ID, DEVICE_ID);
+        int second = callInt(host, "checkin", ACTIVITY_ID, USER_ID, MEMBER_ID, DEVICE_ID);
+        assertEquals(1, second, "同一 (activityId, memberId) 重复打卡不得重复累计进度(幂等)");
+    }
+
+    // ==================== 边界流:刷量 / 机器行为拦截 ====================
+
+    @Test
+    @DisplayName("边界流-2:窗口内高频打卡判为刷量,应拒绝")
+    void highFrequencyCheckin_shouldReject() {
+        Object host = contractHost();
+        boolean reject = callBoolean(host, "shouldRejectCheckin", ABUSE_CHECKINS, ABUSE_WINDOW_SECONDS);
+        assertTrue(reject, "60 秒窗口内 5 次打卡应判为刷量并拒绝(SC-P-77 反刷量)");
+    }
+
+    @Test
+    @DisplayName("边界流-3:正常低频打卡不判刷量")
+    void normalFrequencyCheckin_notRejected() {
+        Object host = contractHost();
+        boolean reject = callBoolean(host, "shouldRejectCheckin", 1, ABUSE_WINDOW_SECONDS);
+        assertFalse(reject, "60 秒窗口内 1 次打卡属正常行为,不应拒绝");
+    }
+
+    @Test
+    @DisplayName("边界流-4:同设备短时多账号判为机器行为,应拒绝")
+    void sameDeviceMultiAccount_shouldReject() {
+        Object host = contractHost();
+        boolean reject = callBoolean(host, "shouldRejectMachineAbuse", MACHINE_ACCOUNTS, MACHINE_WINDOW_HOURS);
+        assertTrue(reject, "1 小时窗口内同设备 3 个账号应判为机器行为并拒绝");
+    }
+
+    @Test
+    @DisplayName("边界流-5:同设备单账号不判机器行为")
+    void sameDeviceSingleAccount_notRejected() {
+        Object host = contractHost();
+        boolean reject = callBoolean(host, "shouldRejectMachineAbuse", 1, MACHINE_WINDOW_HOURS);
+        assertFalse(reject, "1 小时窗口内同设备 1 个账号属正常,不应判机器行为");
+    }
+
+    // ==================== CF 值预估流(SC-X-22) ====================
+
+    @Test
+    @DisplayName("CF预估-1:打卡入口应返回预计获得 CF(非负)")
+    void expectedCf_checkin_nonNegative() {
+        Object host = contractHost();
+        int cf = callInt(host, "expectedCf", ACTIVITY_ID, "checkin");
+        assertTrue(cf >= 0, "打卡入口应返回『本操作预计 +N CF』预估(SC-X-22),cf=" + cf);
+    }
+
+    @Test
+    @DisplayName("CF预估-2:助力入口应返回预计获得 CF(非负)")
+    void expectedCf_boost_nonNegative() {
+        Object host = contractHost();
+        int cf = callInt(host, "expectedCf", ACTIVITY_ID, "boost");
+        assertTrue(cf >= 0, "助力入口应返回『本操作预计 +N CF』预估(SC-X-22),cf=" + cf);
+    }
+
+    @Test
+    @DisplayName("CF预估-3:未知动作类型不得为负(降级为 0)")
+    void expectedCf_unknownAction_notNegative() {
+        Object host = contractHost();
+        int cf = callInt(host, "expectedCf", ACTIVITY_ID, "unknown");
+        assertTrue(cf >= 0, "未知动作类型 CF 预估不得为负(SC-X-22 降级口径),cf=" + cf);
+    }
+
+    // ==================== 权限流(不适用) ====================
+
+    @Test
+    @DisplayName("权限流:营销打卡/助力无独立角色权限分支,权限流不适用")
+    void permissionFlow_notApplicable() {
+        // 说明性用例:SC-P-77 权限校验由活动报名与身份门禁承载(activity.enroll.join),
+        // 本能力聚焦进度累计/幂等/反刷量/CF 预估,无独立权限分支。不产生断言失败。
+        assertTrue(true, "权限流不适用:activity.checkin.boost 无独立角色权限分支。");
+    }
+
+    // ==================== 反射辅助(锚定宿主契约,避免编译期依赖不存在类) ====================
+
+    private Object contractHost() {
+        try {
+            Class<?> clazz = Class.forName(HOST_CLASS);
+            return clazz.getDeclaredConstructor().newInstance();
+        } catch (ClassNotFoundException e) {
+            fail("RED[activity.checkin.boost] 契约宿主 ActivityCheckinBoostService 未实现(实现未落地):"
+                    + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        } catch (Exception e) {
+            fail("契约宿主 ActivityCheckinBoostService 实例化失败:" + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        }
+    }
+
+    /** 按方法名 + 参数个数匹配并调用(宽松适配基本/包装类型),契约方法缺失即断言失败。 */
+    private Object call(Object host, String method, Object... args) {
+        for (Method m : host.getClass().getMethods()) {
+            if (m.getName().equals(method) && m.getParameterCount() == args.length) {
+                try {
+                    return m.invoke(host, args);
+                } catch (Exception e) {
+                    fail("契约方法 " + HOST_CLASS + "#" + method + " 调用失败:" + e.getMessage());
+                    throw new IllegalStateException("unreachable");
+                }
+            }
+        }
+        fail("RED[activity.checkin.boost] 契约方法 " + HOST_CLASS + "#" + method + " 未实现");
+        throw new IllegalStateException("unreachable");
+    }
+
+    private int callInt(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof Number) {
+            return ((Number) r).intValue();
+        }
+        fail("契约方法 " + method + " 应返回整数,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    private boolean callBoolean(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof Boolean) {
+            return (Boolean) r;
+        }
+        fail("契约方法 " + method + " 应返回布尔,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+}