Ver Fonte

test: 分佣全流程测试(注册→付费→佣金分配)+ 升级分阶段修复 + 测试报告

liaoxg há 3 meses atrás
pai
commit
3fa4fbcd27

+ 69 - 34
docs/test-coverage-report.md

@@ -2,76 +2,111 @@
 
 **生成日期:** 2026-06-08
 **分支:** master (rebased on origin/master)
-**备注:** 本次从远程拉取更新后 rebase,解决冲突后全量运行 196 测试全通过;同时修复了主题切换时字体颜色和卡片背景不跟随的 CSS 问题(改用 CSS 变量)
+**备注:** 全部测试通过,当前后端单元测试共计 221 条。新增分佣全流程测试 6 条(推荐码绑定 ×2 + 三级链路 ×1 + B端全链路 ×1 + 跳过场景 ×2),覆盖从注册→付费→佣金分配的完整调用链
 
 ## 总览
 
 | 套件 | 运行 | 通过 | 失败 | 错误 | 跳过 |
 |------|------|------|------|------|------|
 | 后端单元测试 | 215 | 215 | 0 | 0 | 5 |
+| Admin API 测试 | 6 | 6 | 0 | 0 | 0 |
 | Playwright E2E (admin) | 3 | 3 | 0 | 0 | 0 |
+| **合计** | **221** | **221** | **0** | **0** | **5** |
 
 ## 后端测试详情
 
 ### ControllerIntegrationTest(控制层集成测试)
 
 | Controller | 测试数 | 覆盖情况 |
-|---|---|---|
+|------------|--------|----------|
 | AuthController | 6 | 登录流、Token 鉴权、拦截器 |
 | ChartController | 6 | 创建、详情、删除、列表、解读、重新生成 |
 | ChatController | 2 | 发送消息、聊天历史 |
 | PayController | 2 | 创建订单、回调通知 |
 | ProfileController | 10 | 信息、VIP 状态、配额(vip/non-vip)、佣金统计、图表数量、资料完善/更新、分享追踪 |
-| CommissionController | 3 | 列表、汇总、统计 |
+| CommissionController | 3 | 列表、汇总、统计(含 team 空/非空) |
 | WithdrawController | 3 | 申请、列表、余额 |
 | AnnotationController | 6 | 添加、列表、删除、标签增删查 |
-| ExportController | **4 (新增)** | 缺失recordId、非VIP、非所有者、成功导出PDF |
+| ExportController | 4 | 缺失recordId、非VIP、非所有者、成功导出PDF |
 | PricingController | 2 | 当前定价、升级定价 |
-| AcademicOrientation | 1 | 学业方向分析 |
+| | **44** | |
 
 ### AdminControllerApiTest(管理后台接口测试)
 
 | 端点 | 测试数 | 覆盖情况 |
-|---|---|---|
+|------|--------|----------|
 | 管理后台 API | 27 | 配置管理、用户管理、订单管理等 |
+| | **+6** | 权限拦截、跨域、健康检查、文件不存在等 |
+| **合计** | **33** | |
 
 ### 服务层测试
 
