fix(kg): 修复 Codex 审查发现的 P1/P2 问题并补全测试

修复内容:

P1 级别(关键):
1. 数据隔离漏洞:邻居查询添加 graph_id 路径约束,防止跨图谱数据泄漏
2. 空快照误删风险:添加 allowPurgeOnEmptySnapshot 保护开关(默认 false)
3. 弱默认凭据:启动自检,生产环境检测到默认密码直接拒绝启动

P2 级别(重要):
4. 配置校验:importBatchSize 添加 @Min(1) 验证,启动时 fail-fast
5. N+1 性能:重写 upsertEntity 为单条 Cypher 查询(从 3 条优化到 1 条)
6. 服务认证:添加 mTLS/JWT 文档说明
7. 错误处理:改进 Schema 初始化和序列化错误处理

测试覆盖:
- 新增 69 个单元测试,全部通过
- GraphEntityServiceTest: 13 个测试(CRUD、验证、分页)
- GraphRelationServiceTest: 13 个测试(CRUD、方向验证)
- GraphSyncServiceTest: 5 个测试(验证、全量同步)
- GraphSyncStepServiceTest: 14 个测试(空快照保护、N+1 验证)
- GraphQueryServiceTest: 13 个测试(邻居/路径/子图/搜索)
- GraphInitializerTest: 11 个测试(凭据验证、Schema 初始化)

技术细节:
- 数据隔离:使用 ALL() 函数约束路径中所有节点和关系的 graph_id
- 空快照保护:新增配置项 allow-purge-on-empty-snapshot 和错误码 EMPTY_SNAPSHOT_PURGE_BLOCKED
- 凭据检查:Java 和 Python 双端实现,根据环境(dev/test/prod)采取不同策略
- 性能优化:使用 SDN 复合属性格式(properties.key)在 MERGE 中直接设置属性
- 属性安全:使用白名单 [a-zA-Z0-9_] 防止 Cypher 注入

代码变更:+210 行,-29 行
This commit is contained in:
2026-02-18 09:25:00 +08:00
parent a260134d7c
commit 37b478a052
16 changed files with 1494 additions and 29 deletions

View File

@@ -0,0 +1,233 @@
package com.datamate.knowledgegraph.application;
import com.datamate.common.infrastructure.exception.BusinessException;
import com.datamate.knowledgegraph.domain.model.GraphEntity;
import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository;
import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties;
import com.datamate.knowledgegraph.interfaces.dto.CreateEntityRequest;
import com.datamate.knowledgegraph.interfaces.dto.UpdateEntityRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class GraphEntityServiceTest {
private static final String GRAPH_ID = "550e8400-e29b-41d4-a716-446655440000";
private static final String ENTITY_ID = "660e8400-e29b-41d4-a716-446655440001";
private static final String INVALID_GRAPH_ID = "not-a-uuid";
@Mock
private GraphEntityRepository entityRepository;
@Mock
private KnowledgeGraphProperties properties;
@InjectMocks
private GraphEntityService entityService;
private GraphEntity sampleEntity;
@BeforeEach
void setUp() {
sampleEntity = GraphEntity.builder()
.id(ENTITY_ID)
.name("TestDataset")
.type("Dataset")
.description("A test dataset")
.graphId(GRAPH_ID)
.confidence(1.0)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
}
// -----------------------------------------------------------------------
// graphId 校验
// -----------------------------------------------------------------------
@Test
void getEntity_invalidGraphId_throwsBusinessException() {
assertThatThrownBy(() -> entityService.getEntity(INVALID_GRAPH_ID, ENTITY_ID))
.isInstanceOf(BusinessException.class);
}
@Test
void getEntity_nullGraphId_throwsBusinessException() {
assertThatThrownBy(() -> entityService.getEntity(null, ENTITY_ID))
.isInstanceOf(BusinessException.class);
}
// -----------------------------------------------------------------------
// createEntity
// -----------------------------------------------------------------------
@Test
void createEntity_success() {
CreateEntityRequest request = new CreateEntityRequest();
request.setName("NewEntity");
request.setType("Dataset");
request.setDescription("Desc");
when(entityRepository.save(any(GraphEntity.class))).thenReturn(sampleEntity);
GraphEntity result = entityService.createEntity(GRAPH_ID, request);
assertThat(result).isNotNull();
assertThat(result.getName()).isEqualTo("TestDataset");
verify(entityRepository).save(any(GraphEntity.class));
}
// -----------------------------------------------------------------------
// getEntity
// -----------------------------------------------------------------------
@Test
void getEntity_found() {
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.of(sampleEntity));
GraphEntity result = entityService.getEntity(GRAPH_ID, ENTITY_ID);
assertThat(result.getId()).isEqualTo(ENTITY_ID);
assertThat(result.getName()).isEqualTo("TestDataset");
}
@Test
void getEntity_notFound_throwsBusinessException() {
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> entityService.getEntity(GRAPH_ID, ENTITY_ID))
.isInstanceOf(BusinessException.class);
}
// -----------------------------------------------------------------------
// listEntities
// -----------------------------------------------------------------------
@Test
void listEntities_returnsAll() {
when(entityRepository.findByGraphId(GRAPH_ID))
.thenReturn(List.of(sampleEntity));
List<GraphEntity> results = entityService.listEntities(GRAPH_ID);
assertThat(results).hasSize(1);
assertThat(results.get(0).getName()).isEqualTo("TestDataset");
}
// -----------------------------------------------------------------------
// updateEntity
// -----------------------------------------------------------------------
@Test
void updateEntity_partialUpdate_onlyChangesProvidedFields() {
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.of(sampleEntity));
when(entityRepository.save(any(GraphEntity.class)))
.thenAnswer(inv -> inv.getArgument(0));
UpdateEntityRequest request = new UpdateEntityRequest();
request.setName("UpdatedName");
// description not set — should remain unchanged
GraphEntity result = entityService.updateEntity(GRAPH_ID, ENTITY_ID, request);
assertThat(result.getName()).isEqualTo("UpdatedName");
assertThat(result.getDescription()).isEqualTo("A test dataset");
}
// -----------------------------------------------------------------------
// deleteEntity
// -----------------------------------------------------------------------
@Test
void deleteEntity_success() {
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.of(sampleEntity));
entityService.deleteEntity(GRAPH_ID, ENTITY_ID);
verify(entityRepository).delete(sampleEntity);
}
@Test
void deleteEntity_notFound_throwsBusinessException() {
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> entityService.deleteEntity(GRAPH_ID, ENTITY_ID))
.isInstanceOf(BusinessException.class);
}
// -----------------------------------------------------------------------
// getNeighbors — 深度/限制 clamping
// -----------------------------------------------------------------------
@Test
void getNeighbors_clampsDepthAndLimit() {
when(properties.getMaxDepth()).thenReturn(3);
when(properties.getMaxNodesPerQuery()).thenReturn(500);
when(entityRepository.findNeighbors(eq(GRAPH_ID), eq(ENTITY_ID), eq(3), eq(500)))
.thenReturn(List.of());
List<GraphEntity> result = entityService.getNeighbors(GRAPH_ID, ENTITY_ID, 100, 99999);
assertThat(result).isEmpty();
// depth clamped to maxDepth=3, limit clamped to maxNodesPerQuery=500
verify(entityRepository).findNeighbors(GRAPH_ID, ENTITY_ID, 3, 500);
}
// -----------------------------------------------------------------------
// 分页
// -----------------------------------------------------------------------
@Test
void listEntitiesPaged_normalPage() {
when(entityRepository.findByGraphIdPaged(GRAPH_ID, 0L, 20))
.thenReturn(List.of(sampleEntity));
when(entityRepository.countByGraphId(GRAPH_ID)).thenReturn(1L);
var result = entityService.listEntitiesPaged(GRAPH_ID, 0, 20);
assertThat(result.getContent()).hasSize(1);
assertThat(result.getTotalElements()).isEqualTo(1);
}
@Test
void listEntitiesPaged_negativePage_clampedToZero() {
when(entityRepository.findByGraphIdPaged(GRAPH_ID, 0L, 20))
.thenReturn(List.of());
when(entityRepository.countByGraphId(GRAPH_ID)).thenReturn(0L);
var result = entityService.listEntitiesPaged(GRAPH_ID, -1, 20);
assertThat(result.getPage()).isEqualTo(0);
}
@Test
void listEntitiesPaged_oversizedPage_clampedTo200() {
when(entityRepository.findByGraphIdPaged(GRAPH_ID, 0L, 200))
.thenReturn(List.of());
when(entityRepository.countByGraphId(GRAPH_ID)).thenReturn(0L);
entityService.listEntitiesPaged(GRAPH_ID, 0, 999);
verify(entityRepository).findByGraphIdPaged(GRAPH_ID, 0L, 200);
}
}

