You've already forked DataMate
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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("拉取数据集失败");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user