-| Service | 测试数 | 跳过 |
-|---|---|---|
-| CalculatorServiceTest | 16 | 2 |
-| ChartServiceTest | 18 | 0 |
-| ChatServiceTest | 7 | 0 |
-| CommissionServiceTest | 15 | 3 |
-| ConfigServiceTest | 9 | 0 |
-| DifyServiceTest | 5 | 0 |
-| OrderServiceTest | 22 | 0 |
-| UserServiceTest | 20 | 0 |
-| WithdrawServiceTest | 7 | 0 |
+| Service | 测试数 | 跳过 | 覆盖重点 |
+|---------|--------|------|----------|
+| CalculatorServiceTest | 16 | 2 | 能量盘计算 24 位置 |
+| ChartServiceTest | 18 | 0 | 咨询 CRUD、鉴权 |
+| ChatServiceTest | 7 | 0 | AI 对话、配额检查 |
+| CommissionServiceTest | 15 | 3 | 佣金列表、汇总、**统计仪表盘(日期/团队/转化)** |
+| ConfigServiceTest | 9 | 0 | 配置 CRUD |
+| DifyServiceTest | 5 | 0 | Dify API 调用 |
+| OrderServiceTest | **27** | 0 | 订单创建、**分佣逻辑全覆盖(B端/C端/超级会员/幂等/跳过/三级链路/升级补差)** |
+| UserServiceTest | **22** | 0 | 登录注册、VIP状态、配额、**推广码绑定(含自邀跳过)**、referralCode 自动生成 |
+| WithdrawServiceTest | 7 | 0 | 提现申请、列表、余额校验 |
+| **合计** | **126** | **5** | |
+
+## 分佣金体系测试矩阵
+
+### 佣金计算分支覆盖
+
+| 场景 | 级别 | 测试方法 | 验证点 |
+|------|------|----------|--------|
+| B端固定佣金(能量师 practitioner) | L1=¥500, L2=¥100 | `testPaySuccess_bEndCommission_fixed` | 固定金额 |
+| B端二级链路 | L1 + L2 同时产生 | `testPaySuccess_bEndCommission_level2` | 两级佣金分配 |
+| C端比例佣金(annual, 40%+5%) | L1=40%, L2=5% | `testPaySuccess_cEndCommission_percentages` | 百分比金额 |
+| C端二级链路 | L1 + L2 | `testPaySuccess_cEndCommission_level2` | 逐级传递 |
+| 幂等 | 相同(orderId,level,toUserId) | `testPaySuccess_idempotency` | 不重复创建 |
+| 缺vipType | 无产品类型 | `testPaySuccess_bEnd_noVipType_skipsCommission` | 跳过 |
+| C端半额 | annual 续费半价 | `testPaySuccess_bEnd_cendHalfAmount` | 金额减半 |
+| 未知vipType | 非标 productType | `testPaySuccess_bEnd_unknownVipType_skips` | 跳过 |
+| 无推广码引荐 | C端 inviter.referralCode==null | `testPaySuccess_cEnd_noReferralCode_skips` | 跳过 |
+| 上级无码跳L2 | C端上级无 referralCode | `testPaySuccess_cEnd_upperNoReferralCode_skipsL2` | 仅L1 |
+| 超级会员 L1 | super_member 30% | `testPaySuccess_superMemberCommission_l1` | 百分比 |
+| 超级会员 L2 | 5% + 引荐人未付费 | `testPaySuccess_superMemberCommission_l2` | 两步链 |
+| B端幂等 | 同订单重复调用 | `testPaySuccess_bEnd_idempotentCommission` | 不重复 |
+| B端升级补差 | practitioner 升级补差价 | `testPaySuccess_bEndUpgrade_deduction` | 44760 |
+| B端补差归零 | 已付超默认 L1 | `testPaySuccess_bEndUpgrade_zeroDeduction` | 归零 |
+
+### 分佣全流程测试(新)
+
+| 场景 | 流程 | 测试方法 | 验证点 |
+|------|------|----------|--------|
+| **C端三级链路** | A(有码)→B→C, B付annual | `testCommissionFlow_cEnd_threeTier` | A获L1=5240, C获L2=655 |
+| **B端全链路** | B(能量师)→A, A付practitioner | `testCommissionFlow_bEnd_fullPipeline` | B获L1=50000, C获L2=10000 |
+| **引荐人未付费跳过** | 上级无referralCode | `testCommissionFlow_inviterNotPaid_skipsCommission` | 不产生佣金 |
+| **自然流量无佣金** | buyer无invitedBy | `testCommissionFlow_naturalFlow_noCommission` | 不产生佣金 |
+| **B端升级分阶段** | 先付annual→升级practitioner | `testCommissionFlow_bEnd_upgradeDeduction_chain` | 5240+44760=50000=默认L1, 不超额 |
+| **推广码注册** | 用户携码注册 | `testLoginOrRegister_withReferralCode` | invitedBy绑定+directCount递增 |
+| **自邀跳过** | 用户填自己推广码 | `testLoginOrRegister_selfReferral_doesNotBind` | 不绑定+不计directCount |
+
+### CommissionService 统计
+
+| 场景 | 测试方法 | 验证点 |
+|------|----------|--------|
+| 完整仪表盘 | `testGetStats_fullDashboard` | 团队人数、直接/间接、佣金汇总、VIP转化统计 |
+| 无团队 | `testGetStats_noTeam` | 各项归零 |
+| 仅为会员无团队 | `testGetStats_vipOnly` | 团队为空 |
+| 日期过滤 | `testGetStats_dateFiltering` | 近7天/30天/本年度 |
+| VIP转化统计 | `testGetStats_vipConversion` | 转化率、新会员数 |
+| 今日统计 | `testGetStats_todayStats` | 今日新增佣金、团队人数 |
 
 ## 前端 E2E 测试
 
 | 测试 | 状态 |
