Compare commits

...

2 Commits

Author SHA1 Message Date
ether 0242a12282 Merge feat/u6-test-coverage-and-compound-docs: U6 集成测试覆盖 + U5 审计日志验证 + ce-compound 知识沉淀 2026-07-02 23:58:24 +08:00
ether b5ff4bf9aa test(pms-base): U6 集成测试覆盖 + U5 审计日志验证 + ce-compound 知识沉淀
- 新增 PujialiImportRunnerStageTest 5 个集成测试场景(plan U6):
  - 阶段 1 单项目导入:项目创建 + 能力包绑定 + fallback primary + 4 条 attribute 写入
  - 阶段 1 多业态项目:assignPackages/bindWithFallbackPrimary 按业态列顺序
  - 阶段 1 重复导入:同 project_code 跳过创建
  - 阶段 2 物业费标准创建:JdbcTemplate INSERT t_charge_standard,14 参数 verify
  - sales_status 不写入(OUT_OF_SCOPE):ArgumentCaptor 验证 4 个预期 attrKey
- ProjectAbilityServiceImplTest 增强 forceOverridePrimary 审计步骤验证(plan U5):
  - verify 查询原 primary package(abilityPackageMapper.selectById(2002L))
  - verify 查询新 primary package(abilityPackageMapper.selectById(2001L))
- 新增 ce-compound 知识沉淀 docs/solutions/conventions/mybatis-plus-lambda-wrapper-testing.md:
  - LambdaUpdateWrapper.set() eager evaluation 导致纯 Mockito 测试抛 MybatisPlusException
  - Lombok boolean 字段生成 isXxx() 而非 getXxx() getter

测试:./gradlew :pms-base:test 全量通过
2026-07-02 23:58:20 +08:00
3 changed files with 317 additions and 1 deletions

View File

