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