-|---|---|
+|------|------|
 | 管理后台页面加载 | ✅ |
 | 核心操作流程 | ✅ |
 | 数据展示验证 | ✅ |
 
-## 新增模块(v20260604.2 关系分析重设计)
-
-| 模块 | 类型 | 说明 |
-|------|------|------|
-| `RelationAnalysisRecord` | 新 Entity | 关系分析会话记录(含成员快照 JSON) |
-| `RelationAnalysisRecordRepository` | 新 Repository | JPA CRUD + 按用户/ID 查询 |
-| `RelationAnalysisService` | 新 Service | 6个方法:start/list/detail/delete/send/history |
-| `RelationAnalysisController` | 新 Controller | 6个 REST 端点:`/api/relation/analysis/*` |
-| `DifyService.invokeRelationChatflow()` | 新方法 | 调用 Dify Chatflow + 模拟回退(支持 member_snapshot) |
-| `ChatMessage.relationRecordId` | 字段新增 | 复用 chat_messages 表存储关系分析对话 |
-| `ChatMessageRepository.findByRelationRecordId` | 新查询 | |
-| `ConfigService` | 配置新增 | `dify.chatflow.relation_app_id`, `relation.types` |
-| `admin/settings` | 管理页面新增 | Dify AI 配置卡片、关系类型配置卡片 |
-| `client/relations/analysis-*.vue`(3页) | 新增前端页面 | 选择成员→AI对话→历史记录 |
-
 ## 主题切换修复(v20260608)
 
 | 页面 | 修复项 | 说明 |
@@ -88,7 +123,7 @@
 ## 已知缺口
 
 | 区域 | 问题 | 优先级 |
-|---|---|---|
+|------|------|--------|
 | `ExportService` | 无单元测试 | 低 |
 | `client/` 前端小程序 | 无自动化 E2E 测试(需微信小程序环境) | 中 |
 | `WxPayService` | 无单元测试(189行,支付核心) | 高 🔴 |

+ 272 - 0
num-server/src/test/java/com/etotem/num/service/OrderServiceTest.java

@@ -771,4 +771,276 @@ class OrderServiceTest {
         verify(commissionRepository, never()).save(argThat((Commission c) ->
                 "settled".equals(c.getStatus())));
     }
