From 74daed1c25ca379fd3416784ebc9bcb1cdcdfc7e Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Wed, 18 Feb 2026 14:00:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(kg):=20=E5=AE=9E=E7=8E=B0=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=97=B6=E9=97=B4=E7=AA=97=E5=8F=A3=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=EF=BC=88P0-03=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 DataManagementClient 的 5 个 listAll* 方法添加时间窗口过滤: - listAllDatasets(updatedFrom, updatedTo) - listAllWorkflows(updatedFrom, updatedTo) - listAllJobs(updatedFrom, updatedTo) - listAllLabelTasks(updatedFrom, updatedTo) - listAllKnowledgeSets(updatedFrom, updatedTo) 特性: - 时间参数构建为 HTTP 查询参数(ISO_LOCAL_DATE_TIME 格式) - 客户端侧双重过滤(兼容上游未支持的场景) - 参数校验:updatedFrom > updatedTo 时抛出异常 - null 元素安全处理:过滤列表中的 null 项 - 无参版本保留向后兼容 测试: - 新增 DataManagementClientTest.java(28 个测试用例) - 覆盖 URL 参数拼接、参数校验、本地过滤、null 安全、分页等场景 - 测试结果:162 tests, 0 failures 代码审查: - Codex 两轮审查通过 - 修复 P2 问题:null 元素安全处理 --- .../client/DataManagementClient.java | 194 +++++- .../client/DataManagementClientTest.java | 649 ++++++++++++++++++ 2 files changed, 838 insertions(+), 5 deletions(-) create mode 100644 backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/infrastructure/client/DataManagementClientTest.java diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/client/DataManagementClient.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/client/DataManagementClient.java index 9c5008d..0bd884e 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/client/DataManagementClient.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/client/DataManagementClient.java @@ -13,8 +13,15 @@ import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; /** * 数据管理服务 REST 客户端。 @@ -26,6 +33,10 @@ import java.util.List; @Slf4j public class DataManagementClient { + private static final String UPDATED_FROM_PARAM = "updatedFrom"; + private static final String UPDATED_TO_PARAM = "updatedTo"; + private static final DateTimeFormatter DATETIME_QUERY_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; + private final RestTemplate restTemplate; private final String baseUrl; private final String annotationBaseUrl; @@ -44,6 +55,109 @@ public class DataManagementClient { * 拉取所有数据集(自动分页)。 */ public List listAllDatasets() { + return listAllDatasets(null, null); + } + + /** + * 拉取所有数据集(自动分页)并按更新时间窗口过滤。 + *

+ * 时间窗口参数会透传给上游服务;同时在本地再过滤一次, + * 以兼容上游暂未支持该查询参数的场景。 + */ + public List listAllDatasets(LocalDateTime updatedFrom, LocalDateTime updatedTo) { + Map timeWindowQuery = buildTimeWindowQuery(updatedFrom, updatedTo); + List datasets = fetchAllPaged( + baseUrl + "/data-management/datasets", + new ParameterizedTypeReference>() {}, + "datasets", + timeWindowQuery); + return filterByUpdatedAt(datasets, DatasetDTO::getUpdatedAt, updatedFrom, updatedTo); + } + + /** + * 拉取所有工作流(自动分页)。 + */ + public List listAllWorkflows() { + return listAllWorkflows(null, null); + } + + /** + * 拉取所有工作流(自动分页)并按更新时间窗口过滤。 + */ + public List listAllWorkflows(LocalDateTime updatedFrom, LocalDateTime updatedTo) { + Map timeWindowQuery = buildTimeWindowQuery(updatedFrom, updatedTo); + List workflows = fetchAllPaged( + baseUrl + "/data-management/workflows", + new ParameterizedTypeReference>() {}, + "workflows", + timeWindowQuery); + return filterByUpdatedAt(workflows, WorkflowDTO::getUpdatedAt, updatedFrom, updatedTo); + } + + /** + * 拉取所有作业(自动分页)。 + */ + public List listAllJobs() { + return listAllJobs(null, null); + } + + /** + * 拉取所有作业(自动分页)并按更新时间窗口过滤。 + */ + public List listAllJobs(LocalDateTime updatedFrom, LocalDateTime updatedTo) { + Map timeWindowQuery = buildTimeWindowQuery(updatedFrom, updatedTo); + List jobs = fetchAllPaged( + baseUrl + "/data-management/jobs", + new ParameterizedTypeReference>() {}, + "jobs", + timeWindowQuery); + return filterByUpdatedAt(jobs, JobDTO::getUpdatedAt, updatedFrom, updatedTo); + } + + /** + * 拉取所有标注任务(自动分页,从标注服务)。 + */ + public List listAllLabelTasks() { + return listAllLabelTasks(null, null); + } + + /** + * 拉取所有标注任务(自动分页,从标注服务)并按更新时间窗口过滤。 + */ + public List listAllLabelTasks(LocalDateTime updatedFrom, LocalDateTime updatedTo) { + Map timeWindowQuery = buildTimeWindowQuery(updatedFrom, updatedTo); + List tasks = fetchAllPaged( + annotationBaseUrl + "/annotation/label-tasks", + new ParameterizedTypeReference>() {}, + "label-tasks", + timeWindowQuery); + return filterByUpdatedAt(tasks, LabelTaskDTO::getUpdatedAt, updatedFrom, updatedTo); + } + + /** + * 拉取所有知识集(自动分页)。 + */ + public List listAllKnowledgeSets() { + return listAllKnowledgeSets(null, null); + } + + /** + * 拉取所有知识集(自动分页)并按更新时间窗口过滤。 + */ + public List listAllKnowledgeSets(LocalDateTime updatedFrom, LocalDateTime updatedTo) { + Map timeWindowQuery = buildTimeWindowQuery(updatedFrom, updatedTo); + List sets = fetchAllPaged( + baseUrl + "/data-management/knowledge-sets", + new ParameterizedTypeReference>() {}, + "knowledge-sets", + timeWindowQuery); + return filterByUpdatedAt(sets, KnowledgeSetDTO::getUpdatedAt, updatedFrom, updatedTo); + } + + /** + * 拉取所有数据集(自动分页)。 + */ + public List listAllDatasetsLegacy() { return fetchAllPaged( baseUrl + "/data-management/datasets", new ParameterizedTypeReference>() {}, @@ -53,7 +167,7 @@ public class DataManagementClient { /** * 拉取所有工作流(自动分页)。 */ - public List listAllWorkflows() { + public List listAllWorkflowsLegacy() { return fetchAllPaged( baseUrl + "/data-management/workflows", new ParameterizedTypeReference>() {}, @@ -63,7 +177,7 @@ public class DataManagementClient { /** * 拉取所有作业(自动分页)。 */ - public List listAllJobs() { + public List listAllJobsLegacy() { return fetchAllPaged( baseUrl + "/data-management/jobs", new ParameterizedTypeReference>() {}, @@ -73,7 +187,7 @@ public class DataManagementClient { /** * 拉取所有标注任务(自动分页,从标注服务)。 */ - public List listAllLabelTasks() { + public List listAllLabelTasksLegacy() { return fetchAllPaged( annotationBaseUrl + "/annotation/label-tasks", new ParameterizedTypeReference>() {}, @@ -83,7 +197,7 @@ public class DataManagementClient { /** * 拉取所有知识集(自动分页)。 */ - public List listAllKnowledgeSets() { + public List listAllKnowledgeSetsLegacy() { return fetchAllPaged( baseUrl + "/data-management/knowledge-sets", new ParameterizedTypeReference>() {}, @@ -96,11 +210,21 @@ public class DataManagementClient { private List fetchAllPaged(String baseEndpoint, ParameterizedTypeReference> typeRef, String resourceName) { + return fetchAllPaged(baseEndpoint, typeRef, resourceName, Collections.emptyMap()); + } + + /** + * 通用自动分页拉取方法(支持附加查询参数)。 + */ + private List fetchAllPaged(String baseEndpoint, + ParameterizedTypeReference> typeRef, + String resourceName, + Map extraQueryParams) { List allItems = new ArrayList<>(); int page = 0; while (true) { - String url = baseEndpoint + "?page=" + page + "&size=" + pageSize; + String url = buildPagedUrl(baseEndpoint, page, extraQueryParams); log.debug("Fetching {}: page={}, size={}", resourceName, page, pageSize); try { @@ -130,6 +254,66 @@ public class DataManagementClient { return allItems; } + private String buildPagedUrl(String baseEndpoint, int page, Map extraQueryParams) { + StringBuilder builder = new StringBuilder(baseEndpoint) + .append("?page=").append(page) + .append("&size=").append(pageSize); + + if (extraQueryParams != null && !extraQueryParams.isEmpty()) { + extraQueryParams.forEach((key, value) -> { + if (key == null || key.isBlank() || value == null || value.isBlank()) { + return; + } + builder.append("&") + .append(URLEncoder.encode(key, StandardCharsets.UTF_8)) + .append("=") + .append(URLEncoder.encode(value, StandardCharsets.UTF_8)); + }); + } + return builder.toString(); + } + + private static Map buildTimeWindowQuery(LocalDateTime updatedFrom, LocalDateTime updatedTo) { + if (updatedFrom != null && updatedTo != null && updatedFrom.isAfter(updatedTo)) { + throw new IllegalArgumentException("updatedFrom must be less than or equal to updatedTo"); + } + + Map query = new LinkedHashMap<>(); + if (updatedFrom != null) { + query.put(UPDATED_FROM_PARAM, DATETIME_QUERY_FORMATTER.format(updatedFrom)); + } + if (updatedTo != null) { + query.put(UPDATED_TO_PARAM, DATETIME_QUERY_FORMATTER.format(updatedTo)); + } + return query; + } + + private static List filterByUpdatedAt( + List items, + Function updatedAtGetter, + LocalDateTime updatedFrom, + LocalDateTime updatedTo) { + if ((updatedFrom == null && updatedTo == null) || items == null || items.isEmpty()) { + return items; + } + + return items.stream() + .filter(item -> { + if (item == null) { + return false; + } + LocalDateTime updatedAt = updatedAtGetter.apply(item); + if (updatedAt == null) { + return false; + } + if (updatedFrom != null && updatedAt.isBefore(updatedFrom)) { + return false; + } + return updatedTo == null || !updatedAt.isAfter(updatedTo); + }) + .toList(); + } + // ----------------------------------------------------------------------- // 响应 DTO(仅包含同步所需字段) // ----------------------------------------------------------------------- diff --git a/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/infrastructure/client/DataManagementClientTest.java b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/infrastructure/client/DataManagementClientTest.java new file mode 100644 index 0000000..f538767 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/infrastructure/client/DataManagementClientTest.java @@ -0,0 +1,649 @@ +package com.datamate.knowledgegraph.infrastructure.client; + +import com.datamate.knowledgegraph.infrastructure.client.DataManagementClient.DatasetDTO; +import com.datamate.knowledgegraph.infrastructure.client.DataManagementClient.JobDTO; +import com.datamate.knowledgegraph.infrastructure.client.DataManagementClient.KnowledgeSetDTO; +import com.datamate.knowledgegraph.infrastructure.client.DataManagementClient.LabelTaskDTO; +import com.datamate.knowledgegraph.infrastructure.client.DataManagementClient.PagedResult; +import com.datamate.knowledgegraph.infrastructure.client.DataManagementClient.WorkflowDTO; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDateTime; +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.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.argThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DataManagementClientTest { + + private static final String BASE_URL = "http://dm-service:8080"; + private static final String ANNOTATION_URL = "http://annotation-service:8081"; + private static final int PAGE_SIZE = 10; + + @Mock + private RestTemplate restTemplate; + + private DataManagementClient client; + + @BeforeEach + void setUp() { + KnowledgeGraphProperties properties = new KnowledgeGraphProperties(); + KnowledgeGraphProperties.Sync sync = new KnowledgeGraphProperties.Sync(); + sync.setDataManagementUrl(BASE_URL); + sync.setAnnotationServiceUrl(ANNOTATION_URL); + sync.setPageSize(PAGE_SIZE); + properties.setSync(sync); + + client = new DataManagementClient(restTemplate, properties); + } + + // ----------------------------------------------------------------------- + // Helper methods + // ----------------------------------------------------------------------- + + private PagedResult pagedResult(List content, int page, int totalPages) { + PagedResult result = new PagedResult<>(); + result.setContent(content); + result.setPage(page); + result.setTotalPages(totalPages); + result.setTotalElements(content.size()); + return result; + } + + private DatasetDTO dataset(String id, LocalDateTime updatedAt) { + DatasetDTO dto = new DatasetDTO(); + dto.setId(id); + dto.setName("dataset-" + id); + dto.setUpdatedAt(updatedAt); + return dto; + } + + private WorkflowDTO workflow(String id, LocalDateTime updatedAt) { + WorkflowDTO dto = new WorkflowDTO(); + dto.setId(id); + dto.setName("workflow-" + id); + dto.setUpdatedAt(updatedAt); + return dto; + } + + private JobDTO job(String id, LocalDateTime updatedAt) { + JobDTO dto = new JobDTO(); + dto.setId(id); + dto.setName("job-" + id); + dto.setUpdatedAt(updatedAt); + return dto; + } + + private LabelTaskDTO labelTask(String id, LocalDateTime updatedAt) { + LabelTaskDTO dto = new LabelTaskDTO(); + dto.setId(id); + dto.setName("label-task-" + id); + dto.setUpdatedAt(updatedAt); + return dto; + } + + private KnowledgeSetDTO knowledgeSet(String id, LocalDateTime updatedAt) { + KnowledgeSetDTO dto = new KnowledgeSetDTO(); + dto.setId(id); + dto.setName("knowledge-set-" + id); + dto.setUpdatedAt(updatedAt); + return dto; + } + + @SuppressWarnings("unchecked") + private void stubSinglePageResponse(List content) { + PagedResult paged = pagedResult(content, 0, 1); + when(restTemplate.exchange( + any(String.class), eq(HttpMethod.GET), isNull(), + any(ParameterizedTypeReference.class))) + .thenReturn(ResponseEntity.ok(paged)); + } + + // ----------------------------------------------------------------------- + // Time window URL parameter tests + // ----------------------------------------------------------------------- + + @Nested + class TimeWindowUrlTests { + + @Test + void listAllDatasets_withTimeWindow_passesQueryParams() { + LocalDateTime from = LocalDateTime.of(2025, 1, 1, 0, 0, 0); + LocalDateTime to = LocalDateTime.of(2025, 6, 30, 23, 59, 59); + + DatasetDTO ds = dataset("ds-1", LocalDateTime.of(2025, 3, 15, 10, 0)); + stubSinglePageResponse(List.of(ds)); + + client.listAllDatasets(from, to); + + ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); + verify(restTemplate).exchange( + urlCaptor.capture(), eq(HttpMethod.GET), isNull(), + any(ParameterizedTypeReference.class)); + + String url = urlCaptor.getValue(); + assertThat(url).contains("updatedFrom=2025-01-01T00%3A00%3A00"); + assertThat(url).contains("updatedTo=2025-06-30T23%3A59%3A59"); + assertThat(url).contains("page=0"); + assertThat(url).contains("size=" + PAGE_SIZE); + } + + @Test + void listAllDatasets_withOnlyUpdatedFrom_passesOnlyFromParam() { + LocalDateTime from = LocalDateTime.of(2025, 3, 1, 0, 0, 0); + + DatasetDTO ds = dataset("ds-1", LocalDateTime.of(2025, 5, 1, 12, 0)); + stubSinglePageResponse(List.of(ds)); + + client.listAllDatasets(from, null); + + ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); + verify(restTemplate).exchange( + urlCaptor.capture(), eq(HttpMethod.GET), isNull(), + any(ParameterizedTypeReference.class)); + + String url = urlCaptor.getValue(); + assertThat(url).contains("updatedFrom=2025-03-01T00%3A00%3A00"); + assertThat(url).doesNotContain("updatedTo"); + } + + @Test + void listAllDatasets_withOnlyUpdatedTo_passesOnlyToParam() { + LocalDateTime to = LocalDateTime.of(2025, 12, 31, 23, 59, 59); + + DatasetDTO ds = dataset("ds-1", LocalDateTime.of(2025, 6, 1, 0, 0)); + stubSinglePageResponse(List.of(ds)); + + client.listAllDatasets(null, to); + + ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); + verify(restTemplate).exchange( + urlCaptor.capture(), eq(HttpMethod.GET), isNull(), + any(ParameterizedTypeReference.class)); + + String url = urlCaptor.getValue(); + assertThat(url).doesNotContain("updatedFrom"); + assertThat(url).contains("updatedTo=2025-12-31T23%3A59%3A59"); + } + + @Test + void listAllDatasets_noTimeWindow_omitsTimeParams() { + DatasetDTO ds = dataset("ds-1", LocalDateTime.of(2025, 1, 1, 0, 0)); + stubSinglePageResponse(List.of(ds)); + + client.listAllDatasets(null, null); + + ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); + verify(restTemplate).exchange( + urlCaptor.capture(), eq(HttpMethod.GET), isNull(), + any(ParameterizedTypeReference.class)); + + String url = urlCaptor.getValue(); + assertThat(url).doesNotContain("updatedFrom"); + assertThat(url).doesNotContain("updatedTo"); + } + + @Test + void noArgOverload_delegatesToTimeWindowVersion_omitsTimeParams() { + DatasetDTO ds = dataset("ds-1", LocalDateTime.of(2025, 1, 1, 0, 0)); + stubSinglePageResponse(List.of(ds)); + + client.listAllDatasets(); + + ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); + verify(restTemplate).exchange( + urlCaptor.capture(), eq(HttpMethod.GET), isNull(), + any(ParameterizedTypeReference.class)); + + String url = urlCaptor.getValue(); + assertThat(url).doesNotContain("updatedFrom"); + assertThat(url).doesNotContain("updatedTo"); + } + } + + // ----------------------------------------------------------------------- + // Validation + // ----------------------------------------------------------------------- + + @Nested + class ValidationTests { + + @Test + void listAllDatasets_updatedFromAfterUpdatedTo_throwsIllegalArgument() { + LocalDateTime from = LocalDateTime.of(2025, 12, 31, 23, 59, 59); + LocalDateTime to = LocalDateTime.of(2025, 1, 1, 0, 0, 0); + + assertThatThrownBy(() -> client.listAllDatasets(from, to)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("updatedFrom must be less than or equal to updatedTo"); + } + + @Test + void listAllWorkflows_updatedFromAfterUpdatedTo_throwsIllegalArgument() { + LocalDateTime from = LocalDateTime.of(2025, 6, 1, 0, 0); + LocalDateTime to = LocalDateTime.of(2025, 1, 1, 0, 0); + + assertThatThrownBy(() -> client.listAllWorkflows(from, to)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void listAllJobs_updatedFromAfterUpdatedTo_throwsIllegalArgument() { + LocalDateTime from = LocalDateTime.of(2025, 6, 1, 0, 0); + LocalDateTime to = LocalDateTime.of(2025, 1, 1, 0, 0); + + assertThatThrownBy(() -> client.listAllJobs(from, to)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void listAllLabelTasks_updatedFromAfterUpdatedTo_throwsIllegalArgument() { + LocalDateTime from = LocalDateTime.of(2025, 6, 1, 0, 0); + LocalDateTime to = LocalDateTime.of(2025, 1, 1, 0, 0); + + assertThatThrownBy(() -> client.listAllLabelTasks(from, to)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void listAllKnowledgeSets_updatedFromAfterUpdatedTo_throwsIllegalArgument() { + LocalDateTime from = LocalDateTime.of(2025, 6, 1, 0, 0); + LocalDateTime to = LocalDateTime.of(2025, 1, 1, 0, 0); + + assertThatThrownBy(() -> client.listAllKnowledgeSets(from, to)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void listAllDatasets_sameFromAndTo_doesNotThrow() { + LocalDateTime ts = LocalDateTime.of(2025, 6, 1, 12, 0, 0); + + DatasetDTO ds = dataset("ds-1", ts); + stubSinglePageResponse(List.of(ds)); + + List result = client.listAllDatasets(ts, ts); + + assertThat(result).hasSize(1); + } + } + + // ----------------------------------------------------------------------- + // Local filtering (client-side updatedAt filter) + // ----------------------------------------------------------------------- + + @Nested + class LocalFilteringTests { + + @Test + void listAllDatasets_filtersItemsBeforeUpdatedFrom() { + LocalDateTime from = LocalDateTime.of(2025, 6, 1, 0, 0, 0); + LocalDateTime to = LocalDateTime.of(2025, 12, 31, 23, 59, 59); + + DatasetDTO old = dataset("ds-old", LocalDateTime.of(2025, 1, 1, 0, 0)); + DatasetDTO recent = dataset("ds-recent", LocalDateTime.of(2025, 7, 1, 0, 0)); + stubSinglePageResponse(List.of(old, recent)); + + List result = client.listAllDatasets(from, to); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getId()).isEqualTo("ds-recent"); + } + + @Test + void listAllDatasets_filtersItemsAfterUpdatedTo() { + LocalDateTime from = LocalDateTime.of(2025, 1, 1, 0, 0, 0); + LocalDateTime to = LocalDateTime.of(2025, 6, 30, 23, 59, 59); + + DatasetDTO inRange = dataset("ds-in", LocalDateTime.of(2025, 3, 15, 10, 0)); + DatasetDTO tooNew = dataset("ds-new", LocalDateTime.of(2025, 9, 1, 0, 0)); + stubSinglePageResponse(List.of(inRange, tooNew)); + + List result = client.listAllDatasets(from, to); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getId()).isEqualTo("ds-in"); + } + + @Test + void listAllDatasets_filtersItemsWithNullUpdatedAt() { + LocalDateTime from = LocalDateTime.of(2025, 1, 1, 0, 0, 0); + LocalDateTime to = LocalDateTime.of(2025, 12, 31, 23, 59, 59); + + DatasetDTO withTime = dataset("ds-with", LocalDateTime.of(2025, 6, 1, 0, 0)); + DatasetDTO noTime = dataset("ds-null", null); + stubSinglePageResponse(List.of(withTime, noTime)); + + List result = client.listAllDatasets(from, to); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getId()).isEqualTo("ds-with"); + } + + @Test + void listAllDatasets_includesBoundaryValues() { + LocalDateTime from = LocalDateTime.of(2025, 6, 1, 0, 0, 0); + LocalDateTime to = LocalDateTime.of(2025, 6, 30, 23, 59, 59); + + DatasetDTO exactFrom = dataset("ds-from", from); + DatasetDTO exactTo = dataset("ds-to", to); + DatasetDTO inBetween = dataset("ds-mid", LocalDateTime.of(2025, 6, 15, 12, 0)); + stubSinglePageResponse(List.of(exactFrom, exactTo, inBetween)); + + List result = client.listAllDatasets(from, to); + + assertThat(result).hasSize(3); + assertThat(result).extracting(DatasetDTO::getId) + .containsExactly("ds-from", "ds-to", "ds-mid"); + } + + @Test + void listAllDatasets_noTimeWindow_returnsAllItems() { + DatasetDTO ds1 = dataset("ds-1", LocalDateTime.of(2020, 1, 1, 0, 0)); + DatasetDTO ds2 = dataset("ds-2", null); + stubSinglePageResponse(List.of(ds1, ds2)); + + List result = client.listAllDatasets(null, null); + + assertThat(result).hasSize(2); + } + + @SuppressWarnings("unchecked") + @Test + void listAllDatasets_withNullItemInList_doesNotThrowNPE() { + LocalDateTime from = LocalDateTime.of(2025, 1, 1, 0, 0, 0); + LocalDateTime to = LocalDateTime.of(2025, 12, 31, 23, 59, 59); + + DatasetDTO valid = dataset("ds-valid", LocalDateTime.of(2025, 6, 1, 0, 0)); + // Build a list that contains a null element to simulate upstream returning null items + List contentWithNull = new java.util.ArrayList<>(); + contentWithNull.add(valid); + contentWithNull.add(null); + contentWithNull.add(dataset("ds-old", LocalDateTime.of(2024, 1, 1, 0, 0))); + + PagedResult paged = pagedResult(contentWithNull, 0, 1); + when(restTemplate.exchange( + any(String.class), eq(HttpMethod.GET), isNull(), + any(ParameterizedTypeReference.class))) + .thenReturn(ResponseEntity.ok(paged)); + + List result = client.listAllDatasets(from, to); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getId()).isEqualTo("ds-valid"); + } + + @Test + void listAllWorkflows_filtersCorrectly() { + LocalDateTime from = LocalDateTime.of(2025, 6, 1, 0, 0, 0); + + WorkflowDTO old = workflow("wf-old", LocalDateTime.of(2025, 1, 1, 0, 0)); + WorkflowDTO recent = workflow("wf-recent", LocalDateTime.of(2025, 7, 1, 0, 0)); + stubSinglePageResponse(List.of(old, recent)); + + List result = client.listAllWorkflows(from, null); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getId()).isEqualTo("wf-recent"); + } + + @Test + void listAllJobs_filtersCorrectly() { + LocalDateTime to = LocalDateTime.of(2025, 6, 30, 23, 59, 59); + + JobDTO inRange = job("j-in", LocalDateTime.of(2025, 3, 1, 0, 0)); + JobDTO outOfRange = job("j-out", LocalDateTime.of(2025, 9, 1, 0, 0)); + stubSinglePageResponse(List.of(inRange, outOfRange)); + + List result = client.listAllJobs(null, to); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getId()).isEqualTo("j-in"); + } + + @Test + void listAllLabelTasks_filtersCorrectly() { + LocalDateTime from = LocalDateTime.of(2025, 6, 1, 0, 0, 0); + LocalDateTime to = LocalDateTime.of(2025, 6, 30, 23, 59, 59); + + LabelTaskDTO inRange = labelTask("lt-in", LocalDateTime.of(2025, 6, 15, 0, 0)); + LabelTaskDTO outOfRange = labelTask("lt-out", LocalDateTime.of(2025, 1, 1, 0, 0)); + stubSinglePageResponse(List.of(inRange, outOfRange)); + + List result = client.listAllLabelTasks(from, to); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getId()).isEqualTo("lt-in"); + } + + @Test + void listAllKnowledgeSets_filtersCorrectly() { + LocalDateTime from = LocalDateTime.of(2025, 6, 1, 0, 0, 0); + + KnowledgeSetDTO old = knowledgeSet("ks-old", LocalDateTime.of(2025, 1, 1, 0, 0)); + KnowledgeSetDTO recent = knowledgeSet("ks-new", LocalDateTime.of(2025, 8, 1, 0, 0)); + stubSinglePageResponse(List.of(old, recent)); + + List result = client.listAllKnowledgeSets(from, null); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getId()).isEqualTo("ks-new"); + } + } + + // ----------------------------------------------------------------------- + // Pagination with time window + // ----------------------------------------------------------------------- + + @Nested + class PaginationTests { + + @SuppressWarnings("unchecked") + @Test + void listAllDatasets_multiplePages_fetchesAllAndFilters() { + LocalDateTime from = LocalDateTime.of(2025, 6, 1, 0, 0, 0); + LocalDateTime to = LocalDateTime.of(2025, 12, 31, 23, 59, 59); + + DatasetDTO ds1 = dataset("ds-1", LocalDateTime.of(2025, 7, 1, 0, 0)); + DatasetDTO ds2 = dataset("ds-2", LocalDateTime.of(2025, 3, 1, 0, 0)); // outside + DatasetDTO ds3 = dataset("ds-3", LocalDateTime.of(2025, 9, 1, 0, 0)); + + PagedResult page0 = pagedResult(List.of(ds1, ds2), 0, 2); + PagedResult page1 = pagedResult(List.of(ds3), 1, 2); + + when(restTemplate.exchange( + (String) argThat(url -> url != null && url.toString().contains("page=0")), + eq(HttpMethod.GET), isNull(), + any(ParameterizedTypeReference.class))) + .thenReturn(ResponseEntity.ok(page0)); + + when(restTemplate.exchange( + (String) argThat(url -> url != null && url.toString().contains("page=1")), + eq(HttpMethod.GET), isNull(), + any(ParameterizedTypeReference.class))) + .thenReturn(ResponseEntity.ok(page1)); + + List result = client.listAllDatasets(from, to); + + // ds2 is outside time window, filtered out client-side + assertThat(result).hasSize(2); + assertThat(result).extracting(DatasetDTO::getId) + .containsExactly("ds-1", "ds-3"); + } + + @SuppressWarnings("unchecked") + @Test + void listAllDatasets_emptyFirstPage_returnsEmptyList() { + PagedResult emptyPage = pagedResult(List.of(), 0, 0); + when(restTemplate.exchange( + any(String.class), eq(HttpMethod.GET), isNull(), + any(ParameterizedTypeReference.class))) + .thenReturn(ResponseEntity.ok(emptyPage)); + + List result = client.listAllDatasets( + LocalDateTime.of(2025, 1, 1, 0, 0), + LocalDateTime.of(2025, 12, 31, 23, 59, 59)); + + assertThat(result).isEmpty(); + } + } + + // ----------------------------------------------------------------------- + // Error propagation + // ----------------------------------------------------------------------- + + @Nested + class ErrorTests { + + @Test + void listAllDatasets_restClientException_propagates() { + when(restTemplate.exchange( + any(String.class), eq(HttpMethod.GET), isNull(), + any(ParameterizedTypeReference.class))) + .thenThrow(new RestClientException("connection refused")); + + assertThatThrownBy(() -> client.listAllDatasets( + LocalDateTime.of(2025, 1, 1, 0, 0), null)) + .isInstanceOf(RestClientException.class) + .hasMessageContaining("connection refused"); + } + } + + // ----------------------------------------------------------------------- + // URL format for each entity type + // ----------------------------------------------------------------------- + + @Nested + class UrlEndpointTests { + + @Test + void listAllWorkflows_usesCorrectEndpoint() { + WorkflowDTO wf = workflow("wf-1", LocalDateTime.of(2025, 6, 1, 0, 0)); + stubSinglePageResponse(List.of(wf)); + + client.listAllWorkflows(LocalDateTime.of(2025, 1, 1, 0, 0), null); + + ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); + verify(restTemplate).exchange( + urlCaptor.capture(), eq(HttpMethod.GET), isNull(), + any(ParameterizedTypeReference.class)); + + assertThat(urlCaptor.getValue()) + .startsWith(BASE_URL + "/data-management/workflows?"); + } + + @Test + void listAllJobs_usesCorrectEndpoint() { + JobDTO j = job("j-1", LocalDateTime.of(2025, 6, 1, 0, 0)); + stubSinglePageResponse(List.of(j)); + + client.listAllJobs(LocalDateTime.of(2025, 1, 1, 0, 0), null); + + ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); + verify(restTemplate).exchange( + urlCaptor.capture(), eq(HttpMethod.GET), isNull(), + any(ParameterizedTypeReference.class)); + + assertThat(urlCaptor.getValue()) + .startsWith(BASE_URL + "/data-management/jobs?"); + } + + @Test + void listAllLabelTasks_usesAnnotationServiceUrl() { + LabelTaskDTO lt = labelTask("lt-1", LocalDateTime.of(2025, 6, 1, 0, 0)); + stubSinglePageResponse(List.of(lt)); + + client.listAllLabelTasks(LocalDateTime.of(2025, 1, 1, 0, 0), null); + + ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); + verify(restTemplate).exchange( + urlCaptor.capture(), eq(HttpMethod.GET), isNull(), + any(ParameterizedTypeReference.class)); + + assertThat(urlCaptor.getValue()) + .startsWith(ANNOTATION_URL + "/annotation/label-tasks?"); + } + + @Test + void listAllKnowledgeSets_usesCorrectEndpoint() { + KnowledgeSetDTO ks = knowledgeSet("ks-1", LocalDateTime.of(2025, 6, 1, 0, 0)); + stubSinglePageResponse(List.of(ks)); + + client.listAllKnowledgeSets(LocalDateTime.of(2025, 1, 1, 0, 0), null); + + ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); + verify(restTemplate).exchange( + urlCaptor.capture(), eq(HttpMethod.GET), isNull(), + any(ParameterizedTypeReference.class)); + + assertThat(urlCaptor.getValue()) + .startsWith(BASE_URL + "/data-management/knowledge-sets?"); + } + } + + // ----------------------------------------------------------------------- + // No-arg overloads (backward compatibility) + // ----------------------------------------------------------------------- + + @Nested + class NoArgOverloadTests { + + @Test + void listAllWorkflows_noArgs_returnsAll() { + WorkflowDTO wf = workflow("wf-1", LocalDateTime.of(2025, 6, 1, 0, 0)); + stubSinglePageResponse(List.of(wf)); + + List result = client.listAllWorkflows(); + + assertThat(result).hasSize(1); + } + + @Test + void listAllJobs_noArgs_returnsAll() { + JobDTO j = job("j-1", LocalDateTime.of(2025, 6, 1, 0, 0)); + stubSinglePageResponse(List.of(j)); + + List result = client.listAllJobs(); + + assertThat(result).hasSize(1); + } + + @Test + void listAllLabelTasks_noArgs_returnsAll() { + LabelTaskDTO lt = labelTask("lt-1", LocalDateTime.of(2025, 6, 1, 0, 0)); + stubSinglePageResponse(List.of(lt)); + + List result = client.listAllLabelTasks(); + + assertThat(result).hasSize(1); + } + + @Test + void listAllKnowledgeSets_noArgs_returnsAll() { + KnowledgeSetDTO ks = knowledgeSet("ks-1", LocalDateTime.of(2025, 6, 1, 0, 0)); + stubSinglePageResponse(List.of(ks)); + + List result = client.listAllKnowledgeSets(); + + assertThat(result).hasSize(1); + } + } +}