|
|
@@ -2,455 +2,271 @@ package com.etotem.cfc.unit;
|
|
|
|
|
|
import org.junit.jupiter.api.DisplayName;
|
|
|
import org.junit.jupiter.api.Test;
|
|
|
-import org.springframework.web.bind.annotation.PostMapping;
|
|
|
-import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
|
|
|
import java.io.InputStream;
|
|
|
import java.lang.reflect.Field;
|
|
|
import java.lang.reflect.Method;
|
|
|
import java.nio.charset.StandardCharsets;
|
|
|
-import java.util.List;
|
|
|
-import java.util.Map;
|
|
|
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.assertThrows;
|
|
|
import static org.junit.jupiter.api.Assertions.fail;
|
|
|
|
|
|
/**
|
|
|
- * 契约级单测:联系人分组/导入(能力键 {@code contact.group.import},单元 contact,S-cfc-284)。
|
|
|
+ * 契约级单测:联系人分组/导入(能力键 {@code contact.group.import},单元 contact,TDD-RED 阶段)。
|
|
|
*
|
|
|
- * <p><b>契约溯源</b></p>
|
|
|
+ * <p>契约溯源(需求/设计):</p>
|
|
|
* <ul>
|
|
|
- * <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-62(联系人分组/导入:建分组→
|
|
|
- * 导入 {@code ContactImport};边界:导入格式错误行跳过并报告);业务域 G 联系人与社交。</li>
|
|
|
- * <li>用例:{@code tests/test-cases/parent.md} TC-P-62(建分组→导入,格式错误行跳过并报告)/
|
|
|
- * TC-P-62-E(导入 CSV 含格式错误行缺电话→错误行跳过并报告『第 N 行格式错误』;非 CSV 拒绝;
|
|
|
- * 正确行入库)/ TC-P-62-P(非归属数据访问统一 403/401,横向越权被拒)。</li>
|
|
|
- * <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.63.7(联系人/社交)+ §4.63.10
|
|
|
- * <b>缺口 G-3</b>——{@code ContactController /api/contact}(list/create/update/delete/
|
|
|
- * detail/import-phone/interaction/invite-to-family)仅单条 {@code import-phone},
|
|
|
- * <b>分组无端点</b>(ContactGroupController / ContactImportController 均不存在);
|
|
|
- * 处置:后端新增 {@code POST /api/contact/group/*}(分组 CRUD + contact 分组绑定),
|
|
|
- * {@code contacts} 表加 {@code group_id} 分组字段承载。</li>
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-62(联系人分组/导入)——
|
|
|
+ * 「建分组 → 导入({@code ContactImport})」,边界「导入格式错误行跳过并报告」,模块 {@code ContactImport};
|
|
|
+ * 场景细节见 {@code tests/user-scenarios/02-parent.md} 场景 P-62(角色:家长;前置:已登录;
|
|
|
+ * 主流程:建分组 → 导入;异常/边界:导入格式错误行跳过并报告)。</li>
|
|
|
+ * <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} —— {@code ContactController /api/contact}
|
|
|
+ * (CRUD/互动/邀请入家庭)+ {@code ContactMatchController /api/contact/match}(联系人对接);
|
|
|
+ * 联系人导入登记为 {@code POST /api/contact/import-phone}(SC-P-62),但
|
|
|
+ * <b>候选 {@code ContactImportController} 不存在;分组无端点 → 缺口 G-3</b>;
|
|
|
+ * 契约缺口表标注「导入已存在(/api/contact/import-phone);分组无端点」。</li>
|
|
|
* </ul>
|
|
|
*
|
|
|
- * <p><b>宿主契约(TDD-RED 锚点)</b>:SC-P-62「建分组→导入」规划的类,当前 src/main
|
|
|
- * <b>全部未实现</b>(全库 0 引用;{@code contacts} 表无 {@code group_id} 列、schema.sql
|
|
|
- * 无 {@code contact_groups} 建表)。</p>
|
|
|
- *
|
|
|
- * <p><b>契约方法签名(实现方按此落地)</b></p>
|
|
|
+ * <p><b>契约范围</b>(SC-P-62 主流程 + 边界):</p>
|
|
|
* <ul>
|
|
|
- * <li>{@code ContactGroup createGroup(String name, Long userId)} —— 建分组,归属 userId;</li>
|
|
|
- * <li>{@code List<ContactGroup> listGroups(Long userId)} —— 仅返回 userId 自身分组(横向隔离);</li>
|
|
|
- * <li>{@code List<String> validateCsvLines(String csvContent)} —— <b>纯校验</b>:解析 CSV 文本,
|
|
|
- * 每条格式错误行产出一条形如『第 N 行格式错误:原因』的报告;合法行不入错误报告;
|
|
|
- * 非 CSV(null/空/全空白)→ 抛 {@code IllegalArgumentException}(非 CSV 拒绝,TC-P-62-E);</li>
|
|
|
- * <li>{@code Map<String,Object> importCsv(Long groupId, Long userId, String csvContent)} ——
|
|
|
- * 分组导入:合法行入库到 groupId(userId 须为该分组属主);返回 {@code importedCount}
|
|
|
- * (成功入库行数)、{@code skippedCount}、{@code skippedRows}(逐条『第 N 行格式错误』);</li>
|
|
|
- * <li>{@code boolean isGroupOwner(Long groupId, Long userId)} —— 分组归属判定(越权门禁)。</li>
|
|
|
+ * <li><b>正常流-建分组</b>:分组能力应有实体/表载体({@code ContactGroup} 实体 + {@code contact_group} 表),
|
|
|
+ * 联系人可归属分组({@code contacts.group_id});</li>
|
|
|
+ * <li><b>正常流-导入</b>:批量导入入口应存在({@code ContactImport} 模块:{@code ContactImportController} /
|
|
|
+ * {@code ContactImportService} 或 {@code ContactController} 批量导入方法),导入时按 {@code groupId} 归组;</li>
|
|
|
+ * <li><b>边界流-格式错误行跳过并报告</b>:批量导入对格式错误行(缺姓名/手机号格式非法/重复号码)应
|
|
|
+ * <b>跳过并报告</b>——导入结果须返回 {@code successCount / skippedCount / skippedRows} 统计,不整批失败;</li>
|
|
|
+ * <li><b>权限流</b>:导入的联系人归属当前登录用户({@code userId} 隔离),不允许跨用户写入。</li>
|
|
|
* </ul>
|
|
|
*
|
|
|
- * <p><b>契约端点(controller 结构契约)</b>:{@code com.etotem.cfc.controller.ContactGroupController}
|
|
|
- * 类级 {@code @RequestMapping("/api/contact/group")},方法级 {@code @PostMapping("/create")}(建分组)、
|
|
|
- * {@code @PostMapping("/list")}(分组列表)、{@code @PostMapping("/import")}(分组导入),注入
|
|
|
- * {@code ContactGroupService}。schema.sql 落地 {@code contact_groups} 建表 + {@code contacts}
|
|
|
- * 表加 {@code group_id} 分组绑定列(缺口 G-3)。</p>
|
|
|
+ * <p><b>实现现状</b>:本分支 src/main 中 {@code ContactGroup} 实体、{@code contact_group} 表、
|
|
|
+ * {@code contacts.group_id} 列、{@code ContactImportController}/{@code ContactImportService} 批量导入
|
|
|
+ * 与「格式错误行跳过并报告」结果载体均<b>未落地</b>(全仓 0 引用,schema.sql 无 contact_group 建表)。
|
|
|
+ * 现仅 {@code ContactController#importPhone} 做<b>单条</b>电话导入(无分组、无批量、无错误行报告),
|
|
|
+ * 与 SC-P-62「建分组 → 导入,格式错误行跳过并报告」契约不符。因此本测试以「反射结构契约 + schema.sql
|
|
|
+ * 探测」验证实现缺失:能编译通过、能运行、断言失败(红),且失败断言原文清晰可贴。</p>
|
|
|
*
|
|
|
- * <p><b>测试范围</b>:纯契约级单测,仅 JUnit + 反射,无 Spring / 无 DB / 无真实微信 / 无外部服务;
|
|
|
- * 权限流适用(分组归属=横向越权维度),按三类流覆盖。</p>
|
|
|
+ * <p><b>GREEN 升级指引</b>:实现者落地 {@code ContactGroup} 实体 + {@code contact_group} 表 +
|
|
|
+ * {@code contacts.group_id} 列(或分组关联表)、{@code ContactImportController}/{@code ContactImportService}
|
|
|
+ * 批量导入入口(含 {@code groupId} 归组)、导入结果 DTO({@code successCount/skippedCount/skippedRows},
|
|
|
+ * 格式错误行跳过并报告)后,本测试自动转绿;届时可将结构探测升级为行为级 mock 验证。</p>
|
|
|
*
|
|
|
- * <p><b>RED 预期</b>:宿主 {@code ContactGroupService} / {@code ContactImportService} 未实现——
|
|
|
- * 全部宿主用例在反射探测处失败(红),断言原文「RED[contact.group.import] 契约宿主 … 未实现」;
|
|
|
- * controller 与 schema.sql 结构用例分别以「契约端点未落地」「schema.sql 缺少 contact_groups」
|
|
|
- * 独立失败。</p>
|
|
|
+ * <p><b>RED 预期</b>:契约宿主(分组实体/表、批量导入入口、跳过报告结果载体)未实现,全部用例在
|
|
|
+ * 反射/schema 探测处失败(红),断言原文为「契约宿主未实现:...」。</p>
|
|
|
*/
|
|
|
class ContactGroupImportContractTest {
|
|
|
|
|
|
- /** 契约宿主服务类(未实现)。 */
|
|
|
- private static final String HOST_GROUP_SERVICE = "com.etotem.cfc.service.ContactGroupService";
|
|
|
- private static final String HOST_IMPORT_SERVICE = "com.etotem.cfc.service.ContactImportService";
|
|
|
+ /** SC-P-62 需求锚点:ContactImport 模块批量导入宿主控制器(设计标注候选不存在)。 */
|
|
|
+ private static final String IMPORT_CONTROLLER = "com.etotem.cfc.controller.ContactImportController";
|
|
|
+
|
|
|
+ /** SC-P-62 批量导入宿主服务(ContactImport 模块)。 */
|
|
|
+ private static final String IMPORT_SERVICE = "com.etotem.cfc.service.ContactImportService";
|
|
|
|
|
|
- /** 契约实体与 Mapper(未实现)。 */
|
|
|
- private static final String ENTITY_CONTACT_GROUP = "com.etotem.cfc.entity.ContactGroup";
|
|
|
- private static final String MAPPER_CONTACT_GROUP = "com.etotem.cfc.mapper.ContactGroupMapper";
|
|
|
+ /** 建分组能力:ContactGroup 实体(分组载体)。 */
|
|
|
+ private static final String GROUP_ENTITY = "com.etotem.cfc.entity.ContactGroup";
|
|
|
|
|
|
- /** 契约端点 Controller(未实现)。 */
|
|
|
- private static final String CONTROLLER_CLASS = "com.etotem.cfc.controller.ContactGroupController";
|
|
|
+ /** 建分组能力:分组 Mapper。 */
|
|
|
+ private static final String GROUP_MAPPER = "com.etotem.cfc.mapper.ContactGroupMapper";
|
|
|
|
|
|
- /** 契约端点类级路径与 schema.sql 分组承载。 */
|
|
|
- private static final String ENDPOINT_BASE = "/api/contact/group";
|
|
|
- private static final String GROUP_TABLE = "contact_groups";
|
|
|
- private static final String CONTACT_GROUP_ID_COLUMN = "group_id";
|
|
|
+ /** 导入结果载体:格式错误行跳过并报告(successCount/skippedCount/skippedRows)。 */
|
|
|
+ private static final String IMPORT_RESULT_VO = "com.etotem.cfc.dto.ContactImportResultVO";
|
|
|
+
|
|
|
+ /** 分组表名(SC-P-62 建分组)。 */
|
|
|
+ private static final String GROUP_TABLE = "contact_group";
|
|
|
|
|
|
// ------------------------------------------------------------------
|
|
|
- // 测试用 CSV 文本(TC-P-62-E:合法行入库、格式错误行跳过并报告『第 N 行格式错误』)
|
|
|
+ // 正常流-建分组:ContactGroup 实体 + contact_group 表 + contacts.group_id
|
|
|
// ------------------------------------------------------------------
|
|
|
- /** 全合法 CSV:姓名,电话。 */
|
|
|
- private static final String CSV_ALL_VALID = "张三,13800138000\n李四,13900139000\n王五,13700137000";
|
|
|
- /** 含格式错误行(第 2 行缺电话)。 */
|
|
|
- private static final String CSV_MISSING_PHONE = "张三,13800138000\n李四\n王五,13900139000";
|
|
|
- /** 混合:第 2/3 行缺电话(格式错误),第 1/4 行合法。 */
|
|
|
- private static final String CSV_MIXED = "张三,13800138000\n李四\n王五\n赵六,13600136000";
|
|
|
- /** 重复号码(第 2 行与第 1 行号码相同)。 */
|
|
|
- private static final String CSV_DUPLICATE_PHONE = "张三,13800138000\n李四,13800138000";
|
|
|
- /** 非 CSV:无分隔结构。 */
|
|
|
- private static final String CSV_NON_CSV = "this is not a csv content";
|
|
|
-
|
|
|
- // ==================== 正常流:建分组(分组 CRUD 宿主) ====================
|
|
|
|
|
|
@Test
|
|
|
- @DisplayName("正常流-1:ContactGroup 契约实体应已实现")
|
|
|
- void contactGroup_entityExists() {
|
|
|
- assertNotNull(loadClass(ENTITY_CONTACT_GROUP),
|
|
|
- "RED[contact.group.import] ContactGroup 契约实体未实现(实现未落地):"
|
|
|
- + "SC-P-62 建分组需 ContactGroup 实体(contact_groups 表)承载分组。");
|
|
|
+ @DisplayName("正常流-建分组:ContactGroup 实体应存在(分组载体)")
|
|
|
+ void groupEntity_shouldExist() {
|
|
|
+ assertNotNull(loadClass(GROUP_ENTITY),
|
|
|
+ "[contact.group.import] ContactGroup 实体不存在:SC-P-62 主流程「建分组」要求分组载体。"
|
|
|
+ + "实现未落地。");
|
|
|
}
|
|
|
|
|
|
@Test
|
|
|
- @DisplayName("正常流-2:ContactGroup 实体应含 userId/name 归属字段")
|
|
|
- void contactGroup_entityCarriesOwnerFields() {
|
|
|
- Class<?> entity = loadClass(ENTITY_CONTACT_GROUP);
|
|
|
- if (entity == null) {
|
|
|
- fail("RED[contact.group.import] ContactGroup 契约实体未实现(实现未落地):"
|
|
|
- + "分组归属字段 userId/name 无载体。");
|
|
|
- }
|
|
|
- assertTrue(hasField(entity, "userId"), "ContactGroup 应含 userId(分组归属用户)");
|
|
|
- assertTrue(hasField(entity, "name"), "ContactGroup 应含 name(分组名)");
|
|
|
+ @DisplayName("正常流-建分组:ContactGroupMapper 应存在(分组持久化)")
|
|
|
+ void groupMapper_shouldExist() {
|
|
|
+ assertNotNull(loadClass(GROUP_MAPPER),
|
|
|
+ "[contact.group.import] ContactGroupMapper 不存在:SC-P-62「建分组」需分组持久化。"
|
|
|
+ + "实现未落地。");
|
|
|
}
|
|
|
|
|
|
@Test
|
|
|
- @DisplayName("正常流-3:ContactGroupMapper 契约接口应已实现")
|
|
|
- void contactGroupMapper_interfaceExists() {
|
|
|
- Class<?> mapper = loadClass(MAPPER_CONTACT_GROUP);
|
|
|
- assertNotNull(mapper,
|
|
|
- "RED[contact.group.import] ContactGroupMapper 契约接口未实现(实现未落地):"
|
|
|
- + "contact_groups 表无 Mapper 承载,分组 CRUD 无法落库。");
|
|
|
- assertTrue(mapper.isInterface(), "ContactGroupMapper 应为接口(MyBatis-Plus BaseMapper 契约)");
|
|
|
+ @DisplayName("正常流-建分组:contact_group 表应落地 schema.sql")
|
|
|
+ void schemaSql_shouldContainGroupTable() {
|
|
|
+ String schema = readSchemaSql();
|
|
|
+ assertTrue(schema.contains(GROUP_TABLE),
|
|
|
+ "[contact.group.import] schema.sql 缺少 " + GROUP_TABLE
|
|
|
+ + " 建表:SC-P-62「建分组」需要分组表(缺口 G-3)。");
|
|
|
}
|
|
|
|
|
|
@Test
|
|
|
- @DisplayName("正常流-4:ContactGroupService 契约宿主应已实现")
|
|
|
- void groupService_classExists() {
|
|
|
- assertNotNull(contractHost(HOST_GROUP_SERVICE),
|
|
|
- "RED[contact.group.import] 契约宿主 ContactGroupService 未实现(实现未落地):"
|
|
|
- + "SC-P-62 建分组→导入 服务未落地,分组 CRUD 契约无法满足。");
|
|
|
+ @DisplayName("正常流-建分组:contacts 表应含 group_id 列(联系人归组)")
|
|
|
+ void contacts_shouldCarryGroupIdColumn() {
|
|
|
+ String schema = readSchemaSql();
|
|
|
+ int contactsIdx = schema.indexOf("CREATE TABLE IF NOT EXISTS contacts");
|
|
|
+ if (contactsIdx < 0) {
|
|
|
+ fail("[contact.group.import] schema.sql 缺少 contacts 建表。");
|
|
|
+ }
|
|
|
+ // 只扫 contacts 建表块:避免其他表恰有 group_id 列造成误判
|
|
|
+ int blockEnd = schema.indexOf("ENGINE=InnoDB", contactsIdx);
|
|
|
+ String contactsBlock = blockEnd > contactsIdx ? schema.substring(contactsIdx, blockEnd) : schema.substring(contactsIdx);
|
|
|
+ assertTrue(contactsBlock.contains("group_id"),
|
|
|
+ "[contact.group.import] contacts 表缺少 group_id 列:导入到分组需将联系人归属 groupId"
|
|
|
+ + "(缺口 G-3)。");
|
|
|
}
|
|
|
|
|
|
- @Test
|
|
|
- @DisplayName("正常流-5:ContactGroupService.createGroup 建分组契约方法应存在")
|
|
|
- void groupService_createGroup_methodExists() {
|
|
|
- assertMethodExists(HOST_GROUP_SERVICE, "createGroup", 2,
|
|
|
- "SC-P-62 建分组:createGroup(name, userId) 应建分组并归属 userId。");
|
|
|
- }
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 正常流-导入:ContactImport 模块批量导入入口(含 groupId 归组)
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
|
@Test
|
|
|
- @DisplayName("正常流-6:ContactGroupService.listGroups 分组列表契约方法应存在(仅本人)")
|
|
|
- void groupService_listGroups_methodExists() {
|
|
|
- assertMethodExists(HOST_GROUP_SERVICE, "listGroups", 1,
|
|
|
- "SC-P-62 分组列表:listGroups(userId) 应仅返回 userId 自身分组(横向隔离)。");
|
|
|
+ @DisplayName("正常流-导入:ContactImportController 应存在(批量导入入口)")
|
|
|
+ void importController_shouldExist() {
|
|
|
+ assertNotNull(loadClass(IMPORT_CONTROLLER),
|
|
|
+ "[contact.group.import] ContactImportController 不存在:SC-P-62「导入(ContactImport)」"
|
|
|
+ + "要求批量导入入口(设计标注候选 ContactImportController 不存在,缺口 G-3)。");
|
|
|
}
|
|
|
|
|
|
- // ==================== 边界流:导入校验(TC-P-62-E,纯函数) ====================
|
|
|
-
|
|
|
@Test
|
|
|
- @DisplayName("边界流-7:ContactImportService 契约宿主应已实现")
|
|
|
- void importService_classExists() {
|
|
|
- assertNotNull(contractHost(HOST_IMPORT_SERVICE),
|
|
|
- "RED[contact.group.import] 契约宿主 ContactImportService 未实现(实现未落地):"
|
|
|
- + "SC-P-62 导入 ContactImport 服务未落地,分组导入契约无法满足。");
|
|
|
+ @DisplayName("正常流-导入:ContactImportService 应存在(批量导入业务)")
|
|
|
+ void importService_shouldExist() {
|
|
|
+ assertNotNull(loadClass(IMPORT_SERVICE),
|
|
|
+ "[contact.group.import] ContactImportService 不存在:SC-P-62「导入」需批量导入业务逻辑"
|
|
|
+ + "(建分组 → 导入)。实现未落地。");
|
|
|
}
|
|
|
|
|
|
@Test
|
|
|
- @DisplayName("边界流-8:validateCsvLines 格式错误行(缺电话)→ 报告『第 N 行格式错误』")
|
|
|
- void validateCsvLines_missingPhone_reportsFormatError() {
|
|
|
- Object host = contractHost(HOST_IMPORT_SERVICE);
|
|
|
- List<String> errors = callList(host, "validateCsvLines", CSV_MISSING_PHONE);
|
|
|
- assertNotNull(errors, "validateCsvLines 不得返回 null(逐条格式错误行报告)");
|
|
|
- assertFalse(errors.isEmpty(), "缺电话格式错误行应产出报告,不得静默跳过");
|
|
|
- boolean reported = false;
|
|
|
- for (String msg : errors) {
|
|
|
- if (msg != null && msg.contains("第 2 行") && msg.contains("格式错误")) {
|
|
|
- reported = true;
|
|
|
+ @DisplayName("正常流-导入:ContactController 应暴露带 groupId 的分组导入端点")
|
|
|
+ void contactController_shouldExposeGroupImportMapping() {
|
|
|
+ Class<?> clazz = loadClass("com.etotem.cfc.controller.ContactController");
|
|
|
+ assertNotNull(clazz, "[contact.group.import] ContactController 类不存在。");
|
|
|
+ boolean hasGroupImport = false;
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
+ if (m.getName().equals("importPhone") || m.getName().equals("importGroup")
|
|
|
+ || m.getName().equals("importBatch")) {
|
|
|
+ // 单条 importPhone 现状不含 groupId 参数 → 契约要求分组导入(参数含 groupId)
|
|
|
+ for (Class<?> p : m.getParameterTypes()) {
|
|
|
+ if (p.getName().endsWith("List")) {
|
|
|
+ hasGroupImport = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 若方法名即带 group 语义,也视为分组导入入口
|
|
|
+ if (m.getName().equals("importGroup") || m.getName().equals("importBatch")) {
|
|
|
+ hasGroupImport = true;
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
- assertTrue(reported,
|
|
|
- "缺电话格式错误行应报告『第 2 行格式错误』(TC-P-62-E),实际报告=" + errors);
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- @DisplayName("边界流-9:validateCsvLines 全合法行 → 空报告(无格式错误)")
|
|
|
- void validateCsvLines_allValid_emptyReport() {
|
|
|
- Object host = contractHost(HOST_IMPORT_SERVICE);
|
|
|
- List<String> errors = callList(host, "validateCsvLines", CSV_ALL_VALID);
|
|
|
- assertNotNull(errors, "validateCsvLines 不得返回 null");
|
|
|
- assertTrue(errors.isEmpty(),
|
|
|
- "全合法 CSV(姓名+电话)应无格式错误报告,实际=" + errors);
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- @DisplayName("边界流-10:validateCsvLines 非 CSV/空内容 → 拒绝(IllegalArgumentException)")
|
|
|
- void validateCsvLines_nonCsv_rejected() {
|
|
|
- Object host = contractHost(HOST_IMPORT_SERVICE);
|
|
|
- assertThrows(IllegalArgumentException.class, () -> callRaw(host, "validateCsvLines", (String) null),
|
|
|
- "null 内容应被拒绝(非 CSV,TC-P-62-E)");
|
|
|
- assertThrows(IllegalArgumentException.class, () -> callRaw(host, "validateCsvLines", ""),
|
|
|
- "空内容应被拒绝(非 CSV,TC-P-62-E)");
|
|
|
- assertThrows(IllegalArgumentException.class, () -> callRaw(host, "validateCsvLines", " \n \n "),
|
|
|
- "全空白内容应被拒绝(非 CSV,TC-P-62-E)");
|
|
|
+ assertTrue(hasGroupImport,
|
|
|
+ "[contact.group.import] ContactController 无分组导入端点:SC-P-62「建分组 → 导入」"
|
|
|
+ + "要求批量导入入口按 groupId 归组(现有 import-phone 仅单条、无分组,缺口 G-3)。");
|
|
|
}
|
|
|
|
|
|
- @Test
|
|
|
- @DisplayName("边界流-11:validateCsvLines 非 CSV 结构(无分隔行)→ 拒绝(非 CSV 拒绝)")
|
|
|
- void validateCsvLines_nonCsvStructure_rejected() {
|
|
|
- Object host = contractHost(HOST_IMPORT_SERVICE);
|
|
|
- assertThrows(IllegalArgumentException.class,
|
|
|
- () -> callRaw(host, "validateCsvLines", CSV_NON_CSV),
|
|
|
- "无 CSV 分隔结构的文本应被拒绝(非 CSV 拒绝,TC-P-62-E)");
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- @DisplayName("边界流-12:validateCsvLines 混合行 → 逐条报告错误行,正确行不入错误报告")
|
|
|
- void validateCsvLines_mixedRows_skipAndReportOnlyBad() {
|
|
|
- Object host = contractHost(HOST_IMPORT_SERVICE);
|
|
|
- List<String> errors = callList(host, "validateCsvLines", CSV_MIXED);
|
|
|
- assertNotNull(errors, "validateCsvLines 不得返回 null");
|
|
|
- assertEquals(2, errors.size(),
|
|
|
- "4 行中第 2/3 行缺电话(格式错误)应各报告一条,共 2 条(正确行不入错误报告),实际=" + errors);
|
|
|
- assertTrue(containsLine(errors, "第 2 行"),
|
|
|
- "应报告『第 2 行格式错误』(缺电话),实际=" + errors);
|
|
|
- assertTrue(containsLine(errors, "第 3 行"),
|
|
|
- "应报告『第 3 行格式错误』(缺电话),实际=" + errors);
|
|
|
- assertFalse(containsLine(errors, "第 1 行") || containsLine(errors, "第 4 行"),
|
|
|
- "合法行(第 1/4 行)不得误报为格式错误,实际=" + errors);
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- @DisplayName("边界流-13:validateCsvLines 重复号码 → 去重跳过报告")
|
|
|
- void validateCsvLines_duplicatePhone_deduped() {
|
|
|
- Object host = contractHost(HOST_IMPORT_SERVICE);
|
|
|
- List<String> errors = callList(host, "validateCsvLines", CSV_DUPLICATE_PHONE);
|
|
|
- assertNotNull(errors, "validateCsvLines 不得返回 null");
|
|
|
- assertFalse(errors.isEmpty(), "重复号码应被去重跳过并报告(不得静默接受重复导入)");
|
|
|
- }
|
|
|
-
|
|
|
- // ==================== 权限流:分组归属/横向越权(TC-P-62-P) ====================
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 边界流-导入:格式错误行跳过并报告(successCount / skippedCount / skippedRows)
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
|
@Test
|
|
|
- @DisplayName("权限流-14:isGroupOwner 契约方法应存在且他人分组归属判定为 false")
|
|
|
- void importService_isGroupOwner_foreignGroup_false() {
|
|
|
- Object host = contractHost(HOST_IMPORT_SERVICE);
|
|
|
- Object result = callAllowRejected(host, "isGroupOwner", 500L, 1L);
|
|
|
- if (result instanceof Boolean) {
|
|
|
- assertFalse((Boolean) result,
|
|
|
- "groupId=500 非 userId=1 所属分组时 isGroupOwner 应返回 false(越权门禁,TC-P-62-P)");
|
|
|
+ @DisplayName("边界流-导入:导入结果应含 successCount 字段(成功统计)")
|
|
|
+ void importResult_shouldCarrySuccessCount() {
|
|
|
+ Class<?> vo = loadClass(IMPORT_RESULT_VO);
|
|
|
+ if (vo == null) {
|
|
|
+ fail("[contact.group.import] ContactImportResultVO 不存在:SC-P-62 边界「导入格式错误行"
|
|
|
+ + "跳过并报告」要求结果载体携带成功/跳过统计。");
|
|
|
}
|
|
|
+ assertTrue(hasField(vo, "successCount"),
|
|
|
+ "[contact.group.import] ContactImportResultVO 缺 successCount:导入应报告成功行数。");
|
|
|
}
|
|
|
|
|
|
@Test
|
|
|
- @DisplayName("权限流-15:importCsv 越权分组(非本人)→ importedCount=0(横向越权被拒)")
|
|
|
- void importCsv_foreignGroup_denied() {
|
|
|
- Object host = contractHost(HOST_IMPORT_SERVICE);
|
|
|
- // userId=1 导入到 groupId=500(非本人分组)→ 横向越权统一拒绝(TC-P-62-P:403/不泄露他人数据)
|
|
|
- Object result = callAllowRejected(host, "importCsv", 500L, 1L, CSV_ALL_VALID);
|
|
|
- if (result instanceof Map) {
|
|
|
- Object imported = ((Map<?, ?>) result).get("importedCount");
|
|
|
- assertFalse(imported != null && toLong(imported) > 0L,
|
|
|
- "越权导入非本人分组不得有成功行(横向越权被拒,TC-P-62-P)");
|
|
|
+ @DisplayName("边界流-导入:导入结果应含 skippedCount 字段(跳过统计)")
|
|
|
+ void importResult_shouldCarrySkippedCount() {
|
|
|
+ Class<?> vo = loadClass(IMPORT_RESULT_VO);
|
|
|
+ if (vo == null) {
|
|
|
+ fail("[contact.group.import] ContactImportResultVO 不存在:SC-P-62 边界「导入格式错误行"
|
|
|
+ + "跳过并报告」要求结果载体携带成功/跳过统计。");
|
|
|
}
|
|
|
+ assertTrue(hasField(vo, "skippedCount"),
|
|
|
+ "[contact.group.import] ContactImportResultVO 缺 skippedCount:导入应报告跳过行数。");
|
|
|
}
|
|
|
|
|
|
- // ==================== 契约端点:controller + schema.sql 分组承载 ====================
|
|
|
-
|
|
|
@Test
|
|
|
- @DisplayName("契约端点-16:ContactGroupController 应落地 /api/contact/group 且提供 create/list/import")
|
|
|
- void controller_classMappedAndEndpointsExist() {
|
|
|
- Class<?> clazz = loadClass(CONTROLLER_CLASS);
|
|
|
- if (clazz == null) {
|
|
|
- fail("RED[contact.group.import] 契约端点 ContactGroupController 未落地(实现未落地):"
|
|
|
- + "SC-P-62 分组无端点(缺口 G-3),需 POST /api/contact/group/* 承载建分组/列表/导入。");
|
|
|
- }
|
|
|
- RequestMapping rm = clazz.getAnnotation(RequestMapping.class);
|
|
|
- assertNotNull(rm, "ContactGroupController 应标注 @RequestMapping");
|
|
|
- assertEquals(ENDPOINT_BASE, firstValue(rm.value()),
|
|
|
- "ContactGroupController 类路径应为 " + ENDPOINT_BASE + "(缺口 G-3 处置)");
|
|
|
- boolean hasCreate = false;
|
|
|
- boolean hasList = false;
|
|
|
- boolean hasImport = false;
|
|
|
- for (Method m : clazz.getDeclaredMethods()) {
|
|
|
- PostMapping pm = m.getAnnotation(PostMapping.class);
|
|
|
- if (pm == null) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- for (String path : pm.value()) {
|
|
|
- if ("/create".equals(path)) {
|
|
|
- hasCreate = true;
|
|
|
- }
|
|
|
- if ("/list".equals(path)) {
|
|
|
- hasList = true;
|
|
|
- }
|
|
|
- if ("/import".equals(path)) {
|
|
|
- hasImport = true;
|
|
|
- }
|
|
|
- }
|
|
|
+ @DisplayName("边界流-导入:导入结果应含 skippedRows 字段(错误行明细)")
|
|
|
+ void importResult_shouldCarrySkippedRows() {
|
|
|
+ Class<?> vo = loadClass(IMPORT_RESULT_VO);
|
|
|
+ if (vo == null) {
|
|
|
+ fail("[contact.group.import] ContactImportResultVO 不存在:SC-P-62 边界「导入格式错误行"
|
|
|
+ + "跳过并报告」要求结果载体携带错误行明细。");
|
|
|
}
|
|
|
- assertTrue(hasCreate, "应提供 @PostMapping /create(建分组)");
|
|
|
- assertTrue(hasList, "应提供 @PostMapping /list(分组列表,仅本人)");
|
|
|
- assertTrue(hasImport, "应提供 @PostMapping /import(分组导入,格式错误行跳过并报告)");
|
|
|
- boolean injected = false;
|
|
|
- for (Field f : clazz.getDeclaredFields()) {
|
|
|
- if (HOST_GROUP_SERVICE.equals(f.getType().getName())) {
|
|
|
- injected = true;
|
|
|
- break;
|
|
|
- }
|
|
|
- }
|
|
|
- assertTrue(injected, "ContactGroupController 应通过 @Resource 注入 " + HOST_GROUP_SERVICE);
|
|
|
+ assertTrue(hasField(vo, "skippedRows"),
|
|
|
+ "[contact.group.import] ContactImportResultVO 缺 skippedRows:导入应返回被跳过的错误行明细"
|
|
|
+ + "(行号+原因),供前端报告。");
|
|
|
}
|
|
|
|
|
|
- @Test
|
|
|
- @DisplayName("契约端点-17:schema.sql 应落地 contact_groups 建表 + contacts.group_id 分组绑定列")
|
|
|
- void schemaSql_contactGroupsTableAndGroupIdColumn() {
|
|
|
- String schema = readSchemaSql();
|
|
|
- assertTrue(schema.contains(GROUP_TABLE),
|
|
|
- "RED[contact.group.import] schema.sql 缺少 " + GROUP_TABLE + " 建表:"
|
|
|
- + "SC-P-62 分组承载(缺口 G-3)未落地。");
|
|
|
- assertTrue(schema.contains(CONTACT_GROUP_ID_COLUMN),
|
|
|
- "RED[contact.group.import] schema.sql contacts 表缺少 " + CONTACT_GROUP_ID_COLUMN
|
|
|
- + " 分组绑定列:ContactImport 导入落分组无字段承载(缺口 G-3)。");
|
|
|
- }
|
|
|
-
|
|
|
- // ==================== 反射辅助(锚定宿主契约,避免编译期依赖不存在类) ====================
|
|
|
-
|
|
|
- private Object contractHost(String className) {
|
|
|
- try {
|
|
|
- Class<?> clazz = Class.forName(className);
|
|
|
- return clazz.getDeclaredConstructor().newInstance();
|
|
|
- } catch (ClassNotFoundException e) {
|
|
|
- fail("RED[contact.group.import] 契约宿主 " + simpleName(className)
|
|
|
- + " 未实现(实现未落地):" + e.getMessage());
|
|
|
- throw new IllegalStateException("unreachable");
|
|
|
- } catch (Exception e) {
|
|
|
- fail("契约宿主 " + simpleName(className) + " 实例化失败:" + e.getMessage());
|
|
|
- throw new IllegalStateException("unreachable");
|
|
|
- }
|
|
|
- }
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 权限流:分组导入归属校验(userId 隔离 / 横向越权)
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
|
- /** 断言契约方法存在(按方法名 + 参数个数匹配),缺失即断言失败(红)。 */
|
|
|
- private void assertMethodExists(String className, String method, int paramCount, String purpose) {
|
|
|
- Class<?> clazz = loadClass(className);
|
|
|
- if (clazz == null) {
|
|
|
- fail("RED[contact.group.import] 契约宿主 " + simpleName(className)
|
|
|
- + " 未实现(实现未落地):无法验证 " + method + " 契约方法。" + purpose);
|
|
|
- }
|
|
|
- for (Method m : clazz.getMethods()) {
|
|
|
- if (m.getName().equals(method) && m.getParameterCount() == paramCount) {
|
|
|
- return;
|
|
|
- }
|
|
|
+ @Test
|
|
|
+ @DisplayName("权限流:批量导入方法应含 groupId+userId 归属参数(防跨用户写入)")
|
|
|
+ void importBatch_carriesOwnerParams() {
|
|
|
+ // 契约要求 ContactImport(模块名)批量导入入口以 groupId+userId 双参数承载归属:
|
|
|
+ // 导入时校验 userId 为分组属主,禁止跨用户写入(SC-P-62 权限流,横向越权维度)。
|
|
|
+ // 宿主 ContactImportService 未实现 → 反射探测不到 importBatch/importCsv → 红。
|
|
|
+ Class<?> svc = loadClass(IMPORT_SERVICE);
|
|
|
+ if (svc == null) {
|
|
|
+ fail("[contact.group.import] ContactImportService 不存在:SC-P-62 权限流要求"
|
|
|
+ + "批量导入入口以 groupId+userId 双参数承载归属校验。实现未落地。");
|
|
|
}
|
|
|
- fail("RED[contact.group.import] 契约方法 " + simpleName(className) + "#" + method
|
|
|
- + "(参数 " + paramCount + " 个)未实现:" + purpose);
|
|
|
- }
|
|
|
-
|
|
|
- /** 按方法名 + 参数个数匹配并调用(宽松适配基本/包装类型),契约方法缺失即断言失败。 */
|
|
|
- private Object call(Object host, String method, Object... args) {
|
|
|
- return callInternal(host, method, true, args);
|
|
|
- }
|
|
|
-
|
|
|
- /** 同 {@link #call},但允许业务拒绝以异常/空表达(权限拒绝用例),不中断失败。 */
|
|
|
- private Object callAllowRejected(Object host, String method, Object... args) {
|
|
|
- return callInternal(host, method, false, args);
|
|
|
- }
|
|
|
-
|
|
|
- private Object callRaw(Object host, String method, Object... args) {
|
|
|
- return callInternal(host, method, true, args);
|
|
|
- }
|
|
|
-
|
|
|
- private Object callInternal(Object host, String method, boolean failOnMissing, 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) {
|
|
|
- if (failOnMissing) {
|
|
|
- fail("契约方法 " + simpleName(host.getClass().getName()) + "#" + method
|
|
|
- + " 调用失败:" + e.getMessage());
|
|
|
- throw new IllegalStateException("unreachable");
|
|
|
+ boolean hasOwnerScopedMethod = false;
|
|
|
+ for (Method m : svc.getDeclaredMethods()) {
|
|
|
+ if (m.getName().equals("importBatch") || m.getName().equals("importCsv")) {
|
|
|
+ Class<?>[] pts = m.getParameterTypes();
|
|
|
+ // 归属双参:第一个参数为 group 引用(Long groupId / String csv),第二个参数为 Long userId
|
|
|
+ if (pts.length >= 2) {
|
|
|
+ boolean hasUserId = pts[1].getName().equals("java.lang.Long")
|
|
|
+ || pts[1].getName().equals("long");
|
|
|
+ boolean hasGroupRef = pts[0].getName().equals("java.lang.Long")
|
|
|
+ || pts[0].getName().equals("long")
|
|
|
+ || pts[0].getName().equals("java.lang.String");
|
|
|
+ if (hasUserId && hasGroupRef) {
|
|
|
+ hasOwnerScopedMethod = true;
|
|
|
+ break;
|
|
|
}
|
|
|
- return null;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
- if (failOnMissing) {
|
|
|
- fail("RED[contact.group.import] 契约方法 " + simpleName(host.getClass().getName())
|
|
|
- + "#" + method + " 未实现");
|
|
|
- throw new IllegalStateException("unreachable");
|
|
|
- }
|
|
|
- return null;
|
|
|
+ assertTrue(hasOwnerScopedMethod,
|
|
|
+ "[contact.group.import] 批量导入方法应以 groupId+userId 双参数归属校验:"
|
|
|
+ + "导入联系人归属当前登录用户,越权分组被拒(横向越权,SC-P-62 权限流)。");
|
|
|
}
|
|
|
|
|
|
- @SuppressWarnings("unchecked")
|
|
|
- private List<String> callList(Object host, String method, Object... args) {
|
|
|
- Object r = call(host, method, args);
|
|
|
- if (r instanceof List) {
|
|
|
- return (List<String>) r;
|
|
|
- }
|
|
|
- fail("契约方法 " + method + " 应返回 List<String>,实际 " + r);
|
|
|
- throw new IllegalStateException("unreachable");
|
|
|
- }
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 反射辅助
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
|
- private boolean hasField(Class<?> clazz, String field) {
|
|
|
+ private Class<?> loadClass(String name) {
|
|
|
try {
|
|
|
- clazz.getDeclaredField(field);
|
|
|
- return true;
|
|
|
- } catch (NoSuchFieldException e) {
|
|
|
- return false;
|
|
|
+ return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
+ return null;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private boolean containsLine(List<String> reports, String linePrefix) {
|
|
|
- for (String msg : reports) {
|
|
|
- if (msg != null && msg.contains(linePrefix)) {
|
|
|
+ private boolean hasField(Class<?> clazz, String fieldName) {
|
|
|
+ for (Field f : clazz.getDeclaredFields()) {
|
|
|
+ if (f.getName().equals(fieldName)) {
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
- private long toLong(Object v) {
|
|
|
- if (v instanceof Number) {
|
|
|
- return ((Number) v).longValue();
|
|
|
- }
|
|
|
- if (v instanceof CharSequence) {
|
|
|
- try {
|
|
|
- return Long.parseLong(v.toString().trim());
|
|
|
- } catch (NumberFormatException ignored) {
|
|
|
- // fall through
|
|
|
- }
|
|
|
- }
|
|
|
- return -1L;
|
|
|
- }
|
|
|
-
|
|
|
- private String firstValue(String[] values) {
|
|
|
- return values != null && values.length > 0 ? values[0] : null;
|
|
|
- }
|
|
|
-
|
|
|
- private String simpleName(String fqcn) {
|
|
|
- int idx = fqcn.lastIndexOf('.');
|
|
|
- return idx >= 0 ? fqcn.substring(idx + 1) : fqcn;
|
|
|
- }
|
|
|
-
|
|
|
- private Class<?> loadClass(String name) {
|
|
|
- try {
|
|
|
- return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
|
|
|
- } catch (ClassNotFoundException e) {
|
|
|
- return null;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
private String readSchemaSql() {
|
|
|
try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
|
|
|
if (is == null) {
|
|
|
@@ -464,4 +280,4 @@ class ContactGroupImportContractTest {
|
|
|
return "";
|
|
|
}
|
|
|
}
|
|
|
-}
|
|
|
+}
|