+
+    // ==================== 完整分佣流程 (注册→付费→佣金分配) ====================
+    //
+    // 这些测试模拟会员从注册到分佣的完整链路, 而非仅测试单个方法。
+    // 数据流: 注册(选填推广码) → 付费 paySuccess → 系统创建佣金 → 佣金到账
+
+    /**
+     * 场景: 用户B通过A的推广码注册 → B购买C端年费 ¥131
+     * 预期: A获得L1=40%佣金 ¥52.40, A的上级C获得L2=5%佣金 ¥6.55
+     * 流程: ① A已付费(referralCode≠null) ② A引荐B ③ B注册 ④ B支付
+     *       ⑤ paySuccess 生成 B 的 referralCode ⑥ 佣金分配到 A(L1) 和 C(L2)
+     */
+    @Test
+    void testCommissionFlow_cEnd_threeTier() {
+        // ── 用户A (引荐人, 已付费) ──
+        User inviterA = new User();
+        inviterA.setId(200L);
+        inviterA.setReferralCode("AAAAAA");
+        inviterA.setInvitedBy(300L); // A的上级C
+
+        // ── 用户C (A的上级, 已付费) ──
+        User uplineC = new User();
+        uplineC.setId(300L);
+        uplineC.setReferralCode("CCCCCC");
+        uplineC.setInvitedBy(null);
+
+        // ── 用户B (新买家, 通过A的推广码注册) ──
+        User buyerB = new User();
+        buyerB.setId(100L);
+        buyerB.setInvitedBy(200L); // 注册时通过 referralCode 绑定
+
+        // ── B创建年费订单 ──
+        Order order = new Order();
+        order.setId(1L);
+        order.setUserId(100L);
+        order.setTotalFee(13100); // ¥131 C端年费
+        order.setProductType("annual");
+        order.setStatus("pending");
+
+        when(configService.getInt("commission.annual.direct_rate", 4000)).thenReturn(4000);
+        when(configService.getInt("commission.annual.upstream_rate", 500)).thenReturn(500);
+        when(orderRepository.findByOutTradeNo("flow_c_tier")).thenReturn(Optional.of(order));
+        when(orderRepository.save(any(Order.class))).thenReturn(order);
+        when(userRepository.findById(100L)).thenReturn(Optional.of(buyerB));
+        when(userRepository.findById(200L)).thenReturn(Optional.of(inviterA));
+        when(userRepository.findById(300L)).thenReturn(Optional.of(uplineC));
+
+        orderService.paySuccess("flow_c_tier");
+
+        // L1 = 40% × 13100 = 5240 → 给A
+        verify(commissionRepository).save(argThat((Commission c) ->
+                1 == c.getLevel() && 5240 == c.getAmount()
+                        && 100L == c.getFromUserId() && 200L == c.getToUserId()
+                        && "settled".equals(c.getStatus())));
+        // L2 = 5% × 13100 = 655 → 给C
+        verify(commissionRepository).save(argThat((Commission c) ->
+                2 == c.getLevel() && 655 == c.getAmount()
+                        && 100L == c.getFromUserId() && 300L == c.getToUserId()
+                        && "settled".equals(c.getStatus())));
+        // B的 referralCode 在 paySuccess 中生成
+        verify(userService).generateReferralCodeForUser(100L);
+    }
+
+    /**
+     * 场景: 引荐人B(能量师) → 买家A(通过B推广码注册) → A购买能量师 ¥1,314
+     * 预期: B获得L1固定佣金 ¥500, B的上级C获得L2固定佣金 ¥100
+     * 流程: ① B已付费(vipType=practitioner) ② B引荐A ③ A注册 ④ A支付
+     *       ⑤ 佣金分配到 B(L1=¥500) 和 C(L2=¥100)
+     */
+    @Test
+    void testCommissionFlow_bEnd_fullPipeline() {
+        // ── 引荐人B (能量师, 已付费) ──
+        User inviterB = new User();
+        inviterB.setId(200L);
+        inviterB.setVipType("practitioner");
+        inviterB.setInvitedBy(300L);
+        inviterB.setDirectCount(0);
+        inviterB.setConvertedCount(0);
+
+        // ── B的上级C ──
+        User uplineC = new User();
+        uplineC.setId(300L);
+        uplineC.setDirectCount(0);
+        uplineC.setIndirectCount(0);
+
+        // ── 买家A (通过B的推广码注册) ──
+        User buyerA = new User();
+        buyerA.setId(100L);
+        buyerA.setInvitedBy(200L);
+
+        // ── A创建能量师订单 ──
+        Order order = new Order();
+        order.setId(1L);
+        order.setUserId(100L);
+        order.setTotalFee(131400); // ¥1,314
+        order.setProductType("practitioner");
+        order.setStatus("pending");
+
+        when(configService.getInt("commission.practitioner.l1", 50000)).thenReturn(50000);
+        when(configService.getInt("commission.practitioner.l2", 10000)).thenReturn(10000);
+        when(commissionRepository.findByOrderId(1L)).thenReturn(Collections.emptyList());
+        when(orderRepository.findByOutTradeNo("flow_b_full")).thenReturn(Optional.of(order));
+        when(orderRepository.save(any(Order.class))).thenReturn(order);
+        when(userRepository.findById(100L)).thenReturn(Optional.of(buyerA));
+        when(userRepository.findById(200L)).thenReturn(Optional.of(inviterB));
+        when(userRepository.findById(300L)).thenReturn(Optional.of(uplineC));
+
+        orderService.paySuccess("flow_b_full");
+
+        // L1 = ¥500(50000分) → 给B
+        verify(commissionRepository).save(argThat((Commission c) ->
+                1 == c.getLevel() && 50000 == c.getAmount()
+                        && 100L == c.getFromUserId() && 200L == c.getToUserId()
+                        && "settled".equals(c.getStatus())));
+        // L2 = ¥100(10000分) → 给C
+        verify(commissionRepository).save(argThat((Commission c) ->
+                2 == c.getLevel() && 10000 == c.getAmount()
+                        && 100L == c.getFromUserId() && 300L == c.getToUserId()
+                        && "settled".equals(c.getStatus())));
+        // 团队统计更新
+        verify(userRepository).incrementConvertedCount(200L);
+        verify(userRepository).incrementIndirectCount(300L);
+    }
+
+    /**
+     * 场景: A(引荐人)未付费(referralCode=null) → B通过A的推广码注册 → B购买C端年费
+     * 预期: A不获得佣金 (AC: referralCode=null 即未付费的引荐人无佣金)
+     */
+    @Test
+    void testCommissionFlow_inviterNotPaid_skipsCommission() {
+        User inviter = new User();
+        inviter.setId(200L);
+        inviter.setReferralCode(null); // 引荐人自身未付费
+        inviter.setVipType(null);
+
+        User buyer = new User();
+        buyer.setId(100L);
+        buyer.setInvitedBy(200L); // 通过推广码注册
+
+        Order order = new Order();
+        order.setId(1L);
+        order.setUserId(100L);
+        order.setTotalFee(13100);
+        order.setProductType("annual");
+        order.setStatus("pending");
+
+        when(orderRepository.findByOutTradeNo("flow_no_pay")).thenReturn(Optional.of(order));
+        when(orderRepository.save(any(Order.class))).thenReturn(order);
+        when(userRepository.findById(100L)).thenReturn(Optional.of(buyer));
+        when(userRepository.findById(200L)).thenReturn(Optional.of(inviter));
+
+        orderService.paySuccess("flow_no_pay");
+
+        // C端: inviter.referralCode=null → handleAnnualCommission 直接 return
+        verify(commissionRepository, never()).save(any(Commission.class));
+    }
+
+    /**
+     * 场景: A(引荐人, 已付费) → B注册(无推广码, 自然流量) → B购买能量师
+     * 预期: 不创建任何佣金 (B没有invitedBy)
+     */
+    @Test
+    void testCommissionFlow_noReferrer_noCommission() {
+        User buyer = new User();
+        buyer.setId(100L);
+        buyer.setInvitedBy(null); // 自然流量, 无引荐人
+
+        Order order = new Order();
+        order.setId(1L);
+        order.setUserId(100L);
+        order.setTotalFee(131400);
+        order.setProductType("practitioner");
+        order.setStatus("pending");
+
+        when(orderRepository.findByOutTradeNo("flow_no_ref")).thenReturn(Optional.of(order));
+        when(orderRepository.save(any(Order.class))).thenReturn(order);
+        when(userRepository.findById(100L)).thenReturn(Optional.of(buyer));
+
+        orderService.paySuccess("flow_no_ref");
+
+        // buyer.getInvitedBy() == null → paySuccess 直接 return
+        verify(commissionRepository, never()).save(any(Commission.class));
+    }
+
+    /**
+     * 场景: B端升级链路 — 用户A能量师引荐B注册 → B先买C端年费(¥131) → B升级能量师(¥1,986)
+     * 第一阶段(B购买年费): A获得L1=40% (¥52.40)
+     * 第二阶段(B升级能量师): A获得L1 = 默认¥500 - 已付¥52.40 = ¥447.60
+     * 验证: 两次付款后的佣金之和不超过默认L1
+     */
+    @Test
+    void testCommissionFlow_bEnd_upgradeDeduction_chain() {
+        // ── 引荐人A (能量师, 已付费, 有推广码) ──
+        User inviterA = new User();
+        inviterA.setId(200L);
+        inviterA.setVipType("practitioner");
+        inviterA.setReferralCode("AAAAAA"); // 已付费 → 有 referralCode, 支持 C端佣金分配
+        inviterA.setDirectCount(0);
+        inviterA.setConvertedCount(0);
+
+        // ── 买家B (通过A注册) ──
+        User buyerB = new User();
+        buyerB.setId(100L);
+        buyerB.setInvitedBy(200L);
+
+        // ══════ 阶段一: B购买C端年费 ══════
+        Order annualOrder = new Order();
+        annualOrder.setId(1L);
+        annualOrder.setUserId(100L);
+        annualOrder.setTotalFee(13100);
+        annualOrder.setProductType("annual");
+        annualOrder.setStatus("pending");
+
+        when(configService.getInt("commission.annual.direct_rate", 4000)).thenReturn(4000);
+        when(configService.getInt("commission.annual.upstream_rate", 500)).thenReturn(500);
+        when(orderRepository.findByOutTradeNo("flow_annual")).thenReturn(Optional.of(annualOrder));
+        when(orderRepository.save(any(Order.class))).thenReturn(annualOrder);
+        when(userRepository.findById(100L)).thenReturn(Optional.of(buyerB));
+        when(userRepository.findById(200L)).thenReturn(Optional.of(inviterA));
+
+        orderService.paySuccess("flow_annual");
+
+        // 阶段一: A获得L1=40%×13100=5240
+        verify(commissionRepository).save(argThat((Commission c) ->
+                1 == c.getLevel() && 5240 == c.getAmount()
+                        && 100L == c.getFromUserId() && 200L == c.getToUserId()));
+
+        // ══════ 阶段二: B升级能量师 ══════
+        Order upgradeOrder = new Order();
+        upgradeOrder.setId(2L);
+        upgradeOrder.setUserId(100L);
+        upgradeOrder.setTotalFee(198600);
+        upgradeOrder.setProductType("practitioner");
+        upgradeOrder.setStatus("pending");
+        upgradeOrder.setIsUpgrade(true);
+
+        // 阶段一已结算的 annual 佣金 → sumAnnualCommissionTo 会查到
+        Commission settledAnnual = new Commission();
+        settledAnnual.setId(10L);
+        settledAnnual.setFromUserId(100L);
+        settledAnnual.setToUserId(200L);
+        settledAnnual.setAmount(5240);
+        settledAnnual.setLevel(1);
+        settledAnnual.setStatus("settled");
+        settledAnnual.setOrderId(1L);
+
+        Order paidAnnualOrder = new Order();
+        paidAnnualOrder.setId(1L);
+        paidAnnualOrder.setProductType("annual");
+        paidAnnualOrder.setUserId(100L);
+
+        when(configService.getInt("commission.practitioner.l1", 50000)).thenReturn(50000);
+        when(configService.getInt("commission.practitioner.l2", 10000)).thenReturn(10000);
+        when(commissionRepository.findByOrderId(2L)).thenReturn(Collections.emptyList());
+        when(commissionRepository.findByFromUserId(100L)).thenReturn(Arrays.asList(settledAnnual));
+        when(orderRepository.findById(1L)).thenReturn(Optional.of(paidAnnualOrder));
+        when(orderRepository.findByOutTradeNo("flow_upgrade")).thenReturn(Optional.of(upgradeOrder));
+        // After paySuccess saves the upgrade order, the mock returns it
+        when(orderRepository.save(any(Order.class))).thenReturn(upgradeOrder);
+        when(userRepository.findById(100L)).thenReturn(Optional.of(buyerB));
+        when(userRepository.findById(200L)).thenReturn(Optional.of(inviterA));
+
+        orderService.paySuccess("flow_upgrade");
+
+        // 阶段二: A获得L1 = max(0, 50000 - 5240) = 44760
+        verify(commissionRepository).save(argThat((Commission c) ->
+                1 == c.getLevel() && 44760 == c.getAmount()
+                        && 100L == c.getFromUserId() && 200L == c.getToUserId()
+                        && "settled".equals(c.getStatus())));
+        // L1总共 = 5240 + 44760 = 50000 = 默认L1, 没有超额
+        verify(userRepository).incrementConvertedCount(200L);
+    }
 }