@ -0,0 +1,209 @@
package com.pms.base.importer;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.pms.base.constant.ProjectAttrType;
import com.pms.base.dto.ProjectAttributeDTO;
import com.pms.base.dto.ProjectSaveRequest;
import com.pms.base.entity.Project;
import com.pms.base.mapper.ProjectMapper;
import com.pms.base.service.AbilityPackageService;
import com.pms.base.service.ProjectAbilityService;
import com.pms.base.service.ProjectAttributeService;
import com.pms.base.service.ProjectService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.jdbc.core.JdbcTemplate;
import java.io.InputStream;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* PujialiImportRunner 集成编排逻辑测试plan U6 Test scenarios
* <p>
* Mockito Spring Contextmock 所有依赖验证 stage1/stage2/stage3 编排行为
* 对应 plan U6 5 个集成测试场景
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("PujialiImportRunner 集成编排逻辑")
class PujialiImportRunnerStageTest {
@Mock private ProjectService projectService;
@Mock private AbilityPackageService abilityPackageService;
@Mock private ProjectAbilityService projectAbilityService;
@Mock private ProjectAttributeService projectAttributeService;
@Mock private ProjectMapper projectMapper;
@Mock private JdbcTemplate jdbcTemplate;
@Mock private ObjectMapper objectMapper;
@InjectMocks
private PujialiImportRunner runner;
private static final Long PROJECT_ID = 1001L;
@BeforeEach
void setUp() throws Exception {
// mock loadProjectsobjectMapper.readValue 返回空列表各测试用例覆盖时再 stub
when(objectMapper.readValue(any(InputStream.class), any(TypeReference.class)))
.thenReturn(List.of());
// mock findByCode 默认返回 null不存在
when(projectMapper.selectOne(any())).thenReturn(null);
// mock projectService.create 返回 PROJECT_ID
when(projectService.create(any())).thenReturn(PROJECT_ID);
// mock projectMapper.updateupdateLifecycleStage
when(projectMapper.update(any(), any())).thenReturn(1);
}
// ===== 场景 1: 阶段 1 单项目导入 =====
@Test
@DisplayName("阶段 1 单项目导入:项目创建 + 能力包绑定 + t_project_attribute 写入CUSTOMER/PRE_HANDOVER")
void stage1_singleProject_importsCorrectly() throws Exception {
PujialiImportRunner.PujialiProject p = baseProject("PJL-TEST-001", List.of("住宅"));
when(objectMapper.readValue(any(InputStream.class), any(TypeReference.class)))
.thenReturn(List.of(p));
runner.run();
// 项目创建
verify(projectService).create(any(ProjectSaveRequest.class));
// 能力包绑定 + is_primary fallback
verify(abilityPackageService).assignPackages(eq(PROJECT_ID), eq(List.of("RESIDENTIAL")));
verify(projectAbilityService).bindWithFallbackPrimary(eq(PROJECT_ID), eq(List.of("RESIDENTIAL")));
// t_project_attribute 写入 4 land_name/public_opinion_risk=CUSTOMER, construction_progress/latest_progress=PRE_HANDOVER
verify(projectAttributeService, times(4)).create(eq(PROJECT_ID), any(ProjectAttributeDTO.class));
}
// ===== 场景 2: 阶段 1 多业态项目 =====
@Test
@DisplayName("阶段 1 多业态项目is_primary 按 Excel 业态列第一值置位")
void stage1_multiBusinessType_assignsFirstAsPrimary() throws Exception {
PujialiImportRunner.PujialiProject p = baseProject("PJL-TEST-002", List.of("住宅", "商业"));
when(objectMapper.readValue(any(InputStream.class), any(TypeReference.class)))
.thenReturn(List.of(p));
runner.run();
// 能力包绑定顺序保持 Excel 业态列顺序
verify(abilityPackageService).assignPackages(eq(PROJECT_ID), eq(List.of("RESIDENTIAL", "COMMERCIAL")));
verify(projectAbilityService).bindWithFallbackPrimary(eq(PROJECT_ID), eq(List.of("RESIDENTIAL", "COMMERCIAL")));
}
// ===== 场景 3: 阶段 1 重复导入 =====
@Test
@DisplayName("阶段 1 重复导入同 project_code跳过不创建")
void stage1_duplicateProjectCode_skips() throws Exception {
PujialiImportRunner.PujialiProject p = baseProject("PJL-TEST-003", List.of("住宅"));
when(objectMapper.readValue(any(InputStream.class), any(TypeReference.class)))
.thenReturn(List.of(p));
// 模拟项目已存在
Project existing = new Project();
existing.setId(PROJECT_ID);
existing.setProjectCode("PJL-TEST-003");
when(projectMapper.selectOne(any())).thenReturn(existing);
runner.run();
// 不创建项目不绑定能力包不写属性
verify(projectService, never()).create(any());
verify(abilityPackageService, never()).assignPackages(any(), anyList());
verify(projectAttributeService, never()).create(any(), any());
}
// ===== 场景 4: 阶段 2 物业费标准创建 =====
@Test
@DisplayName("阶段 2 物业费标准创建BigDecimal→Long 适配JdbcTemplate INSERT 调用")
void stage2_chargeStandard_insertsWithPriceFen() throws Exception {
PujialiImportRunner.PujialiProject p = baseProject("PJL-TEST-004", List.of("住宅"));
PujialiImportRunner.ChargeStandardItem item = new PujialiImportRunner.ChargeStandardItem();
item.setBuildingType("高层");
item.setUnitPrice(new BigDecimal("7.9"));
p.setChargeStandards(List.of(item));
when(objectMapper.readValue(any(InputStream.class), any(TypeReference.class)))
.thenReturn(List.of(p));
runner.run();
// 验证 JdbcTemplate INSERT 被调用stage2SQL t_charge_standard
// 13 个参数id, projectId, standardCode, standardName, feeType, unitPrice, unit, billingCycle, calcRule, status, remark, createdAt, updatedAt
verify(jdbcTemplate, times(1)).update(contains("t_charge_standard"),
any(), eq(PROJECT_ID), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any());
}
// ===== 场景 5: sales_status 不写入 =====
@Test
@DisplayName("sales_status 不写入 t_project_attributeOUT_OF_SCOPE")
void stage1_salesStatus_notWritten() throws Exception {
PujialiImportRunner.PujialiProject p = baseProject("PJL-TEST-005", List.of("住宅"));
p.setSalesStatus("在售"); // 有值但不应写入
when(objectMapper.readValue(any(InputStream.class), any(TypeReference.class)))
.thenReturn(List.of(p));
runner.run();
// 捕获所有 projectAttributeService.create 调用的 DTO
ArgumentCaptor<ProjectAttributeDTO> dtoCaptor = ArgumentCaptor.forClass(ProjectAttributeDTO.class);
verify(projectAttributeService, atLeastOnce()).create(eq(PROJECT_ID), dtoCaptor.capture());
// 验证没有 attrKey="sales_status" 的调用
assertThat(dtoCaptor.getAllValues())
.noneMatch(dto -> "sales_status".equals(dto.getAttrKey()));
// 验证写入的 attrKey 都是预期的 4
assertThat(dtoCaptor.getAllValues())
.extracting(ProjectAttributeDTO::getAttrKey)
.containsExactlyInAnyOrder("land_name", "public_opinion_risk",
"construction_progress", "latest_progress");
// 验证 attr_type 分类正确
assertThat(dtoCaptor.getAllValues())
.filteredOn(dto -> "land_name".equals(dto.getAttrKey()) || "public_opinion_risk".equals(dto.getAttrKey()))
.allMatch(dto -> ProjectAttrType.CUSTOMER.equals(dto.getAttrType()));
assertThat(dtoCaptor.getAllValues())
.filteredOn(dto -> "construction_progress".equals(dto.getAttrKey()) || "latest_progress".equals(dto.getAttrKey()))
.allMatch(dto -> ProjectAttrType.PRE_HANDOVER.equals(dto.getAttrType()));
}
// ===== 辅助方法 =====
private PujialiImportRunner.PujialiProject baseProject(String code, List<String> businessTypes) {
PujialiImportRunner.PujialiProject p = new PujialiImportRunner.PujialiProject();
p.setProjectCode(code);
p.setProjectName("测试项目-" + code);
p.setShortName("测试");
p.setProvince("江苏省");
p.setCity("南京市");
p.setDistrict("鼓楼区");
p.setAddress("测试地址");
p.setFeeMode("ALL_INCLUSIVE");
p.setServiceManager("测试人");
p.setServiceManagerPhone("13800000000");
p.setBusinessTypes(businessTypes);
p.setLandName("测试地块");
p.setPublicOpinionRisk("");
p.setConstructionProgress("已竣工");
p.setLatestProgress("运营中");
p.setSalesStatus("售罄");
p.setStatus("已进场");
p.setChargeStandards(List.of());
p.setBuildings(List.of());
return p;
}
}

