package com.crm.dict.config; import com.crm.dict.AbstractDictH2Test; import com.crm.dict.constant.DictConstants; import com.crm.dict.domain.entity.DictGroup; import com.crm.dict.domain.entity.DictGroupDefault; import com.crm.dict.domain.entity.DictItem; import com.crm.dict.domain.entity.DictRefCount; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; /** * T08 内置字典幂等初始化:首装建库 / 幂等重跑 / 只改内置不碰用户数据 / 默认项不覆盖 / 只加不删 / 种子校验 fail-fast *

种子注入走 {@link DictDataInitializer#run(List)} 包私有入口(Excel 替换口的同构接入点)。

*/ @DisplayName("T08 内置字典幂等初始化") class DictDataInitializerTest extends AbstractDictH2Test { private DictDataInitializer initializer; @BeforeEach void setUpInitializer() { initializer = new DictDataInitializer(dictGroupMapper, dictItemMapper, dictGroupDefaultMapper, dictRefCountMapper); } @Test @DisplayName("首装:3 个内置分组 + 17 个内置项全部建库,ref_count 预建,种子默认项生效") void firstRun_createsBuiltinDictsWithDefaults() { initializer.run(); List groups = dictGroupMapper.selectList(null); assertThat(groups).hasSize(3); assertThat(groups).extracting(DictGroup::getCode) .containsExactlyInAnyOrder("lead_source", "customer_status", "industry"); assertThat(groups).allMatch(g -> Boolean.TRUE.equals(g.getBuiltin())); assertThat(groups).allMatch(g -> g.getStatus() == DictConstants.STATUS_ENABLED); List items = dictItemMapper.selectList(null); assertThat(items).hasSize(17); // 6 + 3 + 8 assertThat(items).allMatch(i -> Boolean.TRUE.equals(i.getBuiltin())); // 引用计数行预建(与新建接口同口径) assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(17); DictGroup statusGroup = groups.stream() .filter(g -> g.getCode().equals("customer_status")).findFirst().orElseThrow(); DictItem potential = dictItemMapper.selectList(null).stream() .filter(i -> i.getGroupId().equals(statusGroup.getId()) && i.getCode().equals("potential")) .findFirst().orElseThrow(); // 种子默认项生效 DictGroupDefault def = dictGroupDefaultMapper.selectById(statusGroup.getId()); assertThat(def).isNotNull(); assertThat(def.getItemId()).isEqualTo(potential.getId()); // 默认项自身在启用列表中 assertThat(dictQueryService.listEnabledItems("customer_status")) .extracting(DictItem::getCode).contains("potential"); } @Test @DisplayName("幂等:重复执行不产生重复分组/项/计数行/默认行") void rerun_idempotent() { initializer.run(); initializer.run(); assertThat(dictGroupMapper.selectCount(null)).isEqualTo(3); assertThat(dictItemMapper.selectCount(null)).isEqualTo(17); assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(17); assertThat(dictGroupDefaultMapper.selectCount(null)).isEqualTo(1); } @Test @DisplayName("更新:内置项按种子刷新名称;builtin=false 用户数据绝不触碰") void update_refreshesBuiltinButNeverTouchesUserData() { initializer.run(); // 管理员本地化:改内置项名 + 手建用户项(builtin=false) DictGroup leadSource = dictGroupMapper.selectList(null).stream() .filter(g -> g.getCode().equals("lead_source")).findFirst().orElseThrow(); DictItem adItem = dictItemMapper.selectList(null).stream() .filter(i -> i.getGroupId().equals(leadSource.getId()) && i.getCode().equals("ad")) .findFirst().orElseThrow(); adItem.setName("广告投放-本地改名"); adItem.setValue("LOCAL_VALUE"); // 管理员本地化改值 dictItemMapper.updateById(adItem); DictItem userItem = new DictItem(); userItem.setGroupId(leadSource.getId()); userItem.setCode("user_created"); userItem.setName("用户自建项"); userItem.setValue("USER_VALUE"); userItem.setStatus(DictConstants.STATUS_ENABLED); userItem.setBuiltin(false); userItem.setDeleteKey(0L); dictItemMapper.insert(userItem); initializer.run(); // 内置项名被种子刷新(种子的"改"语义),但字典值属业务契约不被覆盖 DictItem refreshed = dictItemMapper.selectById(adItem.getId()); assertThat(refreshed.getName()).isEqualTo("广告投放"); assertThat(refreshed.getValue()).isEqualTo("LOCAL_VALUE"); // 用户项原样保留:名称/值/内置标记均未动 DictItem untouched = dictItemMapper.selectList(null).stream() .filter(i -> i.getCode().equals("user_created")).findFirst().orElseThrow(); assertThat(untouched.getName()).isEqualTo("用户自建项"); assertThat(untouched.getValue()).isEqualTo("USER_VALUE"); assertThat(untouched.getBuiltin()).isFalse(); } @Test @DisplayName("默认项:管理员已设默认不覆盖;种子默认项停用后不复活") void defaultItem_notOverwritten() { initializer.run(); DictGroup statusGroup = dictGroupMapper.selectList(null).stream() .filter(g -> g.getCode().equals("customer_status")).findFirst().orElseThrow(); // 管理员改设 active 为默认 → 重跑种子不得覆盖 DictItem active = dictItemMapper.selectList(null).stream() .filter(i -> i.getGroupId().equals(statusGroup.getId()) && i.getCode().equals("active")) .findFirst().orElseThrow(); dictGroupDefaultMapper.deleteById(statusGroup.getId()); DictGroupDefault adminDefault = new DictGroupDefault(); adminDefault.setGroupId(statusGroup.getId()); adminDefault.setItemId(active.getId()); dictGroupDefaultMapper.insert(adminDefault); initializer.run(); assertThat(dictGroupDefaultMapper.selectById(statusGroup.getId()).getItemId()) .isEqualTo(active.getId()); // 管理员默认保留 // 停用种子默认项(停用自动取消默认)→ 重跑种子不得复活 DictItem potential = dictItemMapper.selectList(null).stream() .filter(i -> i.getGroupId().equals(statusGroup.getId()) && i.getCode().equals("potential")) .findFirst().orElseThrow(); // 先把默认改回种子默认项(模拟 potential 是当前默认),再停用它触发自动取消默认 dictGroupDefaultMapper.deleteById(statusGroup.getId()); DictGroupDefault seedDefault = new DictGroupDefault(); seedDefault.setGroupId(statusGroup.getId()); seedDefault.setItemId(potential.getId()); dictGroupDefaultMapper.insert(seedDefault); dictItemService.updateItemStatus(potential.getId(), DictConstants.STATUS_DISABLED); assertThat(dictGroupDefaultMapper.selectById(statusGroup.getId())).isNull(); // 停用已取消默认 initializer.run(); assertThat(dictGroupDefaultMapper.selectById(statusGroup.getId())).isNull(); // 种子不复活停用默认 } @Test @DisplayName("只加不删:种子外既有内置项在重跑后仍保留") void removedFromSeed_notDeleted() { initializer.run(); DictGroup industry = dictGroupMapper.selectList(null).stream() .filter(g -> g.getCode().equals("industry")).findFirst().orElseThrow(); // 模拟历史版本种子里有、当前种子里已移除的内置项 DictItem legacy = new DictItem(); legacy.setGroupId(industry.getId()); legacy.setCode("legacy_removed"); legacy.setName("旧版本内置项"); legacy.setValue("LEGACY"); legacy.setStatus(DictConstants.STATUS_ENABLED); legacy.setBuiltin(true); legacy.setDeleteKey(0L); dictItemMapper.insert(legacy); initializer.run(); assertThat(dictItemMapper.selectList(null).stream() .filter(i -> i.getCode().equals("legacy_removed"))).hasSize(1); } @Test @DisplayName("US-41:同编码用户分组被种子分组占用时,整组跳过——不插入种子项/不设默认/不改名") void userGroupWithSeedCode_neverTouched() { // 模拟部署前管理员已手建同编码分组(builtin=false) DictGroup userGroup = new DictGroup(); userGroup.setCode("lead_source"); userGroup.setName("用户自有来源"); userGroup.setStatus(DictConstants.STATUS_ENABLED); userGroup.setBuiltin(false); userGroup.setDeleteKey(0L); dictGroupMapper.insert(userGroup); initializer.run(); // 用户分组未被改名、未被重建,且其下未插入任何种子项 assertThat(dictGroupMapper.selectById(userGroup.getId()).getName()).isEqualTo("用户自有来源"); assertThat(dictGroupMapper.selectById(userGroup.getId()).getBuiltin()).isFalse(); assertThat(dictItemMapper.selectList(null).stream() .filter(i -> i.getGroupId().equals(userGroup.getId()))).isEmpty(); // 种子其他分组不受影响(继续正常导入):customer_status + industry assertThat(dictGroupMapper.selectCount(null)).isEqualTo(3); // lead_source(用户) + customer_status + industry assertThat(dictGroupMapper.selectList(null).stream() .filter(g -> g.getCode().equals("industry"))).hasSize(1); assertThat(dictItemMapper.selectCount(null)).isEqualTo(11); // customer_status 3 + industry 8 assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(11); assertThat(dictGroupDefaultMapper.selectCount(null)).isEqualTo(1); // customer_status 默认项 } @Test @DisplayName("种子校验 fail-fast:坏种子拒绝导入且不写库") void invalidSeed_failFast() { // 1. 分组编码重复 assertSeedRejected(List.of( groupSeed("g1", "一", itemSeed("a", "项A", "A", false)), groupSeed("g1", "二", itemSeed("b", "项B", "B", false)))); // 2. 组内字典编码重复 assertSeedRejected(List.of( groupSeed("g1", "一", itemSeed("a", "项A", "A", false), itemSeed("a", "项A2", "B", false)))); // 3. 同组字典值重复(非空) assertSeedRejected(List.of( groupSeed("g1", "一", itemSeed("a", "项A", "A", false), itemSeed("b", "项B", "A", false)))); // 4. 每组超过一个默认项 assertSeedRejected(List.of( groupSeed("g1", "一", itemSeed("a", "项A", "A", true), itemSeed("b", "项B", "B", true)))); // 5. 编码格式非法(含非法字符) assertSeedRejected(List.of( groupSeed("bad-code!", "非法编码", itemSeed("a", "项A", "A", false)))); // 6. 分组名称缺失 assertSeedRejected(List.of( groupSeed("g1", " ", itemSeed("a", "项A", "A", false)))); // 全部被拒 → 库中未写入任何数据(fail-fast 语义) assertThat(dictGroupMapper.selectCount(null)).isZero(); assertThat(dictItemMapper.selectCount(null)).isZero(); } /*-------- 测试数据工厂 --------*/ private void assertSeedRejected(List seeds) { org.assertj.core.api.Assertions.assertThatThrownBy(() -> initializer.run(seeds)) .isInstanceOf(com.crm.base.domain.exception.BusinessErrorException.class); } private DictDataInitializer.GroupSeed groupSeed(String code, String name, DictDataInitializer.ItemSeed... items) { return new DictDataInitializer.GroupSeed(code, name, 1, null, List.of(items)); } private DictDataInitializer.ItemSeed itemSeed(String code, String name, String value, boolean defaultItem) { return new DictDataInitializer.ItemSeed(code, name, value, 1, null, defaultItem); } }