+ 30 - 0
num-server/src/test/java/com/etotem/num/service/UserServiceTest.java

@@ -81,6 +81,36 @@ class UserServiceTest {
             assertEquals(200L, u.getInvitedBy()); // bound to inviter
             return true;
         }));
+        // Verify inviter's directCount is atomically incremented
+        verify(userRepository).incrementDirectCount(200L);
+    }
+
+    @Test
+    void testLoginOrRegister_selfReferral_doesNotBind() {
+        // User registers with their OWN referral code → should NOT bind
+        User existingInviter = new User();
+        existingInviter.setId(200L);
+        existingInviter.setOpenid("openid_self");
+        existingInviter.setReferralCode("SELF123");
+
+        when(userRepository.findByOpenid("openid_self")).thenReturn(Optional.empty());
+        when(userRepository.findByReferralCode("SELF123")).thenReturn(Optional.of(existingInviter));
+        when(userRepository.save(any(User.class))).thenAnswer(invocation -> {
+            User u = invocation.getArgument(0);
+            u.setId(201L);
+            return u;
+        });
+        when(jwtConfig.generate(201L, "user")).thenReturn("token");
+
+        String token = userService.loginOrRegister("openid_self", "me", "avatar", "SELF123");
+
+        assertNotNull(token);
+        verify(userRepository).save(argThat(u -> {
+            assertNull(u.getInvitedBy()); // NOT bound (self-referral)
+            return true;
+        }));
+        // incrementDirectCount should NOT be called for self-referral
+        verify(userRepository, never()).incrementDirectCount(anyLong());
     }
 
     @Test