View File

@ -340,7 +340,7 @@ class ProjectAbilityServiceImplTest {
// ===== forceOverridePrimary =====
@Test
@DisplayName("forceOverridePrimary指定 abilityId 置 1其余清零")
@DisplayName("forceOverridePrimary指定 abilityId 置 1其余清零,审计信息获取")
void forceOverridePrimary_normal_setsPrimary() {
ProjectAbility pa = newProjectAbility(10L, PROJECT_ID, 2001L);
when(projectAbilityMapper.selectById(10L)).thenReturn(pa);
@ -361,6 +361,9 @@ class ProjectAbilityServiceImplTest {
// 清零 + 置位 = 2 update
verify(projectAbilityMapper, times(2)).update(any(), any());
// 审计信息获取查询原 primary packageCOMMERCIAL+ primary packageRESIDENTIAL
verify(abilityPackageMapper).selectById(2002L);
verify(abilityPackageMapper).selectById(2001L);
}
@Test

View File

@ -0,0 +1,104 @@
---
title: "MyBatis-Plus LambdaUpdateWrapper 测试陷阱 + Lombok boolean getter 命名"
date: 2026-07-02
category: conventions
module: pms-base
problem_type: convention
component: testing_framework
severity: medium
applies_when:
- 纯 Mockito 测试 MyBatis-Plus Service 时 LambdaUpdateWrapper.set() 抛 MybatisPlusException
- Lombok boolean 字段生成 isXxx() 而非 getXxx() 导致测试编译失败
tags: [mybatis-plus, lambda-wrapper, mockito, lombok, unit-testing]
---
# MyBatis-Plus LambdaUpdateWrapper 测试陷阱 + Lombok boolean getter 命名
## Context
EtherPMS 后端测试模式遵循 `docs/solutions/conventions/lazy-injection-reflectiontestutils-testing.md`:纯 Mockito + AssertJ`@ExtendWith(MockitoExtension.class)` + `@MockitoSettings(strictness=LENIENT)`,无 Spring Context。
在该模式下MyBatis-Plus 的 `LambdaUpdateWrapper` 和 Lombok boolean getter 存在两个隐蔽陷阱。
## Problem 1: LambdaUpdateWrapper.set() 在纯 Mockito 测试中抛 MybatisPlusException
### 症状
```
com.baomidou.mybatisplus.core.exceptions.MybatisPlusException: can not find lambda cache for this entity [ProjectAbility]
at com.baomidou.mybatisplus.core.toolkit.LambdaUtils.installCache(LambdaUtils.java:113)
at com.baomidou.mybatisplus.core.conditions.AbstractLambdaWrapper.tryInitCache(AbstractLambdaWrapper.java:142)
```
### 根因
`LambdaUpdateWrapper.set(SFunction, Object)``LambdaQueryWrapper.eq(SFunction, Object)` 的求值时机不同:
- **`LambdaUpdateWrapper.set()`** — **eager evaluation**:通过 `maybeDo(condition, supplier)``supplier.get()` 立即执行 `columnToString()``getColumnCache()``tryInitCache()`,需要 `TableInfo` 已初始化
- **`LambdaQueryWrapper.eq()`** — **lazy evaluation**:通过 `columnToSqlSegment()` 返回 lambda `() -> columnToString()`SQL 段在实际执行时才生成
纯 Mockito 测试无 Spring Context`TableInfo` 从未初始化(需要 MyBatis-Plus 启动时扫描实体),因此 `set()` 立即抛异常,而 `eq()` 因延迟执行不触发。
### 解决方案
`update(entity, LambdaQueryWrapper)` 替代 `update(null, LambdaUpdateWrapper.set())`
```java
// BAD — 测试中抛 MybatisPlusExceptioneager evaluation
LambdaUpdateWrapper<ProjectAbility> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(ProjectAbility::getProjectId, projectId)
.set(ProjectAbility::getIsPrimary, 0);
projectAbilityMapper.update(null, wrapper);
// GOOD — 测试中正常工作lazy evaluation
ProjectAbility update = new ProjectAbility();
update.setIsPrimary(0);
LambdaQueryWrapper<ProjectAbility> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ProjectAbility::getProjectId, projectId);
projectAbilityMapper.update(update, wrapper);
```
### 适用场景
- 纯 Mockito 测试中需要 UPDATE 操作的 Service 方法
- 无法引入 Spring Context 的快速单元测试
### 局限
- `update(entity, wrapper)` 语义与 `update(null, wrapper.set())` 略有差异:前者更新实体非 null 字段,后者更新 set 指定字段。对于明确赋值的场景两者等价。
## Problem 2: Lombok boolean getter 命名为 isXxx() 而非 getXxx()
### 症状
```
java: cannot find symbol
symbol: method getSwitched()
location: variable result of type PrimaryRecomputeResultDTO
```
### 根因
Lombok 对 `private boolean switched;` 生成 `isSwitched()` 而非 `getSwitched()`。`is` 前缀仅对 `boolean`/`Boolean` 类型生效,其他类型仍用 `get`
### 解决方案
测试中用 `isXxx()` 替代 `getXxx()`
```java
// BAD — 编译失败
assertThat(result.getSwitched()).isTrue();
// GOOD
assertThat(result.isSwitched()).isTrue();
```
### 预防
DTO 设计 boolean 字段时,注意 Jackson 序列化默认用 `isXxx()` 作为 JSON 属性名(`switched` 而非 `isSwitched`)。若前端依赖特定字段名,用 `@JsonProperty("is_switched")` 显式指定。
## References
- 问题 1 发现于 U5 ProjectAbilityServiceImpl 测试commit `3824362`
- 问题 2 发现于 U5 PrimaryRecomputeResultDTO 测试commit `3824362`
- 测试模式参考:`docs/solutions/conventions/lazy-injection-reflectiontestutils-testing.md`