Compare commits
2 Commits
204a1c3047
...
0242a12282
| Author | SHA1 | Date |
|---|---|---|
|
|
0242a12282 | |
|
|
b5ff4bf9aa |
|
|
@ -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 Context。mock 所有依赖,验证 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 loadProjects:objectMapper.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.update(updateLifecycleStage 用)
|
||||
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 被调用(stage2),SQL 含 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_attribute(OUT_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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 package(COMMERCIAL)+ 新 primary package(RESIDENTIAL)
|
||||
verify(abilityPackageMapper).selectById(2002L);
|
||||
verify(abilityPackageMapper).selectById(2001L);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -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 — 测试中抛 MybatisPlusException(eager 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`
|
||||
Loading…
Reference in New Issue