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

test(S-cfc-47): RED 契约级单测 team.star.digest 月度星级简报(StarDigestService + team_star_snapshot 表契约)

E2E Test Bot 5 дней назад
Родитель
Сommit
9dd9ab92e9

+ 325 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TeamStarDigestContractTest.java

@@ -0,0 +1,325 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:月度星级简报(能力键 {@code team.star.digest},单元 team,任务 S-cfc-47,TDD-RED)。
+ *
+ * <p><b>需求/设计锚点(规格 v0.7)</b>:
+ * {@code docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md}</p>
+ * <ul>
+ *   <li>需求:§12-T8(星级简报推送 P2)—— 每月 1 日重评后发微信订阅消息(升/降/持平 + 行动建议);
+ *       受影响范围=§4.3.1 ③④(已拍板 #13:按推荐全做,简报 P2)</li>
+ *   <li>设计:§4.3.1 ③ 月度星级简报 ——
+ *       升星:恭喜 + 新星级 + 解锁分佣份数 + 权益(微信订阅消息);
+ *       降星:提示 + 原因(滚动窗口滑出)+ 需补多少分回原级;
+ *       持平:距下一星差多少 + 本月行动建议(复用 §4.3.1 ② 换算);
+ *       数据源:{@code team_star_snapshot} 本月 vs 上月对比(§7.6 表 + {@code uk_user_period} 唯一键)。</li>
+ *   <li>设计:§4.3.1 ④ 星级权益可见 —— 星级徽章(团队页展示)、培训/专家课优先、带教与领队资格、
+ *       年度表彰;每个星级页展示「本档权益 / 下一档权益 / 达成条件」。</li>
+ *   <li>数据:§7.6 新表 {@code team_star_snapshot}(user_id / team_id / star_level 1-6 / score /
+ *       direct_rev_cent / team_rev_cent / referral_consume_cent / period 'YYYYMM',
+ *       UNIQUE KEY {@code uk_user_period(user_id, period)})。</li>
+ *   <li>推送口径:每月 1 日 00:30 定时重评后触发(§4.3 / §12-T8),复用微信订阅消息模板。</li>
+ * </ul>
+ *
+ * <p><b>宿主契约(TDD-RED 锚点)</b>:{@code com.etotem.cfc.service.StarDigestService} ——
+ * 月度星级简报生成组件(本文档属能力键 {@code team.star.digest} 的新增契约,当前 src/main
+ * 未实现该组件,全库 0 引用)。本测试通过反射锚定宿主契约方法(避免直接 import 不存在类导致
+ * 测试编译失败),运行时宿主类缺失即断言失败(红),暴露「实现未落地」这一真实缺陷;
+ * 实现落地后转为验证真实行为(GREEN)。</p>
+ *
+ * <p><b>契约方法签名(实现方按此落地,约 param 个数的宽松反射匹配)</b></p>
+ * <ul>
+ *   <li>{@code int digestTrend(int currentStarLevel, int previousStarLevel)}
+ *       —— 升/降/持平分类:+1=升星 / 0=持平 / -1=降星(§4.3.1 ③);previous=0(首期无上月快照)
+ *       按持平处理,不虚报升/降</li>
+ *   <li>{@code String digestMonthly(long userId, String period)}
+ *       —— 月度简报正文入口(本月 vs 上月 {@code team_star_snapshot} 对比后按三态出稿,§12-T8)</li>
+ *   <li>{@code String upgradeMessage(long userId, int newStarLevel)}
+ *       —— 升星稿件:恭喜 + 新星级 + 解锁分佣份数(每层按星级拿份数 = 新星级份)+ 星级权益</li>
+ *   <li>{@code String downgradeMessage(long userId, int oldStarLevel, int currentStarLevel, int currentScore)}
+ *       —— 降星稿件:提示 + 原因(滚动窗口滑出)+ 需补多少分回原级</li>
+ *   <li>{@code String flatMessage(long userId, int currentStarLevel, int currentScore)}
+ *       —— 持平稿件:距下一档差多少 + 本月行动建议(复用②换算);六星(顶流)无下一档不再虚构差距</li>
+ *   <li>{@code List<String> starBenefits(int starLevel)}
+ *       —— 该星级的可感知权益清单(星级徽章 / 培训与专家课优先 / 带教与领队资格 / 年度表彰,§4.3.1 ④),
+ *       高星权益只多不少</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯契约级单测(JUnit + 反射 / 读 schema.sql),无 Spring / 无 DB / 无真实微信,
+ * 无外部服务依赖。</p>
+ *
+ * <p><b>权限流</b>:简报为<b>服务端主动推送</b>(每月 1 日重评后发微信订阅消息),
+ * 收件人即成员本人,无用户鉴权分支,权限流不适用(说明性用例标注)。</p>
+ *
+ * <p><b>RED 预期</b>:宿主 {@code StarDigestService} 未实现、{@code team_star_snapshot} 表
+ * 未落地 schema.sql —— 行为与结构类用例在反射/断言处失败(红),断言原文含「[team.star.digest]」前缀,
+ * 便于贴日志回归。</p>
+ */
+class TeamStarDigestContractTest {
+
+    /** 规格设计宿主类(TDD-RED 契约锚点,当前未实现)。 */
+    private static final String HOST_CLASS = "com.etotem.cfc.service.StarDigestService";
+
+    /** §7.6 月度星级快照表名。 */
+    private static final String SNAPSHOT_TABLE = "team_star_snapshot";
+
+    /** §7.6 唯一键(user_id, period):本月 vs 上月对比基础。 */
+    private static final String SNAPSHOT_UNIQUE_KEY = "uk_user_period";
+
+    /** 契约测试用户(仅作占位入参)。 */
+    private static final long USER_ID = 88L;
+
+    // ==================== 正常流:升/降/持平 三态分类(§4.3.1 ③) ====================
+
+    @Test
+    void 正常流1_宿主StarDigestService_应存在() {
+        assertNotNull(loadClass(HOST_CLASS),
+                "[team.star.digest] StarDigestService 类不存在:契约要求月度星级简报组件"
+                        + "(每月 1 日重评后发微信订阅消息:升/降/持平 + 行动建议,§12-T8 / §4.3.1 ③④)。实现未落地。");
+    }
+
+    @Test
+    void 正常流2_升星_趋势分类为升() {
+        Object host = contractHost();
+        assertEquals(1, callInt(host, "digestTrend", 3, 2),
+                "[team.star.digest] 当月 3 星 vs 上月 2 星应分类为升星(+1);升星简报:恭喜 + 新星级 + 解锁份数 + 权益(§4.3.1 ③)");
+        assertEquals(1, callInt(host, "digestTrend", 6, 5),
+                "[team.star.digest] 6星 vs 5星 应分类为升星(+1)");
+    }
+
+    @Test
+    void 正常流3_降星_趋势分类为降() {
+        Object host = contractHost();
+        assertEquals(-1, callInt(host, "digestTrend", 2, 3),
+                "[team.star.digest] 当月 2 星 vs 上月 3 星应分类为降星(-1);降星简报:提示 + 原因(滚动窗口滑出)+ 补分回原级(§4.3.1 ③)");
+        assertEquals(-1, callInt(host, "digestTrend", 4, 6),
+                "[team.star.digest] 6星降至4星 应分类为降星(-1)");
+    }
+
+    @Test
+    void 正常流4_持平_趋势分类为持平() {
+        Object host = contractHost();
+        assertEquals(0, callInt(host, "digestTrend", 3, 3),
+                "[team.star.digest] 当月与上月同星应分类为持平(0);持平简报:距下一星差多少 + 本月行动建议(§4.3.1 ③)");
+    }
+
+    // ==================== 正常流:三态简报稿件(§4.3.1 ③) ====================
+
+    @Test
+    void 正常流5_升星简报_含恭喜新星级解锁份数与权益() {
+        Object host = contractHost();
+        String msg = callString(host, "upgradeMessage", USER_ID, 4);
+        assertNotNull(msg, "[team.star.digest] 升星简报不能为空");
+        assertTrue(msg.contains("恭喜"),
+                "[team.star.digest] 升星简报应含「恭喜」(§4.3.1 ③ 升星:恭喜 + 新星级 + 解锁分佣份数 + 权益)");
+        assertTrue(msg.contains("4") && msg.contains("星"),
+                "[team.star.digest] 升星简报应含新星级(4星)");
+        assertTrue(msg.contains("份"),
+                "[team.star.digest] 升星简报应含解锁分佣份数(每层按星级拿份数:4★=4份,§4.3 八份制)");
+        assertTrue(msg.contains("权益"),
+                "[team.star.digest] 升星简报应含星级权益(§4.3.1 ④ 权益可见)");
+    }
+
+    @Test
+    void 正常流6_降星简报_含滚动窗口滑出原因与补分回原级() {
+        Object host = contractHost();
+        String msg = callString(host, "downgradeMessage", USER_ID, 4, 2, 50000);
+        assertNotNull(msg, "[team.star.digest] 降星简报不能为空");
+        assertTrue(msg.contains("滑出"),
+                "[team.star.digest] 降星简报应含原因「滚动窗口滑出」(§4.3.1 ③ 降星:原因(滚动窗口滑出))");
+        assertTrue(msg.contains("补"),
+                "[team.star.digest] 降星简报应提示需补多少分回原级(§4.3.1 ③)");
+        assertFalse(msg.contains("恭喜"),
+                "[team.star.digest] 降星简报不应误报升星");
+    }
+
+    @Test
+    void 正常流7_持平简报_含距下一档差距与本月行动建议() {
+        Object host = contractHost();
+        String msg = callString(host, "flatMessage", USER_ID, 3, 72000);
+        assertNotNull(msg, "[team.star.digest] 持平简报不能为空");
+        assertTrue(msg.contains("距"),
+                "[team.star.digest] 持平简报应含距下一档差距(§4.3.1 ③ 持平:距下一星差多少)");
+        assertTrue(msg.contains("行动"),
+                "[team.star.digest] 持平简报应含本月行动建议(§4.3.1 ③,复用②差距→行动换算)");
+    }
+
+    // ==================== 正常流:星级权益可见(§4.3.1 ④) ====================
+
+    @Test
+    void 正常流8_各星级权益清单_非空且含星级徽章() {
+        Object host = contractHost();
+        for (int level = 1; level <= 6; level++) {
+            List<String> benefits = callStringList(host, "starBenefits", level);
+            assertNotNull(benefits,
+                    "[team.star.digest] " + level + "★ 权益清单不能为 null(§4.3.1 ④)");
+            assertFalse(benefits.isEmpty(),
+                    "[team.star.digest] " + level + "★ 权益清单不能为空:星级必须绑定可感知权益");
+            assertTrue(benefits.contains("星级徽章"),
+                    "[team.star.digest] " + level + "★ 权益应含「星级徽章」(团队页展示,§4.3.1 ④)");
+        }
+    }
+
+    @Test
+    void 正常流9_高星权益_只多不少() {
+        Object host = contractHost();
+        List<String> low = callStringList(host, "starBenefits", 1);
+        List<String> high = callStringList(host, "starBenefits", 6);
+        assertTrue(high.size() >= low.size(),
+                "[team.star.digest] 权益随星级增长只多不少:6★ 权益数(" + high.size()
+                        + ") 应 ≥ 1★ 权益数(" + low.size() + ")(星级「有用」,§4.3.1 ④)");
+    }
+
+    // ==================== 边界流 ====================
+
+    @Test
+    void 边界流1_六星顶格_持平简报不再虚构下一档差距() {
+        Object host = contractHost();
+        assertEquals(0, callInt(host, "digestTrend", 6, 6), "6★ 对 6★ 应为持平");
+        String topMsg = callString(host, "flatMessage", USER_ID, 6, 250000);
+        assertFalse(topMsg.contains("下一星"),
+                "[team.star.digest] 六星(顶流团队长)已顶格,持平简报不应虚构「下一星」差距(§4.3 六星=最高档)");
+    }
+
+    @Test
+    void 边界流2_首期无上月快照_按持平处理不虚报升降() {
+        Object host = contractHost();
+        assertEquals(0, callInt(host, "digestTrend", 3, 0),
+                "[team.star.digest] 首期无上月快照(previous=0)应无升降结论(持平),不虚报升/降"
+                        + "(数据源=team_star_snapshot 本月 vs 上月对比,§4.3.1 ③)");
+    }
+
+    // ==================== 结构/数据契约(§12-T8 触发与 §7.6 表) ====================
+
+    @Test
+    void 结构契约_月度简报入口digestMonthly_应存在() {
+        Object host = contractHost();
+        boolean hasEntry = false;
+        for (Method m : host.getClass().getDeclaredMethods()) {
+            if (m.getName().equals("digestMonthly") && m.getParameterCount() == 2) {
+                hasEntry = true;
+                break;
+            }
+        }
+        assertTrue(hasEntry,
+                "[team.star.digest] StarDigestService 应提供 digestMonthly(long userId, String period) 简报入口"
+                        + "(每月 1 日重评后触发推送,本月 vs 上月 team_star_snapshot 对比,§12-T8)。");
+    }
+
+    @Test
+    void 数据契约_team_star_snapshot表与uk_user_period_应落地schemaSql() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains(SNAPSHOT_TABLE),
+                "[team.star.digest] schema.sql 缺少 " + SNAPSHOT_TABLE + " 建表(§7.6 月度星级快照,"
+                        + "简报数据源:本月 vs 上月对比)。");
+        assertTrue(schema.contains(SNAPSHOT_UNIQUE_KEY),
+                "[team.star.digest] " + SNAPSHOT_TABLE + " 应含 " + SNAPSHOT_UNIQUE_KEY + " 唯一键"
+                        + "(user_id, period),供月度简报取本月/上月对比(§7.6)。");
+        assertTrue(schema.contains("star_level"),
+                "[team.star.digest] " + SNAPSHOT_TABLE + " 应含 star_level 列(1-6,趋势分类依据,§7.6)。");
+    }
+
+    // ==================== 权限流(不适用说明) ====================
+
+    @Test
+    void 权限流_服务端主动推送_无用户权限维度() {
+        // 说明性用例:月度星级简报为服务端定时主动推送(每月 1 日重评后触发),收件人即成员本人,
+        // 契约 AC 无用户鉴权分支,权限流不适用;不产生断言失败,仅为三流覆盖标注「不适用」。
+        assertTrue(true, "权限流不适用:team.star.digest 为服务端主动推送,无用户权限维度。");
+    }
+
+    // ==================== 反射辅助(锚定宿主契约,避免编译期依赖不存在类) ====================
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private Object contractHost() {
+        try {
+            Class<?> clazz = Class.forName(HOST_CLASS);
+            return clazz.getDeclaredConstructor().newInstance();
+        } catch (ClassNotFoundException e) {
+            fail("RED[team.star.digest] 契约宿主 StarDigestService 未实现(实现未落地):" + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        } catch (Exception e) {
+            fail("契约宿主 StarDigestService 实例化失败:" + 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[team.star.digest] 契约方法 " + 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 String callString(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof String) {
+            return (String) r;
+        }
+        fail("契约方法 " + method + " 应返回字符串,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<String> callStringList(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof List) {
+            return (List<String>) r;
+        }
+        fail("契约方法 " + method + " 应返回 List,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}