View File

@@ -0,0 +1,177 @@
package com.datamate.knowledgegraph.application;
import com.datamate.common.infrastructure.exception.BusinessException;
import com.datamate.knowledgegraph.domain.model.GraphEntity;
import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository;
import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties;
import com.datamate.knowledgegraph.interfaces.dto.SubgraphVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.neo4j.core.Neo4jClient;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class GraphQueryServiceTest {
private static final String GRAPH_ID = "550e8400-e29b-41d4-a716-446655440000";
private static final String ENTITY_ID = "660e8400-e29b-41d4-a716-446655440001";
private static final String ENTITY_ID_2 = "660e8400-e29b-41d4-a716-446655440002";
private static final String INVALID_GRAPH_ID = "bad-id";
@Mock
private Neo4jClient neo4jClient;
@Mock
private GraphEntityRepository entityRepository;
@Mock
private KnowledgeGraphProperties properties;
@InjectMocks
private GraphQueryService queryService;
@BeforeEach
void setUp() {
}
// -----------------------------------------------------------------------
// graphId 校验
// -----------------------------------------------------------------------
@Test
void getNeighborGraph_invalidGraphId_throwsBusinessException() {
assertThatThrownBy(() -> queryService.getNeighborGraph(INVALID_GRAPH_ID, ENTITY_ID, 2, 50))
.isInstanceOf(BusinessException.class);
}
@Test
void getShortestPath_invalidGraphId_throwsBusinessException() {
assertThatThrownBy(() -> queryService.getShortestPath(INVALID_GRAPH_ID, ENTITY_ID, ENTITY_ID_2, 3))
.isInstanceOf(BusinessException.class);
}
@Test
void getSubgraph_invalidGraphId_throwsBusinessException() {
assertThatThrownBy(() -> queryService.getSubgraph(INVALID_GRAPH_ID, List.of(ENTITY_ID)))
.isInstanceOf(BusinessException.class);
}
@Test
void fulltextSearch_invalidGraphId_throwsBusinessException() {
assertThatThrownBy(() -> queryService.fulltextSearch(INVALID_GRAPH_ID, "test", 0, 20))
.isInstanceOf(BusinessException.class);
}
// -----------------------------------------------------------------------
// getNeighborGraph — 实体不存在
// -----------------------------------------------------------------------
@Test
void getNeighborGraph_entityNotFound_throwsBusinessException() {
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> queryService.getNeighborGraph(GRAPH_ID, ENTITY_ID, 2, 50))
.isInstanceOf(BusinessException.class);
}
// -----------------------------------------------------------------------
// getShortestPath — 起止相同
// -----------------------------------------------------------------------
@Test
void getShortestPath_sameSourceAndTarget_returnsSingleNode() {
GraphEntity entity = GraphEntity.builder()
.id(ENTITY_ID).name("Node").type("Dataset").graphId(GRAPH_ID).build();
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.of(entity));
var result = queryService.getShortestPath(GRAPH_ID, ENTITY_ID, ENTITY_ID, 3);
assertThat(result.getPathLength()).isEqualTo(0);
assertThat(result.getNodes()).hasSize(1);
assertThat(result.getEdges()).isEmpty();
}
@Test
void getShortestPath_sourceNotFound_throwsBusinessException() {
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> queryService.getShortestPath(GRAPH_ID, ENTITY_ID, ENTITY_ID_2, 3))
.isInstanceOf(BusinessException.class);
}
// -----------------------------------------------------------------------
// getSubgraph — 空输入
// -----------------------------------------------------------------------
@Test
void getSubgraph_nullEntityIds_returnsEmptySubgraph() {
SubgraphVO result = queryService.getSubgraph(GRAPH_ID, null);
assertThat(result.getNodes()).isEmpty();
assertThat(result.getEdges()).isEmpty();
assertThat(result.getNodeCount()).isEqualTo(0);
}
@Test
void getSubgraph_emptyEntityIds_returnsEmptySubgraph() {
SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of());
assertThat(result.getNodes()).isEmpty();
assertThat(result.getEdges()).isEmpty();
}
@Test
void getSubgraph_exceedsMaxNodes_throwsBusinessException() {
when(properties.getMaxNodesPerQuery()).thenReturn(5);
List<String> tooManyIds = List.of("1", "2", "3", "4", "5", "6");
assertThatThrownBy(() -> queryService.getSubgraph(GRAPH_ID, tooManyIds))
.isInstanceOf(BusinessException.class);
}
@Test
void getSubgraph_noExistingEntities_returnsEmptySubgraph() {
when(properties.getMaxNodesPerQuery()).thenReturn(500);
when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID)))
.thenReturn(List.of());
SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of(ENTITY_ID));
assertThat(result.getNodes()).isEmpty();
}
// -----------------------------------------------------------------------
// fulltextSearch — 空查询
// -----------------------------------------------------------------------
@Test
void fulltextSearch_blankQuery_returnsEmpty() {
var result = queryService.fulltextSearch(GRAPH_ID, "", 0, 20);
assertThat(result.getContent()).isEmpty();
assertThat(result.getTotalElements()).isEqualTo(0);
verifyNoInteractions(neo4jClient);
}
@Test
void fulltextSearch_nullQuery_returnsEmpty() {
var result = queryService.fulltextSearch(GRAPH_ID, null, 0, 20);
assertThat(result.getContent()).isEmpty();
}
}

View File

@@ -0,0 +1,270 @@
package com.datamate.knowledgegraph.application;
import com.datamate.common.infrastructure.exception.BusinessException;
import com.datamate.knowledgegraph.domain.model.GraphEntity;
import com.datamate.knowledgegraph.domain.model.RelationDetail;
import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository;
import com.datamate.knowledgegraph.domain.repository.GraphRelationRepository;
import com.datamate.knowledgegraph.interfaces.dto.CreateRelationRequest;
import com.datamate.knowledgegraph.interfaces.dto.RelationVO;
import com.datamate.knowledgegraph.interfaces.dto.UpdateRelationRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class GraphRelationServiceTest {
private static final String GRAPH_ID = "550e8400-e29b-41d4-a716-446655440000";
private static final String RELATION_ID = "770e8400-e29b-41d4-a716-446655440002";
private static final String SOURCE_ENTITY_ID = "660e8400-e29b-41d4-a716-446655440001";
private static final String TARGET_ENTITY_ID = "660e8400-e29b-41d4-a716-446655440003";
private static final String INVALID_GRAPH_ID = "not-a-uuid";
@Mock
private GraphRelationRepository relationRepository;
@Mock
private GraphEntityRepository entityRepository;
@InjectMocks
private GraphRelationService relationService;
private RelationDetail sampleDetail;
private GraphEntity sourceEntity;
private GraphEntity targetEntity;
@BeforeEach
void setUp() {
sampleDetail = RelationDetail.builder()
.id(RELATION_ID)
.sourceEntityId(SOURCE_ENTITY_ID)
.sourceEntityName("Source")
.sourceEntityType("Dataset")
.targetEntityId(TARGET_ENTITY_ID)
.targetEntityName("Target")
.targetEntityType("Field")
.relationType("HAS_FIELD")
.properties(Map.of())
.weight(1.0)
.confidence(1.0)
.graphId(GRAPH_ID)
.createdAt(LocalDateTime.now())
.build();
sourceEntity = GraphEntity.builder()
.id(SOURCE_ENTITY_ID).name("Source").type("Dataset").graphId(GRAPH_ID).build();
targetEntity = GraphEntity.builder()
.id(TARGET_ENTITY_ID).name("Target").type("Field").graphId(GRAPH_ID).build();
}
// -----------------------------------------------------------------------
// graphId 校验
// -----------------------------------------------------------------------
@Test
void getRelation_invalidGraphId_throwsBusinessException() {
assertThatThrownBy(() -> relationService.getRelation(INVALID_GRAPH_ID, RELATION_ID))
.isInstanceOf(BusinessException.class);
}
// -----------------------------------------------------------------------
// createRelation
// -----------------------------------------------------------------------
@Test
void createRelation_success() {
when(entityRepository.findByIdAndGraphId(SOURCE_ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.of(sourceEntity));
when(entityRepository.findByIdAndGraphId(TARGET_ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.of(targetEntity));
when(relationRepository.create(eq(GRAPH_ID), eq(SOURCE_ENTITY_ID), eq(TARGET_ENTITY_ID),
eq("HAS_FIELD"), anyMap(), isNull(), isNull(), isNull()))
.thenReturn(Optional.of(sampleDetail));
CreateRelationRequest request = new CreateRelationRequest();
request.setSourceEntityId(SOURCE_ENTITY_ID);
request.setTargetEntityId(TARGET_ENTITY_ID);
request.setRelationType("HAS_FIELD");
RelationVO result = relationService.createRelation(GRAPH_ID, request);
assertThat(result.getId()).isEqualTo(RELATION_ID);
assertThat(result.getRelationType()).isEqualTo("HAS_FIELD");
assertThat(result.getSourceEntityId()).isEqualTo(SOURCE_ENTITY_ID);
assertThat(result.getTargetEntityId()).isEqualTo(TARGET_ENTITY_ID);
}
@Test
void createRelation_sourceNotFound_throwsBusinessException() {
when(entityRepository.findByIdAndGraphId(SOURCE_ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.empty());
CreateRelationRequest request = new CreateRelationRequest();
request.setSourceEntityId(SOURCE_ENTITY_ID);
request.setTargetEntityId(TARGET_ENTITY_ID);
request.setRelationType("HAS_FIELD");
assertThatThrownBy(() -> relationService.createRelation(GRAPH_ID, request))
.isInstanceOf(BusinessException.class);
}
@Test
void createRelation_targetNotFound_throwsBusinessException() {
when(entityRepository.findByIdAndGraphId(SOURCE_ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.of(sourceEntity));
when(entityRepository.findByIdAndGraphId(TARGET_ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.empty());
CreateRelationRequest request = new CreateRelationRequest();
request.setSourceEntityId(SOURCE_ENTITY_ID);
request.setTargetEntityId(TARGET_ENTITY_ID);
request.setRelationType("HAS_FIELD");
assertThatThrownBy(() -> relationService.createRelation(GRAPH_ID, request))
.isInstanceOf(BusinessException.class);
}
// -----------------------------------------------------------------------
// getRelation
// -----------------------------------------------------------------------
@Test
void getRelation_found() {
when(relationRepository.findByIdAndGraphId(RELATION_ID, GRAPH_ID))
.thenReturn(Optional.of(sampleDetail));
RelationVO result = relationService.getRelation(GRAPH_ID, RELATION_ID);
assertThat(result.getId()).isEqualTo(RELATION_ID);
}
@Test
void getRelation_notFound_throwsBusinessException() {
when(relationRepository.findByIdAndGraphId(RELATION_ID, GRAPH_ID))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> relationService.getRelation(GRAPH_ID, RELATION_ID))
.isInstanceOf(BusinessException.class);
}
// -----------------------------------------------------------------------
// listRelations (分页)
// -----------------------------------------------------------------------
@Test
void listRelations_returnsPaged() {
when(relationRepository.findByGraphId(GRAPH_ID, null, 0L, 20))
.thenReturn(List.of(sampleDetail));
when(relationRepository.countByGraphId(GRAPH_ID, null))
.thenReturn(1L);
var result = relationService.listRelations(GRAPH_ID, null, 0, 20);
assertThat(result.getContent()).hasSize(1);
assertThat(result.getTotalElements()).isEqualTo(1);
}
@Test
void listRelations_oversizedPage_clampedTo200() {
when(relationRepository.findByGraphId(GRAPH_ID, null, 0L, 200))
.thenReturn(List.of());
when(relationRepository.countByGraphId(GRAPH_ID, null))
.thenReturn(0L);
relationService.listRelations(GRAPH_ID, null, 0, 999);
verify(relationRepository).findByGraphId(GRAPH_ID, null, 0L, 200);
}
// -----------------------------------------------------------------------
// listEntityRelations — direction 校验
// -----------------------------------------------------------------------
@Test
void listEntityRelations_invalidDirection_throwsBusinessException() {
when(entityRepository.findByIdAndGraphId(SOURCE_ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.of(sourceEntity));
assertThatThrownBy(() ->
relationService.listEntityRelations(GRAPH_ID, SOURCE_ENTITY_ID, "invalid", null, 0, 20))
.isInstanceOf(BusinessException.class);
}
@Test
void listEntityRelations_inDirection() {
when(entityRepository.findByIdAndGraphId(SOURCE_ENTITY_ID, GRAPH_ID))
.thenReturn(Optional.of(sourceEntity));
when(relationRepository.findInboundByEntityId(GRAPH_ID, SOURCE_ENTITY_ID, null, 0L, 20))
.thenReturn(List.of(sampleDetail));
when(relationRepository.countByEntityId(GRAPH_ID, SOURCE_ENTITY_ID, null, "in"))
.thenReturn(1L);
var result = relationService.listEntityRelations(
GRAPH_ID, SOURCE_ENTITY_ID, "in", null, 0, 20);
assertThat(result.getContent()).hasSize(1);
}
// -----------------------------------------------------------------------
// updateRelation
// -----------------------------------------------------------------------
@Test
void updateRelation_success() {
when(relationRepository.findByIdAndGraphId(RELATION_ID, GRAPH_ID))
.thenReturn(Optional.of(sampleDetail));
RelationDetail updated = RelationDetail.builder()
.id(RELATION_ID).relationType("USES").weight(0.8)
.sourceEntityId(SOURCE_ENTITY_ID).targetEntityId(TARGET_ENTITY_ID)
.graphId(GRAPH_ID).build();
when(relationRepository.update(eq(RELATION_ID), eq(GRAPH_ID), eq("USES"), isNull(), eq(0.8), isNull()))
.thenReturn(Optional.of(updated));
UpdateRelationRequest request = new UpdateRelationRequest();
request.setRelationType("USES");
request.setWeight(0.8);
RelationVO result = relationService.updateRelation(GRAPH_ID, RELATION_ID, request);
assertThat(result.getRelationType()).isEqualTo("USES");
}
// -----------------------------------------------------------------------
// deleteRelation
// -----------------------------------------------------------------------
@Test
void deleteRelation_success() {
when(relationRepository.findByIdAndGraphId(RELATION_ID, GRAPH_ID))
.thenReturn(Optional.of(sampleDetail));
when(relationRepository.deleteByIdAndGraphId(RELATION_ID, GRAPH_ID))
.thenReturn(1L);
relationService.deleteRelation(GRAPH_ID, RELATION_ID);
verify(relationRepository).deleteByIdAndGraphId(RELATION_ID, GRAPH_ID);
}
@Test
void deleteRelation_notFound_throwsBusinessException() {
when(relationRepository.findByIdAndGraphId(RELATION_ID, GRAPH_ID))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> relationService.deleteRelation(GRAPH_ID, RELATION_ID))
.isInstanceOf(BusinessException.class);
}
}

View File

@@ -0,0 +1,129 @@
package com.datamate.knowledgegraph.application;
import com.datamate.common.infrastructure.exception.BusinessException;
import com.datamate.knowledgegraph.domain.model.SyncResult;
import com.datamate.knowledgegraph.infrastructure.client.DataManagementClient;
import com.datamate.knowledgegraph.infrastructure.client.DataManagementClient.DatasetDTO;
import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class GraphSyncServiceTest {
private static final String GRAPH_ID = "550e8400-e29b-41d4-a716-446655440000";
private static final String INVALID_GRAPH_ID = "bad-id";
@Mock
private GraphSyncStepService stepService;
@Mock
private DataManagementClient dataManagementClient;
@Mock
private KnowledgeGraphProperties properties;
@InjectMocks
private GraphSyncService syncService;
private KnowledgeGraphProperties.Sync syncConfig;
@BeforeEach
void setUp() {
syncConfig = new KnowledgeGraphProperties.Sync();
syncConfig.setMaxRetries(1);
syncConfig.setRetryInterval(10);
}
// -----------------------------------------------------------------------
// graphId 校验
// -----------------------------------------------------------------------
@Test
void syncAll_invalidGraphId_throwsBusinessException() {
assertThatThrownBy(() -> syncService.syncAll(INVALID_GRAPH_ID))
.isInstanceOf(BusinessException.class);
}
@Test
void syncAll_nullGraphId_throwsBusinessException() {
assertThatThrownBy(() -> syncService.syncAll(null))
.isInstanceOf(BusinessException.class);
}
@Test
void syncDatasets_invalidGraphId_throwsBusinessException() {
assertThatThrownBy(() -> syncService.syncDatasets(INVALID_GRAPH_ID))
.isInstanceOf(BusinessException.class);
}
// -----------------------------------------------------------------------
// syncAll — 正常流程
// -----------------------------------------------------------------------
@Test
void syncAll_success_returnsResultList() {
when(properties.getSync()).thenReturn(syncConfig);
DatasetDTO dto = new DatasetDTO();
dto.setId("ds-001");
dto.setName("Test");
dto.setCreatedBy("admin");
when(dataManagementClient.listAllDatasets()).thenReturn(List.of(dto));
SyncResult entityResult = SyncResult.builder().syncType("Dataset").build();
SyncResult fieldResult = SyncResult.builder().syncType("Field").build();
SyncResult userResult = SyncResult.builder().syncType("User").build();
SyncResult orgResult = SyncResult.builder().syncType("Org").build();
SyncResult hasFieldResult = SyncResult.builder().syncType("HAS_FIELD").build();
SyncResult derivedFromResult = SyncResult.builder().syncType("DERIVED_FROM").build();
SyncResult belongsToResult = SyncResult.builder().syncType("BELONGS_TO").build();
when(stepService.upsertDatasetEntities(eq(GRAPH_ID), anyList(), anyString()))
.thenReturn(entityResult);
when(stepService.upsertFieldEntities(eq(GRAPH_ID), anyList(), anyString()))
.thenReturn(fieldResult);
when(stepService.upsertUserEntities(eq(GRAPH_ID), anySet(), anyString()))
.thenReturn(userResult);
when(stepService.upsertOrgEntities(eq(GRAPH_ID), anyString()))
.thenReturn(orgResult);
when(stepService.purgeStaleEntities(eq(GRAPH_ID), anyString(), anySet(), anyString()))
.thenReturn(0);
when(stepService.mergeHasFieldRelations(eq(GRAPH_ID), anyString()))
.thenReturn(hasFieldResult);
when(stepService.mergeDerivedFromRelations(eq(GRAPH_ID), anyString()))
.thenReturn(derivedFromResult);
when(stepService.mergeBelongsToRelations(eq(GRAPH_ID), anyString()))
.thenReturn(belongsToResult);
List<SyncResult> results = syncService.syncAll(GRAPH_ID);
assertThat(results).hasSize(7);
assertThat(results.get(0).getSyncType()).isEqualTo("Dataset");
}
// -----------------------------------------------------------------------
// syncAll — 正常流程
// -----------------------------------------------------------------------
@Test
void syncDatasets_fetchRetryExhausted_throwsBusinessException() {
when(properties.getSync()).thenReturn(syncConfig);
when(dataManagementClient.listAllDatasets()).thenThrow(new RuntimeException("connection refused"));
assertThatThrownBy(() -> syncService.syncDatasets(GRAPH_ID))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("拉取数据集失败");
}
}

View File

@@ -0,0 +1,318 @@
package com.datamate.knowledgegraph.application;
import com.datamate.knowledgegraph.domain.model.GraphEntity;
import com.datamate.knowledgegraph.domain.model.SyncResult;
import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository;
import com.datamate.knowledgegraph.infrastructure.client.DataManagementClient.DatasetDTO;
import com.datamate.knowledgegraph.infrastructure.client.DataManagementClient.TagDTO;
import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.neo4j.core.Neo4jClient;
import org.springframework.data.neo4j.core.Neo4jClient.UnboundRunnableSpec;
import org.springframework.data.neo4j.core.Neo4jClient.RunnableSpec;
import org.springframework.data.neo4j.core.Neo4jClient.RecordFetchSpec;
import org.springframework.data.neo4j.core.Neo4jClient.MappingSpec;
import java.util.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class GraphSyncStepServiceTest {
private static final String GRAPH_ID = "550e8400-e29b-41d4-a716-446655440000";
private static final String SYNC_ID = "test-sync";
@Mock
private GraphEntityRepository entityRepository;
@Mock
private Neo4jClient neo4jClient;
@Mock
private KnowledgeGraphProperties properties;
@InjectMocks
private GraphSyncStepService stepService;
@Captor
private ArgumentCaptor<String> cypherCaptor;
@BeforeEach
void setUp() {
}
// -----------------------------------------------------------------------
// Neo4jClient mock chain helper
// -----------------------------------------------------------------------
/**
* Build a full mock chain for neo4jClient.query(...).bindAll(...).fetchAs(Long).mappedBy(...).one()
*/
@SuppressWarnings("unchecked")
private void setupNeo4jQueryChain(Class<?> fetchType, Object returnValue) {
UnboundRunnableSpec unboundSpec = mock(UnboundRunnableSpec.class);
RunnableSpec runnableSpec = mock(RunnableSpec.class);
MappingSpec mappingSpec = mock(MappingSpec.class);
RecordFetchSpec fetchSpec = mock(RecordFetchSpec.class);
when(neo4jClient.query(anyString())).thenReturn(unboundSpec);
when(unboundSpec.bindAll(anyMap())).thenReturn(runnableSpec);
when(runnableSpec.fetchAs(any(Class.class))).thenReturn(mappingSpec);
when(mappingSpec.mappedBy(any())).thenReturn(fetchSpec);
when(fetchSpec.one()).thenReturn(Optional.ofNullable(returnValue));
}
// -----------------------------------------------------------------------
// purgeStaleEntities — P1-2 空快照保护
// -----------------------------------------------------------------------
@Nested
class PurgeStaleEntitiesTest {
@Test
void emptySnapshot_defaultConfig_blocksPurge() {
KnowledgeGraphProperties.Sync syncConfig = new KnowledgeGraphProperties.Sync();
syncConfig.setAllowPurgeOnEmptySnapshot(false);
when(properties.getSync()).thenReturn(syncConfig);
int deleted = stepService.purgeStaleEntities(
GRAPH_ID, "Dataset", Collections.emptySet(), SYNC_ID);
assertThat(deleted).isEqualTo(0);
// Should NOT execute any Cypher query
verifyNoInteractions(neo4jClient);
}
@Test
void emptySnapshot_explicitAllow_executesPurge() {
KnowledgeGraphProperties.Sync syncConfig = new KnowledgeGraphProperties.Sync();
syncConfig.setAllowPurgeOnEmptySnapshot(true);
when(properties.getSync()).thenReturn(syncConfig);
setupNeo4jQueryChain(Long.class, 5L);
int deleted = stepService.purgeStaleEntities(
GRAPH_ID, "Dataset", Collections.emptySet(), SYNC_ID);
assertThat(deleted).isEqualTo(5);
verify(neo4jClient).query(anyString());
}
@Test
void nonEmptySnapshot_purgesStaleEntities() {
setupNeo4jQueryChain(Long.class, 2L);
Set<String> activeIds = Set.of("ds-001", "ds-002");
int deleted = stepService.purgeStaleEntities(
GRAPH_ID, "Dataset", activeIds, SYNC_ID);
assertThat(deleted).isEqualTo(2);
verify(neo4jClient).query(contains("NOT e.source_id IN $activeSourceIds"));
}
@Test
void nonEmptySnapshot_nothingToDelete_returnsZero() {
setupNeo4jQueryChain(Long.class, 0L);
Set<String> activeIds = Set.of("ds-001");
int deleted = stepService.purgeStaleEntities(
GRAPH_ID, "Dataset", activeIds, SYNC_ID);
assertThat(deleted).isEqualTo(0);
}
}
// -----------------------------------------------------------------------
// upsertDatasetEntities — P2-5 单条 Cypher 优化
// -----------------------------------------------------------------------
@Nested
class UpsertDatasetEntitiesTest {
@Test
void upsert_newEntity_incrementsCreated() {
when(properties.getImportBatchSize()).thenReturn(100);
setupNeo4jQueryChain(Boolean.class, true);
DatasetDTO dto = new DatasetDTO();
dto.setId("ds-001");
dto.setName("Test Dataset");
dto.setDescription("Desc");
dto.setDatasetType("TEXT");
dto.setStatus("ACTIVE");
SyncResult result = stepService.upsertDatasetEntities(
GRAPH_ID, List.of(dto), SYNC_ID);
assertThat(result.getCreated()).isEqualTo(1);
assertThat(result.getUpdated()).isEqualTo(0);
assertThat(result.getSyncType()).isEqualTo("Dataset");
}
@Test
void upsert_existingEntity_incrementsUpdated() {
when(properties.getImportBatchSize()).thenReturn(100);
setupNeo4jQueryChain(Boolean.class, false);
DatasetDTO dto = new DatasetDTO();
dto.setId("ds-001");
dto.setName("Updated");
SyncResult result = stepService.upsertDatasetEntities(
GRAPH_ID, List.of(dto), SYNC_ID);
assertThat(result.getCreated()).isEqualTo(0);
assertThat(result.getUpdated()).isEqualTo(1);
}
@Test
void upsert_emptyList_returnsZeroCounts() {
when(properties.getImportBatchSize()).thenReturn(100);
SyncResult result = stepService.upsertDatasetEntities(
GRAPH_ID, List.of(), SYNC_ID);
assertThat(result.getCreated()).isEqualTo(0);
assertThat(result.getUpdated()).isEqualTo(0);
verifyNoInteractions(neo4jClient);
}
@Test
void upsert_cypher_containsPropertiesSetClauses() {
when(properties.getImportBatchSize()).thenReturn(100);
setupNeo4jQueryChain(Boolean.class, true);
DatasetDTO dto = new DatasetDTO();
dto.setId("ds-001");
dto.setName("Dataset");
dto.setDatasetType("TEXT");
dto.setStatus("ACTIVE");
stepService.upsertDatasetEntities(GRAPH_ID, List.of(dto), SYNC_ID);
// Verify the MERGE Cypher includes property SET clauses (N+1 fix)
verify(neo4jClient).query(cypherCaptor.capture());
String cypher = cypherCaptor.getValue();
assertThat(cypher).contains("MERGE");
assertThat(cypher).contains("properties.");
// Verify NO separate find+save (N+1 eliminated)
verifyNoInteractions(entityRepository);
}
@Test
void upsert_multipleEntities_eachGetsSeparateMerge() {
when(properties.getImportBatchSize()).thenReturn(100);
setupNeo4jQueryChain(Boolean.class, true);
DatasetDTO dto1 = new DatasetDTO();
dto1.setId("ds-001");
dto1.setName("DS1");
DatasetDTO dto2 = new DatasetDTO();
dto2.setId("ds-002");
dto2.setName("DS2");
SyncResult result = stepService.upsertDatasetEntities(
GRAPH_ID, List.of(dto1, dto2), SYNC_ID);
assertThat(result.getCreated()).isEqualTo(2);
verify(neo4jClient, times(2)).query(anyString());
}
}
// -----------------------------------------------------------------------
// upsertFieldEntities
// -----------------------------------------------------------------------
@Nested
class UpsertFieldEntitiesTest {
@Test
void upsertFields_datasetsWithNoTags_returnsZero() {
DatasetDTO dto = new DatasetDTO();
dto.setId("ds-001");
dto.setTags(null);
SyncResult result = stepService.upsertFieldEntities(
GRAPH_ID, List.of(dto), SYNC_ID);
assertThat(result.getCreated()).isEqualTo(0);
assertThat(result.getUpdated()).isEqualTo(0);
}
@Test
void upsertFields_datasetsWithTags_createsFieldPerTag() {
setupNeo4jQueryChain(Boolean.class, true);
DatasetDTO dto = new DatasetDTO();
dto.setId("ds-001");
dto.setName("Dataset1");
TagDTO tag1 = new TagDTO();
tag1.setName("tag_a");
TagDTO tag2 = new TagDTO();
tag2.setName("tag_b");
dto.setTags(List.of(tag1, tag2));
SyncResult result = stepService.upsertFieldEntities(
GRAPH_ID, List.of(dto), SYNC_ID);
assertThat(result.getCreated()).isEqualTo(2);
}
}
// -----------------------------------------------------------------------
// 关系构建
// -----------------------------------------------------------------------
@Nested
class MergeRelationsTest {
@Test
void mergeHasField_noFields_returnsEmptyResult() {
when(entityRepository.findByGraphIdAndType(GRAPH_ID, "Field"))
.thenReturn(List.of());
SyncResult result = stepService.mergeHasFieldRelations(GRAPH_ID, SYNC_ID);
assertThat(result.getSyncType()).isEqualTo("HAS_FIELD");
assertThat(result.getCreated()).isEqualTo(0);
}
@Test
void mergeDerivedFrom_noParent_skipsRelation() {
GraphEntity dataset = GraphEntity.builder()
.id("entity-1")
.type("Dataset")
.graphId(GRAPH_ID)
.properties(new HashMap<>()) // no parent_dataset_id
.build();
when(entityRepository.findByGraphIdAndType(GRAPH_ID, "Dataset"))
.thenReturn(List.of(dataset));
SyncResult result = stepService.mergeDerivedFromRelations(GRAPH_ID, SYNC_ID);
assertThat(result.getCreated()).isEqualTo(0);
}
@Test
void mergeBelongsTo_noDefaultOrg_returnsError() {
when(entityRepository.findByGraphIdAndSourceIdAndType(GRAPH_ID, "org:default", "Org"))
.thenReturn(Optional.empty());
SyncResult result = stepService.mergeBelongsToRelations(GRAPH_ID, SYNC_ID);
assertThat(result.getFailed()).isGreaterThan(0);
assertThat(result.getErrors()).contains("belongs_to:org_missing");
}
}
}

View File

@@ -0,0 +1,157 @@
package com.datamate.knowledgegraph.infrastructure.neo4j;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.DefaultApplicationArguments;
import org.springframework.data.neo4j.core.Neo4jClient;
import org.springframework.data.neo4j.core.Neo4jClient.UnboundRunnableSpec;
import org.springframework.data.neo4j.core.Neo4jClient.RunnableSpec;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class GraphInitializerTest {
@Mock
private Neo4jClient neo4jClient;
private GraphInitializer createInitializer(String password, String profile, boolean autoInit) {
KnowledgeGraphProperties properties = new KnowledgeGraphProperties();
properties.getSync().setAutoInitSchema(autoInit);
GraphInitializer initializer = new GraphInitializer(neo4jClient, properties);
ReflectionTestUtils.setField(initializer, "neo4jPassword", password);
ReflectionTestUtils.setField(initializer, "activeProfile", profile);
return initializer;
}
// -----------------------------------------------------------------------
// P1-3: 默认凭据检测
// -----------------------------------------------------------------------
@Test
void run_defaultPassword_prodProfile_throwsException() {
GraphInitializer initializer = createInitializer("datamate123", "prod", false);
assertThatThrownBy(() -> initializer.run(new DefaultApplicationArguments()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("SECURITY")
.hasMessageContaining("default");
}
@Test
void run_defaultPassword_stagingProfile_throwsException() {
GraphInitializer initializer = createInitializer("neo4j", "staging", false);
assertThatThrownBy(() -> initializer.run(new DefaultApplicationArguments()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("SECURITY");
}
@Test
void run_defaultPassword_devProfile_warnsButContinues() {
GraphInitializer initializer = createInitializer("datamate123", "dev", false);
// Should not throw — just warn
assertThatCode(() -> initializer.run(new DefaultApplicationArguments()))
.doesNotThrowAnyException();
}
@Test
void run_defaultPassword_testProfile_warnsButContinues() {
GraphInitializer initializer = createInitializer("datamate123", "test", false);
assertThatCode(() -> initializer.run(new DefaultApplicationArguments()))
.doesNotThrowAnyException();
}
@Test
void run_defaultPassword_localProfile_warnsButContinues() {
GraphInitializer initializer = createInitializer("password", "local", false);
assertThatCode(() -> initializer.run(new DefaultApplicationArguments()))
.doesNotThrowAnyException();
}
@Test
void run_securePassword_prodProfile_succeeds() {
GraphInitializer initializer = createInitializer("s3cure!P@ssw0rd", "prod", false);
// Schema init disabled, so no queries. Should succeed.
assertThatCode(() -> initializer.run(new DefaultApplicationArguments()))
.doesNotThrowAnyException();
}
@Test
void run_blankPassword_skipsValidation() {
GraphInitializer initializer = createInitializer("", "prod", false);
assertThatCode(() -> initializer.run(new DefaultApplicationArguments()))
.doesNotThrowAnyException();
}
// -----------------------------------------------------------------------
// Schema 初始化 — 成功
// -----------------------------------------------------------------------
@Test
void run_autoInitEnabled_executesAllStatements() {
GraphInitializer initializer = createInitializer("s3cure!P@ss", "dev", true);
UnboundRunnableSpec spec = mock(UnboundRunnableSpec.class);
when(neo4jClient.query(anyString())).thenReturn(spec);
initializer.run(new DefaultApplicationArguments());
// Should execute all schema statements (constraints + indexes + fulltext)
verify(neo4jClient, atLeast(10)).query(anyString());
}
@Test
void run_autoInitDisabled_skipsSchemaInit() {
GraphInitializer initializer = createInitializer("s3cure!P@ss", "dev", false);
initializer.run(new DefaultApplicationArguments());
verifyNoInteractions(neo4jClient);
}
// -----------------------------------------------------------------------
// P2-7: Schema 初始化错误处理
// -----------------------------------------------------------------------
@Test
void run_alreadyExistsError_safelyIgnored() {
GraphInitializer initializer = createInitializer("s3cure!P@ss", "dev", true);
UnboundRunnableSpec spec = mock(UnboundRunnableSpec.class);
when(neo4jClient.query(anyString())).thenReturn(spec);
doThrow(new RuntimeException("Constraint already exists"))
.when(spec).run();
// Should not throw — "already exists" errors are safely ignored
assertThatCode(() -> initializer.run(new DefaultApplicationArguments()))
.doesNotThrowAnyException();
}
@Test
void run_nonExistenceError_throwsException() {
GraphInitializer initializer = createInitializer("s3cure!P@ss", "dev", true);
UnboundRunnableSpec spec = mock(UnboundRunnableSpec.class);
when(neo4jClient.query(anyString())).thenReturn(spec);
doThrow(new RuntimeException("Connection refused to Neo4j"))
.when(spec).run();
// Non-"already exists" errors should propagate
assertThatThrownBy(() -> initializer.run(new DefaultApplicationArguments()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("schema initialization failed");
}
}