From b385d5cb90c06480d273157e1acdcd15a1874b38 Mon Sep 17 00:00:00 2001 From: Chris Edwards Date: Fri, 9 Jan 2026 20:42:35 -0500 Subject: [PATCH 01/12] feat: add observations fields to Route model for N+1 elimination --- .../data/routecoverage/Route.java | 6 ++ .../data/routecoverage/RouteTest.java | 66 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/RouteTest.java diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/Route.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/Route.java index 8165c757..014e12a4 100644 --- a/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/Route.java +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/Route.java @@ -45,4 +45,10 @@ public class Route { @SerializedName("critical_vulnerabilities") private int criticalVulnerabilities; + + @SerializedName("observations") + private List observations; + + @SerializedName("total_observations") + private Long totalObservations; } diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/RouteTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/RouteTest.java new file mode 100644 index 00000000..5e900c2f --- /dev/null +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/RouteTest.java @@ -0,0 +1,66 @@ +/* + * Copyright 2025 Contrast Security + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.gson.Gson; +import org.junit.jupiter.api.Test; + +class RouteTest { + + private final Gson gson = new Gson(); + + @Test + void should_deserialize_observations_from_json() { + var json = + """ + { + "route_hash": "abc123", + "signature": "GET /api/users", + "observations": [ + {"verb": "GET", "url": "/api/users"}, + {"verb": "POST", "url": "/api/users"} + ], + "total_observations": 2 + } + """; + + var route = gson.fromJson(json, Route.class); + + assertThat(route.getRouteHash()).isEqualTo("abc123"); + assertThat(route.getObservations()).hasSize(2); + assertThat(route.getObservations().get(0).getVerb()).isEqualTo("GET"); + assertThat(route.getObservations().get(1).getVerb()).isEqualTo("POST"); + assertThat(route.getTotalObservations()).isEqualTo(2L); + } + + @Test + void should_handle_null_observations() { + var json = + """ + { + "route_hash": "abc123", + "signature": "GET /api/users" + } + """; + + var route = gson.fromJson(json, Route.class); + + assertThat(route.getObservations()).isNull(); + assertThat(route.getTotalObservations()).isNull(); + } +} From c42c7d6686971fce3a00a0323bedb46bbcfc393f Mon Sep 17 00:00:00 2001 From: Chris Edwards Date: Fri, 9 Jan 2026 20:44:14 -0500 Subject: [PATCH 02/12] perf: always use POST endpoint for route coverage to include observations --- .../contrast/sdkextension/SDKExtension.java | 36 +++---- .../sdkextension/SDKExtensionTest.java | 93 +++++++++++++++++++ 2 files changed, 106 insertions(+), 23 deletions(-) create mode 100644 src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtensionTest.java diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.java index d38d961f..2819f6b3 100644 --- a/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.java +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.java @@ -199,10 +199,13 @@ public RouteDetailsResponse getRouteDetails( /** * Retrieves route coverage information for an application. * + *

Always uses POST endpoint with expand=observations to ensure observations are included, + * eliminating N+1 queries for route details. + * * @param organizationId The organization ID * @param appId The application ID - * @param metadata Optional metadata request for filtering (can be null) - * @return RouteCoverageResponse containing route coverage information + * @param metadata Optional metadata request for filtering (can be null for unfiltered) + * @return RouteCoverageResponse containing route coverage information with observations * @throws IOException If an I/O error occurs * @throws UnauthorizedException If the request is not authorized */ @@ -210,29 +213,16 @@ public RouteCoverageResponse getRouteCoverage( String organizationId, String appId, RouteCoverageBySessionIDAndMetadataRequest metadata) throws IOException, UnauthorizedException { - InputStream is = null; + // Always use POST with expand=observations to get observations inline (eliminates N+1) + var url = urlBuilder.getRouteCoverageWithMetadataUrl(organizationId, appId); - try { - if (metadata == null) { - is = - contrastSDK.makeRequest( - HttpMethod.GET, urlBuilder.getRouteCoverageUrl(organizationId, appId)); - } else { - is = - contrastSDK.makeRequestWithBody( - HttpMethod.POST, - urlBuilder.getRouteCoverageWithMetadataUrl(organizationId, appId), - gson.toJson(metadata), - MediaType.JSON); - } + // Use empty object for unfiltered requests, otherwise serialize metadata + var body = metadata == null ? "{}" : gson.toJson(metadata); - try (Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { - return gson.fromJson(reader, RouteCoverageResponse.class); - } - } finally { - if (is != null) { - is.close(); - } + try (InputStream is = + contrastSDK.makeRequestWithBody(HttpMethod.POST, url, body, MediaType.JSON); + Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { + return gson.fromJson(reader, RouteCoverageResponse.class); } } diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtensionTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtensionTest.java new file mode 100644 index 00000000..eda959ba --- /dev/null +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtensionTest.java @@ -0,0 +1,93 @@ +/* + * Copyright 2025 Contrast Security + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.contrast.labs.ai.mcp.contrast.sdkextension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageBySessionIDAndMetadataRequestExtended; +import com.contrastsecurity.http.HttpMethod; +import com.contrastsecurity.http.MediaType; +import com.contrastsecurity.sdk.ContrastSDK; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class SDKExtensionTest { + + private ContrastSDK sdk; + private SDKExtension sdkExtension; + + @BeforeEach + void setUp() { + sdk = mock(); + sdkExtension = new SDKExtension(sdk); + } + + @Test + void getRouteCoverage_should_use_post_with_empty_body_when_no_metadata() throws Exception { + var emptyResponse = + """ + {"success": true, "routes": []} + """; + when(sdk.makeRequestWithBody(any(), any(), any(), any())) + .thenReturn(new ByteArrayInputStream(emptyResponse.getBytes(StandardCharsets.UTF_8))); + + var result = sdkExtension.getRouteCoverage("org-123", "app-456", null); + + assertThat(result.isSuccess()).isTrue(); + + // Verify POST is used (not GET), with expand=observations in URL + verify(sdk) + .makeRequestWithBody( + eq(HttpMethod.POST), + argThat(url -> url.contains("/route/filter") && url.contains("expand=observations")), + eq("{}"), // Empty body for unfiltered + eq(MediaType.JSON)); + } + + @Test + void getRouteCoverage_should_use_post_with_metadata_body_when_metadata_provided() + throws Exception { + var emptyResponse = + """ + {"success": true, "routes": []} + """; + when(sdk.makeRequestWithBody(any(), any(), any(), any())) + .thenReturn(new ByteArrayInputStream(emptyResponse.getBytes(StandardCharsets.UTF_8))); + + var metadata = new RouteCoverageBySessionIDAndMetadataRequestExtended(); + metadata.setSessionId("session-123"); + + var result = sdkExtension.getRouteCoverage("org-123", "app-456", metadata); + + assertThat(result.isSuccess()).isTrue(); + + // Verify POST is used with metadata in body + verify(sdk) + .makeRequestWithBody( + eq(HttpMethod.POST), + argThat(url -> url.contains("/route/filter") && url.contains("expand=observations")), + argThat(body -> body.contains("session-123")), + eq(MediaType.JSON)); + } +} From 8bd993d7c3585d5c6ab23419f04ca3e9a50c98b1 Mon Sep 17 00:00:00 2001 From: Chris Edwards Date: Fri, 9 Jan 2026 20:46:33 -0500 Subject: [PATCH 03/12] perf: remove N+1 route details loop (observations now inline) --- .../tool/coverage/GetRouteCoverageTool.java | 11 +---- .../coverage/GetRouteCoverageToolTest.java | 49 ++++++++----------- 2 files changed, 22 insertions(+), 38 deletions(-) diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageTool.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageTool.java index c254bf77..0b05eb9c 100644 --- a/src/main/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageTool.java +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageTool.java @@ -16,7 +16,6 @@ package com.contrast.labs.ai.mcp.contrast.tool.coverage; import com.contrast.labs.ai.mcp.contrast.sdkextension.SDKExtension; -import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Route; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageBySessionIDAndMetadataRequestExtended; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageResponse; import com.contrast.labs.ai.mcp.contrast.tool.base.BaseSingleTool; @@ -159,14 +158,8 @@ protected RouteCoverageResponse doExecute(RouteCoverageParams params, List { when(mock.getRouteCoverage(eq(ORG_ID), eq(VALID_APP_ID), isNull())) .thenReturn(mockResponse); - when(mock.getRouteDetails(eq(ORG_ID), eq(VALID_APP_ID), anyString())) - .thenReturn(routeDetails); })) { var result = tool.getRouteCoverage(VALID_APP_ID, null, null, null); @@ -130,9 +127,10 @@ void getRouteCoverage_should_return_data_when_unfiltered() throws Exception { assertThat(result.data()).isNotNull(); assertThat(result.data().getRoutes()).hasSize(2); + // Observations are included inline - no N+1 calls to getRouteDetails var constructedMock = mockedConstruction.constructed().get(0); verify(constructedMock).getRouteCoverage(eq(ORG_ID), eq(VALID_APP_ID), isNull()); - verify(constructedMock, times(2)).getRouteDetails(eq(ORG_ID), eq(VALID_APP_ID), anyString()); + verify(constructedMock, never()).getRouteDetails(anyString(), anyString(), anyString()); } } @@ -164,7 +162,6 @@ void getRouteCoverage_should_return_empty_routes_when_none_found() throws Except @Test void getRouteCoverage_should_filter_by_session_metadata() throws Exception { var mockResponse = createMockRouteCoverageResponse(1); - var routeDetails = createMockRouteDetailsResponse(); try (var mockedConstruction = mockConstruction( @@ -175,8 +172,6 @@ void getRouteCoverage_should_filter_by_session_metadata() throws Exception { eq(VALID_APP_ID), any(RouteCoverageBySessionIDAndMetadataRequestExtended.class))) .thenReturn(mockResponse); - when(mock.getRouteDetails(eq(ORG_ID), eq(VALID_APP_ID), anyString())) - .thenReturn(routeDetails); })) { var result = tool.getRouteCoverage(VALID_APP_ID, METADATA_NAME, METADATA_VALUE, null); @@ -204,7 +199,6 @@ void getRouteCoverage_should_filter_by_session_metadata() throws Exception { void getRouteCoverage_should_filter_by_latest_session() throws Exception { var sessionResponse = createMockSessionMetadataResponse(); var mockResponse = createMockRouteCoverageResponse(1); - var routeDetails = createMockRouteDetailsResponse(); try (var mockedConstruction = mockConstruction( @@ -217,8 +211,6 @@ void getRouteCoverage_should_filter_by_latest_session() throws Exception { eq(VALID_APP_ID), any(RouteCoverageBySessionIDAndMetadataRequestExtended.class))) .thenReturn(mockResponse); - when(mock.getRouteDetails(eq(ORG_ID), eq(VALID_APP_ID), anyString())) - .thenReturn(routeDetails); })) { var result = tool.getRouteCoverage(VALID_APP_ID, null, null, true); @@ -320,7 +312,6 @@ void getRouteCoverage_should_handle_null_routes_list() throws Exception { void getRouteCoverage_should_add_warning_when_precedence_applies() throws Exception { var sessionResponse = createMockSessionMetadataResponse(); var mockResponse = createMockRouteCoverageResponse(1); - var routeDetails = createMockRouteDetailsResponse(); try (var mockedConstruction = mockConstruction( @@ -333,8 +324,6 @@ void getRouteCoverage_should_add_warning_when_precedence_applies() throws Except eq(VALID_APP_ID), any(RouteCoverageBySessionIDAndMetadataRequestExtended.class))) .thenReturn(mockResponse); - when(mock.getRouteDetails(eq(ORG_ID), eq(VALID_APP_ID), anyString())) - .thenReturn(routeDetails); })) { var result = tool.getRouteCoverage(VALID_APP_ID, METADATA_NAME, METADATA_VALUE, true); @@ -344,12 +333,11 @@ void getRouteCoverage_should_add_warning_when_precedence_applies() throws Except } } - // ========== Route details fetching ========== + // ========== Observations are included inline ========== @Test - void getRouteCoverage_should_fetch_route_details_for_each_route() throws Exception { + void getRouteCoverage_should_return_observations_inline() throws Exception { var mockResponse = createMockRouteCoverageResponse(3); - var routeDetails = createMockRouteDetailsResponse(); try (var mockedConstruction = mockConstruction( @@ -357,8 +345,6 @@ void getRouteCoverage_should_fetch_route_details_for_each_route() throws Excepti (mock, context) -> { when(mock.getRouteCoverage(eq(ORG_ID), eq(VALID_APP_ID), isNull())) .thenReturn(mockResponse); - when(mock.getRouteDetails(eq(ORG_ID), eq(VALID_APP_ID), anyString())) - .thenReturn(routeDetails); })) { var result = tool.getRouteCoverage(VALID_APP_ID, null, null, null); @@ -366,13 +352,16 @@ void getRouteCoverage_should_fetch_route_details_for_each_route() throws Excepti assertThat(result.isSuccess()).isTrue(); assertThat(result.data().getRoutes()).hasSize(3); - // Verify route details were fetched for each route + // Verify observations are included inline (from expand=observations) for (var route : result.data().getRoutes()) { - assertThat(route.getRouteDetailsResponse()).isNotNull(); + assertThat(route.getObservations()).isNotNull(); + assertThat(route.getObservations()).hasSize(1); + assertThat(route.getTotalObservations()).isEqualTo(1L); } + // Verify N+1 calls to getRouteDetails are NOT made var constructedMock = mockedConstruction.constructed().get(0); - verify(constructedMock, times(3)).getRouteDetails(eq(ORG_ID), eq(VALID_APP_ID), anyString()); + verify(constructedMock, never()).getRouteDetails(anyString(), anyString(), anyString()); } } @@ -388,6 +377,14 @@ private RouteCoverageResponse createMockRouteCoverageResponse(int routeCount) { route.setSignature("GET /api/endpoint" + i); route.setRouteHash(ROUTE_HASH + "-" + i); route.setExercised(i % 2 == 0 ? 1L : 0L); + + // Include observations inline (simulating expand=observations response) + var observation = new Observation(); + observation.setVerb("GET"); + observation.setUrl("/api/endpoint" + i); + route.setObservations(List.of(observation)); + route.setTotalObservations(1L); + routes.add(route); } @@ -395,12 +392,6 @@ private RouteCoverageResponse createMockRouteCoverageResponse(int routeCount) { return response; } - private RouteDetailsResponse createMockRouteDetailsResponse() { - var response = new RouteDetailsResponse(); - response.setSuccess(true); - return response; - } - private SessionMetadataResponse createMockSessionMetadataResponse() { var response = new SessionMetadataResponse(); var session = new AgentSession(); From 7a536d547d1108de6d3024a63a9b50cf4bfcae81 Mon Sep 17 00:00:00 2001 From: Chris Edwards Date: Fri, 9 Jan 2026 20:53:43 -0500 Subject: [PATCH 04/12] fix: use empty request object instead of {} for unfiltered route coverage --- .../labs/ai/mcp/contrast/sdkextension/SDKExtension.java | 7 +++++-- .../ai/mcp/contrast/sdkextension/SDKExtensionTest.java | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.java index 2819f6b3..bee55355 100644 --- a/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.java +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.java @@ -21,6 +21,7 @@ import com.contrast.labs.ai.mcp.contrast.sdkextension.data.adr.AttacksFilterBody; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.adr.AttacksResponse; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.application.ApplicationsResponse; +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageBySessionIDAndMetadataRequestExtended; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageResponse; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteDetailsResponse; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.sca.LibraryObservation; @@ -216,8 +217,10 @@ public RouteCoverageResponse getRouteCoverage( // Always use POST with expand=observations to get observations inline (eliminates N+1) var url = urlBuilder.getRouteCoverageWithMetadataUrl(organizationId, appId); - // Use empty object for unfiltered requests, otherwise serialize metadata - var body = metadata == null ? "{}" : gson.toJson(metadata); + // Use empty request for unfiltered requests (serializes to {"metadata": []}) + var request = + metadata == null ? new RouteCoverageBySessionIDAndMetadataRequestExtended() : metadata; + var body = gson.toJson(request); try (InputStream is = contrastSDK.makeRequestWithBody(HttpMethod.POST, url, body, MediaType.JSON); diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtensionTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtensionTest.java index eda959ba..90b4a961 100644 --- a/src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtensionTest.java +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtensionTest.java @@ -57,11 +57,12 @@ void getRouteCoverage_should_use_post_with_empty_body_when_no_metadata() throws assertThat(result.isSuccess()).isTrue(); // Verify POST is used (not GET), with expand=observations in URL + // Empty request serializes to {"metadata":[]} not {} verify(sdk) .makeRequestWithBody( eq(HttpMethod.POST), argThat(url -> url.contains("/route/filter") && url.contains("expand=observations")), - eq("{}"), // Empty body for unfiltered + argThat(body -> body.contains("metadata")), eq(MediaType.JSON)); } From dd6ab8f5fc6470d280dcd0bda5673c876e7cbe08 Mon Sep 17 00:00:00 2001 From: Chris Edwards Date: Fri, 9 Jan 2026 21:00:25 -0500 Subject: [PATCH 05/12] fix: use GET with expand=observations for unfiltered requests POST /route/filter requires sessionID or metadata filter - cannot send empty body. Revert to GET/POST logic but add expand=observations to GET URL to include observations inline and eliminate N+1 queries. --- .../contrast/sdkextension/SDKExtension.java | 26 +++++++++++-------- .../sdkextension/SDKExtensionTest.java | 16 +++++------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.java index bee55355..5234ef75 100644 --- a/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.java +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.java @@ -21,7 +21,6 @@ import com.contrast.labs.ai.mcp.contrast.sdkextension.data.adr.AttacksFilterBody; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.adr.AttacksResponse; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.application.ApplicationsResponse; -import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageBySessionIDAndMetadataRequestExtended; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageResponse; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteDetailsResponse; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.sca.LibraryObservation; @@ -200,8 +199,8 @@ public RouteDetailsResponse getRouteDetails( /** * Retrieves route coverage information for an application. * - *

Always uses POST endpoint with expand=observations to ensure observations are included, - * eliminating N+1 queries for route details. + *

Uses GET with expand=observations for unfiltered requests, POST for filtered requests. Both + * include observations inline, eliminating N+1 queries for route details. * * @param organizationId The organization ID * @param appId The application ID @@ -214,16 +213,21 @@ public RouteCoverageResponse getRouteCoverage( String organizationId, String appId, RouteCoverageBySessionIDAndMetadataRequest metadata) throws IOException, UnauthorizedException { - // Always use POST with expand=observations to get observations inline (eliminates N+1) - var url = urlBuilder.getRouteCoverageWithMetadataUrl(organizationId, appId); + InputStream is; - // Use empty request for unfiltered requests (serializes to {"metadata": []}) - var request = - metadata == null ? new RouteCoverageBySessionIDAndMetadataRequestExtended() : metadata; - var body = gson.toJson(request); + if (metadata == null) { + // GET for unfiltered - add expand=observations to include observations inline + var url = urlBuilder.getRouteCoverageUrl(organizationId, appId) + "&expand=observations"; + is = contrastSDK.makeRequest(HttpMethod.GET, url); + } else { + // POST for filtered - URL already includes expand=observations + var url = urlBuilder.getRouteCoverageWithMetadataUrl(organizationId, appId); + is = + contrastSDK.makeRequestWithBody( + HttpMethod.POST, url, gson.toJson(metadata), MediaType.JSON); + } - try (InputStream is = - contrastSDK.makeRequestWithBody(HttpMethod.POST, url, body, MediaType.JSON); + try (is; Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { return gson.fromJson(reader, RouteCoverageResponse.class); } diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtensionTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtensionTest.java index 90b4a961..d6020de8 100644 --- a/src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtensionTest.java +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtensionTest.java @@ -44,26 +44,24 @@ void setUp() { } @Test - void getRouteCoverage_should_use_post_with_empty_body_when_no_metadata() throws Exception { + void getRouteCoverage_should_use_get_with_expand_observations_when_no_metadata() + throws Exception { var emptyResponse = """ {"success": true, "routes": []} """; - when(sdk.makeRequestWithBody(any(), any(), any(), any())) + when(sdk.makeRequest(any(), any())) .thenReturn(new ByteArrayInputStream(emptyResponse.getBytes(StandardCharsets.UTF_8))); var result = sdkExtension.getRouteCoverage("org-123", "app-456", null); assertThat(result.isSuccess()).isTrue(); - // Verify POST is used (not GET), with expand=observations in URL - // Empty request serializes to {"metadata":[]} not {} + // Verify GET is used with expand=observations in URL verify(sdk) - .makeRequestWithBody( - eq(HttpMethod.POST), - argThat(url -> url.contains("/route/filter") && url.contains("expand=observations")), - argThat(body -> body.contains("metadata")), - eq(MediaType.JSON)); + .makeRequest( + eq(HttpMethod.GET), + argThat(url -> url.contains("/route?") && url.contains("expand=observations"))); } @Test From 6f3af05d567c84eced11246545b0bd14e8d42cec Mon Sep 17 00:00:00 2001 From: Chris Edwards Date: Fri, 9 Jan 2026 23:25:52 -0500 Subject: [PATCH 06/12] feat: add RouteLight record for lightweight route coverage responses --- .../labs/ai/mcp/contrast/data/RouteLight.java | 49 ++++++++++++++ .../ai/mcp/contrast/data/RouteLightTest.java | 67 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 src/main/java/com/contrast/labs/ai/mcp/contrast/data/RouteLight.java create mode 100644 src/test/java/com/contrast/labs/ai/mcp/contrast/data/RouteLightTest.java diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/data/RouteLight.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/data/RouteLight.java new file mode 100644 index 00000000..bae0516b --- /dev/null +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/data/RouteLight.java @@ -0,0 +1,49 @@ +/* + * Copyright 2025 Contrast Security + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.contrast.labs.ai.mcp.contrast.data; + +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Observation; +import java.util.List; + +/** + * Lightweight route record for route coverage responses. Contains essential route information + * without redundant application data or full server details. + * + * @param signature Code-level route identifier (e.g., Java method signature) + * @param environments Distinct environments where route has been observed (DEVELOPMENT, QA, + * PRODUCTION) + * @param status Route status (DISCOVERED, EXERCISED) + * @param routeHash Unique route identifier hash + * @param vulnerabilities Total vulnerability count for this route + * @param criticalVulnerabilities Count of vulnerabilities with critical severity + * @param exercised Timestamp when route was last exercised (0 if never) + * @param discovered Timestamp when route was first discovered (immutable) + * @param serversTotal Count of distinct enabled servers where route has been observed + * @param observations List of observed HTTP interactions (verb + url) + * @param totalObservations Total observation count + */ +public record RouteLight( + String signature, + List environments, + String status, + String routeHash, + int vulnerabilities, + int criticalVulnerabilities, + long exercised, + long discovered, + int serversTotal, + List observations, + Long totalObservations) {} diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/data/RouteLightTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/data/RouteLightTest.java new file mode 100644 index 00000000..96d45a4f --- /dev/null +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/data/RouteLightTest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2025 Contrast Security + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.contrast.labs.ai.mcp.contrast.data; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Observation; +import java.util.List; +import org.junit.jupiter.api.Test; + +class RouteLightTest { + + @Test + void should_create_route_light_with_all_fields() { + var observation = new Observation(); + observation.setVerb("GET"); + observation.setUrl("/api/users"); + + var route = + new RouteLight( + "org.example.Controller.getUsers()", + List.of("PRODUCTION", "QA"), + "EXERCISED", + "hash-123", + 5, + 2, + 1704067200000L, + 1704063600000L, + 3, + List.of(observation), + 1L); + + assertThat(route.signature()).isEqualTo("org.example.Controller.getUsers()"); + assertThat(route.environments()).containsExactly("PRODUCTION", "QA"); + assertThat(route.status()).isEqualTo("EXERCISED"); + assertThat(route.routeHash()).isEqualTo("hash-123"); + assertThat(route.vulnerabilities()).isEqualTo(5); + assertThat(route.criticalVulnerabilities()).isEqualTo(2); + assertThat(route.exercised()).isEqualTo(1704067200000L); + assertThat(route.discovered()).isEqualTo(1704063600000L); + assertThat(route.serversTotal()).isEqualTo(3); + assertThat(route.observations()).hasSize(1); + assertThat(route.totalObservations()).isEqualTo(1L); + } + + @Test + void should_handle_null_optional_fields() { + var route = + new RouteLight("signature", List.of(), "DISCOVERED", "hash", 0, 0, 0L, 0L, 0, null, null); + + assertThat(route.observations()).isNull(); + assertThat(route.totalObservations()).isNull(); + } +} From 80162c43dfb9055d5908fa8ef3e357009d2288a0 Mon Sep 17 00:00:00 2001 From: Chris Edwards Date: Fri, 9 Jan 2026 23:29:30 -0500 Subject: [PATCH 07/12] feat: add RouteCoverageResponseLight record Lightweight response wrapper for route coverage that holds: - API status (success, messages) - Aggregate statistics (totalRoutes, exercisedCount, discoveredCount, coveragePercent) - Vulnerability totals (totalVulnerabilities, totalCriticalVulnerabilities) - List of RouteLight objects Part of route coverage N+1 elimination effort. Task 2 of 5. --- .../data/RouteCoverageResponseLight.java | 43 ++++++++++++ .../data/RouteCoverageResponseLightTest.java | 68 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 src/main/java/com/contrast/labs/ai/mcp/contrast/data/RouteCoverageResponseLight.java create mode 100644 src/test/java/com/contrast/labs/ai/mcp/contrast/data/RouteCoverageResponseLightTest.java diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/data/RouteCoverageResponseLight.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/data/RouteCoverageResponseLight.java new file mode 100644 index 00000000..6f36803f --- /dev/null +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/data/RouteCoverageResponseLight.java @@ -0,0 +1,43 @@ +/* + * Copyright 2025 Contrast Security + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.contrast.labs.ai.mcp.contrast.data; + +import java.util.List; + +/** + * Lightweight route coverage response containing essential route data and aggregate statistics. + * Uses RouteLight instead of full Route objects for reduced payload size. + * + * @param success Whether the API request succeeded + * @param messages Any API messages (warnings, info) + * @param totalRoutes Total number of routes in the response + * @param exercisedCount Number of routes with EXERCISED status + * @param discoveredCount Number of routes with DISCOVERED status (not yet exercised) + * @param coveragePercent Percentage of routes that are exercised (0.0 to 100.0) + * @param totalVulnerabilities Sum of vulnerabilities across all routes + * @param totalCriticalVulnerabilities Sum of critical vulnerabilities across all routes + * @param routes List of lightweight route objects + */ +public record RouteCoverageResponseLight( + boolean success, + List messages, + int totalRoutes, + int exercisedCount, + int discoveredCount, + double coveragePercent, + int totalVulnerabilities, + int totalCriticalVulnerabilities, + List routes) {} diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/data/RouteCoverageResponseLightTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/data/RouteCoverageResponseLightTest.java new file mode 100644 index 00000000..fd1f2e9a --- /dev/null +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/data/RouteCoverageResponseLightTest.java @@ -0,0 +1,68 @@ +/* + * Copyright 2025 Contrast Security + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.contrast.labs.ai.mcp.contrast.data; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class RouteCoverageResponseLightTest { + + @Test + void should_create_response_with_routes_and_aggregates() { + var route = + new RouteLight( + "signature", + List.of("PRODUCTION"), + "EXERCISED", + "hash", + 1, + 0, + 100L, + 50L, + 2, + List.of(), + 0L); + + var response = + new RouteCoverageResponseLight(true, List.of(), 10, 7, 3, 70.0, 15, 5, List.of(route)); + + assertThat(response.success()).isTrue(); + assertThat(response.messages()).isEmpty(); + assertThat(response.totalRoutes()).isEqualTo(10); + assertThat(response.exercisedCount()).isEqualTo(7); + assertThat(response.discoveredCount()).isEqualTo(3); + assertThat(response.coveragePercent()).isCloseTo(70.0, within(0.01)); + assertThat(response.totalVulnerabilities()).isEqualTo(15); + assertThat(response.totalCriticalVulnerabilities()).isEqualTo(5); + assertThat(response.routes()).hasSize(1); + } + + @Test + void should_handle_empty_routes_with_zero_aggregates() { + var response = + new RouteCoverageResponseLight( + true, List.of("No routes found"), 0, 0, 0, 0.0, 0, 0, List.of()); + + assertThat(response.success()).isTrue(); + assertThat(response.messages()).containsExactly("No routes found"); + assertThat(response.totalRoutes()).isZero(); + assertThat(response.coveragePercent()).isZero(); + assertThat(response.routes()).isEmpty(); + } +} From 19718d25a785659861d7d466b608e5a58f1b3b8f Mon Sep 17 00:00:00 2001 From: Chris Edwards Date: Fri, 9 Jan 2026 23:35:14 -0500 Subject: [PATCH 08/12] feat: add RouteMapper for transforming Route to RouteLight Implements mapper component that: - Transforms Route -> RouteLight (removes redundant app, servers, routeDetailsResponse, routeHashString fields) - Transforms RouteCoverageResponse -> RouteCoverageResponseLight (computes aggregate statistics: coverage percent, exercised/discovered counts, vulnerability totals) - Handles null safety for lists (returns empty list instead of null) Part of route coverage N+1 elimination effort. --- .../ai/mcp/contrast/mapper/RouteMapper.java | 87 +++++++++ .../mcp/contrast/mapper/RouteMapperTest.java | 183 ++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 src/main/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapper.java create mode 100644 src/test/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapperTest.java diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapper.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapper.java new file mode 100644 index 00000000..29eda614 --- /dev/null +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapper.java @@ -0,0 +1,87 @@ +/* + * Copyright 2025 Contrast Security + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.contrast.labs.ai.mcp.contrast.mapper; + +import com.contrast.labs.ai.mcp.contrast.data.RouteCoverageResponseLight; +import com.contrast.labs.ai.mcp.contrast.data.RouteLight; +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Route; +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageResponse; +import java.util.Collections; +import java.util.Optional; +import org.springframework.stereotype.Component; + +/** + * Mapper for transforming Route objects into lightweight RouteLight representations. Eliminates + * redundant fields (app, servers, routeDetailsResponse, routeHashString) while preserving essential + * route coverage data. + */ +@Component +public class RouteMapper { + + /** + * Transform a Route object into a lightweight representation. Removes redundant app data, full + * server list, and legacy fields. + * + * @param route The full route object from SDK + * @return RouteLight with essential fields only + */ + public RouteLight toRouteLight(Route route) { + return new RouteLight( + route.getSignature(), + Optional.ofNullable(route.getEnvironments()).orElse(Collections.emptyList()), + route.getStatus(), + route.getRouteHash(), + route.getVulnerabilities(), + route.getCriticalVulnerabilities(), + route.getExercised(), + route.getDiscovered(), + route.getServersTotal(), + Optional.ofNullable(route.getObservations()).orElse(Collections.emptyList()), + route.getTotalObservations()); + } + + /** + * Transform full RouteCoverageResponse to lightweight version with aggregate statistics. + * + * @param response Full route coverage response from SDK + * @return Lightweight response with RouteLight objects and computed aggregates + */ + public RouteCoverageResponseLight toResponseLight(RouteCoverageResponse response) { + var routes = Optional.ofNullable(response.getRoutes()).orElse(Collections.emptyList()); + + var lightRoutes = routes.stream().map(this::toRouteLight).toList(); + + int totalRoutes = routes.size(); + int exercisedCount = + (int) routes.stream().filter(r -> "EXERCISED".equals(r.getStatus())).count(); + int discoveredCount = totalRoutes - exercisedCount; + double coveragePercent = totalRoutes > 0 ? (exercisedCount * 100.0) / totalRoutes : 0.0; + int totalVulnerabilities = routes.stream().mapToInt(Route::getVulnerabilities).sum(); + int totalCriticalVulnerabilities = + routes.stream().mapToInt(Route::getCriticalVulnerabilities).sum(); + + return new RouteCoverageResponseLight( + response.isSuccess(), + response.getMessages(), + totalRoutes, + exercisedCount, + discoveredCount, + coveragePercent, + totalVulnerabilities, + totalCriticalVulnerabilities, + lightRoutes); + } +} diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapperTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapperTest.java new file mode 100644 index 00000000..d621ead3 --- /dev/null +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapperTest.java @@ -0,0 +1,183 @@ +/* + * Copyright 2025 Contrast Security + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.contrast.labs.ai.mcp.contrast.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.App; +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Observation; +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Route; +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageResponse; +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Server; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class RouteMapperTest { + + private RouteMapper mapper; + + @BeforeEach + void setUp() { + mapper = new RouteMapper(); + } + + @Test + void toRouteLight_should_map_all_kept_fields() { + var observation = new Observation(); + observation.setVerb("GET"); + observation.setUrl("/api/users"); + + var route = new Route(); + route.setSignature("org.example.Controller.getUsers()"); + route.setEnvironments(List.of("PRODUCTION")); + route.setStatus("EXERCISED"); + route.setRouteHash("hash-123"); + route.setVulnerabilities(5); + route.setCriticalVulnerabilities(2); + route.setExercised(1704067200000L); + route.setDiscovered(1704063600000L); + route.setServersTotal(3); + route.setObservations(List.of(observation)); + route.setTotalObservations(1L); + + var result = mapper.toRouteLight(route); + + assertThat(result.signature()).isEqualTo("org.example.Controller.getUsers()"); + assertThat(result.environments()).containsExactly("PRODUCTION"); + assertThat(result.status()).isEqualTo("EXERCISED"); + assertThat(result.routeHash()).isEqualTo("hash-123"); + assertThat(result.vulnerabilities()).isEqualTo(5); + assertThat(result.criticalVulnerabilities()).isEqualTo(2); + assertThat(result.exercised()).isEqualTo(1704067200000L); + assertThat(result.discovered()).isEqualTo(1704063600000L); + assertThat(result.serversTotal()).isEqualTo(3); + assertThat(result.observations()).hasSize(1); + assertThat(result.observations().get(0).getVerb()).isEqualTo("GET"); + assertThat(result.totalObservations()).isEqualTo(1L); + } + + @Test + void toRouteLight_should_exclude_removed_fields() { + var app = new App(); + app.setName("TestApp"); + app.setAppId("app-123"); + + var server = new Server(); + server.setName("server-1"); + + var route = new Route(); + route.setSignature("signature"); + route.setApp(app); + route.setServers(List.of(server)); + route.setRouteHashString("hash-string-should-be-excluded"); + + var result = mapper.toRouteLight(route); + + // RouteLight record doesn't have app, servers, or routeHashString fields + // If it compiled, those fields were successfully excluded + assertThat(result.signature()).isEqualTo("signature"); + } + + @Test + void toRouteLight_should_handle_null_observations() { + var route = new Route(); + route.setSignature("signature"); + route.setObservations(null); + + var result = mapper.toRouteLight(route); + + assertThat(result.observations()).isEmpty(); + } + + @Test + void toRouteLight_should_handle_null_environments() { + var route = new Route(); + route.setSignature("signature"); + route.setEnvironments(null); + + var result = mapper.toRouteLight(route); + + assertThat(result.environments()).isEmpty(); + } + + @Test + void toResponseLight_should_compute_aggregates() { + var exercisedRoute = new Route(); + exercisedRoute.setSignature("sig1"); + exercisedRoute.setStatus("EXERCISED"); + exercisedRoute.setVulnerabilities(3); + exercisedRoute.setCriticalVulnerabilities(1); + + var discoveredRoute = new Route(); + discoveredRoute.setSignature("sig2"); + discoveredRoute.setStatus("DISCOVERED"); + discoveredRoute.setVulnerabilities(2); + discoveredRoute.setCriticalVulnerabilities(0); + + var response = new RouteCoverageResponse(); + response.setSuccess(true); + response.setMessages(List.of("OK")); + response.setRoutes(List.of(exercisedRoute, discoveredRoute)); + + var result = mapper.toResponseLight(response); + + assertThat(result.success()).isTrue(); + assertThat(result.messages()).containsExactly("OK"); + assertThat(result.totalRoutes()).isEqualTo(2); + assertThat(result.exercisedCount()).isEqualTo(1); + assertThat(result.discoveredCount()).isEqualTo(1); + assertThat(result.coveragePercent()).isCloseTo(50.0, within(0.01)); + assertThat(result.totalVulnerabilities()).isEqualTo(5); + assertThat(result.totalCriticalVulnerabilities()).isEqualTo(1); + assertThat(result.routes()).hasSize(2); + } + + @Test + void toResponseLight_should_handle_null_routes() { + var response = new RouteCoverageResponse(); + response.setSuccess(true); + response.setRoutes(null); + + var result = mapper.toResponseLight(response); + + assertThat(result.totalRoutes()).isZero(); + assertThat(result.exercisedCount()).isZero(); + assertThat(result.discoveredCount()).isZero(); + assertThat(result.coveragePercent()).isZero(); + assertThat(result.routes()).isEmpty(); + } + + @Test + void toResponseLight_should_handle_all_exercised() { + var route1 = new Route(); + route1.setStatus("EXERCISED"); + var route2 = new Route(); + route2.setStatus("EXERCISED"); + + var response = new RouteCoverageResponse(); + response.setSuccess(true); + response.setRoutes(List.of(route1, route2)); + + var result = mapper.toResponseLight(response); + + assertThat(result.totalRoutes()).isEqualTo(2); + assertThat(result.exercisedCount()).isEqualTo(2); + assertThat(result.discoveredCount()).isZero(); + assertThat(result.coveragePercent()).isCloseTo(100.0, within(0.01)); + } +} From e3514c82f90a16a74f5729f3613ae093a795262c Mon Sep 17 00:00:00 2001 From: Chris Edwards Date: Fri, 9 Jan 2026 23:41:59 -0500 Subject: [PATCH 09/12] feat: integrate RouteMapper into GetRouteCoverageTool for light responses Update GetRouteCoverageTool to return RouteCoverageResponseLight instead of the full RouteCoverageResponse. This reduces payload size for AI agents by eliminating redundant fields (app, servers, routeHashString, routeDetailsResponse) and adds aggregate statistics (exercisedCount, discoveredCount, coveragePercent, totalVulnerabilities, totalCriticalVulnerabilities). - Add RouteMapper dependency via constructor injection - Change return type from RouteCoverageResponse to RouteCoverageResponseLight - Update unit tests to use record accessors instead of getters - Add test for aggregate statistics computation - Update integration tests to work with RouteLight records --- .../tool/coverage/GetRouteCoverageTool.java | 14 +- .../tool/coverage/GetRouteCoverageToolIT.java | 120 ++++++++---------- .../coverage/GetRouteCoverageToolTest.java | 71 +++++++++-- 3 files changed, 122 insertions(+), 83 deletions(-) diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageTool.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageTool.java index 0b05eb9c..fa97fd10 100644 --- a/src/main/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageTool.java +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageTool.java @@ -15,9 +15,10 @@ */ package com.contrast.labs.ai.mcp.contrast.tool.coverage; +import com.contrast.labs.ai.mcp.contrast.data.RouteCoverageResponseLight; +import com.contrast.labs.ai.mcp.contrast.mapper.RouteMapper; import com.contrast.labs.ai.mcp.contrast.sdkextension.SDKExtension; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageBySessionIDAndMetadataRequestExtended; -import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageResponse; import com.contrast.labs.ai.mcp.contrast.tool.base.BaseSingleTool; import com.contrast.labs.ai.mcp.contrast.tool.base.SingleToolResponse; import com.contrast.labs.ai.mcp.contrast.tool.coverage.params.RouteCoverageParams; @@ -38,7 +39,9 @@ @RequiredArgsConstructor @Slf4j public class GetRouteCoverageTool - extends BaseSingleTool { + extends BaseSingleTool { + + private final RouteMapper routeMapper; @Tool( name = "get_route_coverage", @@ -71,7 +74,7 @@ Filtering options (mutually exclusive): - search_applications: Find application IDs by name or tag - get_session_metadata: View available session metadata fields """) - public SingleToolResponse getRouteCoverage( + public SingleToolResponse getRouteCoverage( @ToolParam(description = "Application ID (use search_applications to find)") String appId, @ToolParam( description = @@ -98,7 +101,7 @@ public SingleToolResponse getRouteCoverage( } @Override - protected RouteCoverageResponse doExecute(RouteCoverageParams params, List warnings) + protected RouteCoverageResponseLight doExecute(RouteCoverageParams params, List warnings) throws Exception { var sdk = getContrastSDK(); var orgId = getOrgId(); @@ -166,6 +169,7 @@ protected RouteCoverageResponse doExecute(RouteCoverageParams params, List 0).as("Should have at least 1 route").isTrue(); + assertThat(result.data().routes()).as("Routes should not be null").isNotNull(); + assertThat(result.data().routes().size() > 0).as("Should have at least 1 route").isTrue(); log.info( "✓ Retrieved {} routes for application: {}", - result.data().getRoutes().size(), + result.data().routes().size(), testData.appName); - // Count exercised vs discovered routes - long exercisedCount = - result.data().getRoutes().stream().filter(route -> route.getExercised() > 0).count(); - long discoveredCount = result.data().getRoutes().size() - exercisedCount; + // Light response includes aggregate statistics + log.info(" Exercised routes: {}", result.data().exercisedCount()); + log.info(" Discovered routes: {}", result.data().discoveredCount()); + log.info(" Coverage percent: {}%", result.data().coveragePercent()); - log.info(" Exercised routes: {}", exercisedCount); - log.info(" Discovered routes: {}", discoveredCount); - - // Verify all routes have details - for (Route route : result.data().getRoutes()) { - assertThat(route.getSignature()).as("Route signature should not be null").isNotNull(); - assertThat(route.getRouteHash()).as("Route hash should not be null").isNotNull(); - assertThat(route.getRouteDetailsResponse()) - .as("Route details should be populated") - .isNotNull(); + // Verify all routes have essential fields in light format + for (RouteLight route : result.data().routes()) { + assertThat(route.signature()).as("Route signature should not be null").isNotNull(); + assertThat(route.routeHash()).as("Route hash should not be null").isNotNull(); } } @@ -248,21 +242,21 @@ void getRouteCoverage_should_filter_by_session_metadata() { assertThat(result).as("Response should not be null").isNotNull(); assertThat(result.isSuccess()).as("Response should indicate success").isTrue(); assertThat(result.data()).as("Data should not be null").isNotNull(); - assertThat(result.data().getRoutes()).as("Routes should not be null").isNotNull(); + assertThat(result.data().routes()).as("Routes should not be null").isNotNull(); log.info( "✓ Retrieved {} routes for application: {}", - result.data().getRoutes().size(), + result.data().routes().size(), testData.appName); log.info( " Filtered by session metadata: {}={}", testData.sessionMetadataName, testData.sessionMetadataValue); - // Verify route details are populated - for (Route route : result.data().getRoutes()) { - assertThat(route.getRouteDetailsResponse()) - .as("Route details should be populated for filtered routes") + // Verify routes have essential fields in light format + for (RouteLight route : result.data().routes()) { + assertThat(route.signature()) + .as("Route signature should be present for filtered routes") .isNotNull(); } } @@ -285,24 +279,21 @@ void getRouteCoverage_should_filter_by_latest_session() { .as("Response should indicate success. Application should have session metadata.") .isTrue(); assertThat(result.data()).as("Data should not be null").isNotNull(); - assertThat(result.data().getRoutes()) + assertThat(result.data().routes()) .as("Routes should not be null when success is true") .isNotNull(); - log.info("✓ Retrieved {} routes from latest session", result.data().getRoutes().size()); + log.info("✓ Retrieved {} routes from latest session", result.data().routes().size()); log.info(" Application: {}", testData.appName); - // Count exercised vs discovered - long exercisedCount = - result.data().getRoutes().stream().filter(route -> route.getExercised() > 0).count(); + // Light response includes aggregate statistics + log.info(" Exercised: {}", result.data().exercisedCount()); + log.info(" Discovered: {}", result.data().discoveredCount()); - log.info(" Exercised: {}", exercisedCount); - log.info(" Discovered: {}", (result.data().getRoutes().size() - exercisedCount)); - - // Verify all routes have details - for (Route route : result.data().getRoutes()) { - assertThat(route.getRouteDetailsResponse()) - .as("Route details should be populated for latest session") + // Verify all routes have essential fields in light format + for (RouteLight route : result.data().routes()) { + assertThat(route.signature()) + .as("Route signature should be present for latest session") .isNotNull(); } } @@ -320,11 +311,11 @@ void getRouteCoverage_should_treat_empty_strings_as_null() { assertThat(result.isSuccess()).as("Response should be successful").isTrue(); log.info("✓ Response successful: {}", result.isSuccess()); - log.info("✓ Routes returned: {}", result.data().getRoutes().size()); + log.info("✓ Routes returned: {}", result.data().routes().size()); // Should return routes (assuming the app has route coverage) if (testData.hasRouteCoverage) { - assertThat(result.data().getRoutes().size() > 0) + assertThat(result.data().routes().size() > 0) .as( "Empty strings should return all routes (unfiltered query) when app has route" + " coverage") @@ -368,54 +359,50 @@ void getRouteCoverage_should_handle_nonexistent_metadata_gracefully() { log.info("✓ API handled non-existent metadata gracefully"); log.info( " Routes returned: {} (expected 0 for non-existent metadata)", - result.data().getRoutes().size()); + result.data().routes().size()); // With non-existent metadata, we expect 0 routes - assertThat(result.data().getRoutes().size()) + assertThat(result.data().routes().size()) .as("Non-existent metadata should return 0 routes") .isEqualTo(0); } - // ========== Route details validation ========== + // ========== Route structure validation ========== @Test - void getRouteCoverage_should_populate_route_details_for_all_routes() { - log.info("\n=== Integration Test: Route details structure validation ==="); + void getRouteCoverage_should_populate_route_fields_for_all_routes() { + log.info("\n=== Integration Test: Route light structure validation ==="); var result = getRouteCoverageTool.getRouteCoverage(testData.appId, null, null, null); assertThat(result).as("Response should not be null").isNotNull(); assertThat(result.isSuccess()).as("Response should indicate success").isTrue(); - assertThat(result.data().getRoutes().size() > 0) + assertThat(result.data().routes().size() > 0) .as("Should have at least 1 route to validate") .isTrue(); - log.info("✓ Validating structure of {} routes", result.data().getRoutes().size()); + log.info("✓ Validating structure of {} routes", result.data().routes().size()); - // Validate structure of each route and its details - for (Route route : result.data().getRoutes()) { + // Validate structure of each route in light format + for (RouteLight route : result.data().routes()) { // Route itself should have key fields - assertThat(route.getSignature()) + assertThat(route.signature()) .as("Route signature should be present") .isNotNull() .isNotEmpty(); - assertThat(route.getRouteHash()).as("Route hash should be present").isNotNull().isNotEmpty(); - - // exercised field should be set (can be 0 for discovered routes) - assertThat(route.getExercised()) - .as("Route exercised count should be set (can be 0)") - .isNotNull(); + assertThat(route.routeHash()).as("Route hash should be present").isNotNull().isNotEmpty(); - // Route details should be populated - assertThat(route.getRouteDetailsResponse()) - .as("Route details response should be populated") - .isNotNull(); - assertThat(route.getRouteDetailsResponse().isSuccess()) - .as("Route details should be successfully fetched") - .isTrue(); + // status field should be set + assertThat(route.status()).as("Route status should be present").isNotNull(); } - log.info("✓ All {} routes have valid structure and details", result.data().getRoutes().size()); + // Verify aggregate statistics are computed + assertThat(result.data().totalRoutes()).as("Total routes should be set").isGreaterThan(0); + assertThat(result.data().exercisedCount() + result.data().discoveredCount()) + .as("Exercised + discovered should equal total") + .isEqualTo(result.data().totalRoutes()); + + log.info("✓ All {} routes have valid light structure", result.data().routes().size()); } // ========== Comparison test ========== @@ -447,14 +434,13 @@ void getRouteCoverage_should_return_same_or_more_routes_unfiltered_vs_filtered() assertThat(latestSessionResult.isSuccess()).as("Latest session query should succeed").isTrue(); log.info("✓ All filter types work correctly:"); - log.info(" Unfiltered routes: {}", unfilteredResult.data().getRoutes().size()); - log.info(" Session metadata routes: {}", sessionMetadataResult.data().getRoutes().size()); - log.info(" Latest session routes: {}", latestSessionResult.data().getRoutes().size()); + log.info(" Unfiltered routes: {}", unfilteredResult.data().routes().size()); + log.info(" Session metadata routes: {}", sessionMetadataResult.data().routes().size()); + log.info(" Latest session routes: {}", latestSessionResult.data().routes().size()); // Verify unfiltered should have >= filtered results assertThat( - unfilteredResult.data().getRoutes().size() - >= sessionMetadataResult.data().getRoutes().size()) + unfilteredResult.data().routes().size() >= sessionMetadataResult.data().routes().size()) .as("Unfiltered query should return same or more routes than filtered query") .isTrue(); } diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageToolTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageToolTest.java index dd108496..0e14400d 100644 --- a/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageToolTest.java +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageToolTest.java @@ -27,6 +27,7 @@ import static org.mockito.Mockito.when; import com.contrast.labs.ai.mcp.contrast.config.ContrastSDKFactory; +import com.contrast.labs.ai.mcp.contrast.mapper.RouteMapper; import com.contrast.labs.ai.mcp.contrast.sdkextension.SDKExtension; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Observation; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Route; @@ -55,17 +56,19 @@ class GetRouteCoverageToolTest { private ContrastSDKFactory sdkFactory; private ContrastSDK sdk; private SDKExtension sdkExtension; + private RouteMapper routeMapper; @BeforeEach void setUp() { sdk = mock(); sdkFactory = mock(); sdkExtension = mock(); + routeMapper = new RouteMapper(); when(sdkFactory.getSDK()).thenReturn(sdk); when(sdkFactory.getOrgId()).thenReturn(ORG_ID); - tool = new GetRouteCoverageTool(); + tool = new GetRouteCoverageTool(routeMapper); ReflectionTestUtils.setField(tool, "sdkFactory", sdkFactory); } @@ -125,7 +128,7 @@ void getRouteCoverage_should_return_data_when_unfiltered() throws Exception { assertThat(result.isSuccess()).isTrue(); assertThat(result.found()).isTrue(); assertThat(result.data()).isNotNull(); - assertThat(result.data().getRoutes()).hasSize(2); + assertThat(result.data().routes()).hasSize(2); // Observations are included inline - no N+1 calls to getRouteDetails var constructedMock = mockedConstruction.constructed().get(0); @@ -150,7 +153,7 @@ void getRouteCoverage_should_return_empty_routes_when_none_found() throws Except assertThat(result.isSuccess()).isTrue(); assertThat(result.data()).isNotNull(); - assertThat(result.data().getRoutes()).isEmpty(); + assertThat(result.data().routes()).isEmpty(); var constructedMock = mockedConstruction.constructed().get(0); verify(constructedMock, never()).getRouteDetails(anyString(), anyString(), anyString()); @@ -178,7 +181,7 @@ void getRouteCoverage_should_filter_by_session_metadata() throws Exception { assertThat(result.isSuccess()).isTrue(); assertThat(result.data()).isNotNull(); - assertThat(result.data().getRoutes()).hasSize(1); + assertThat(result.data().routes()).hasSize(1); var constructedMock = mockedConstruction.constructed().get(0); var captor = @@ -217,7 +220,7 @@ void getRouteCoverage_should_filter_by_latest_session() throws Exception { assertThat(result.isSuccess()).isTrue(); assertThat(result.data()).isNotNull(); - assertThat(result.data().getRoutes()).hasSize(1); + assertThat(result.data().routes()).hasSize(1); var constructedMock = mockedConstruction.constructed().get(0); verify(constructedMock).getLatestSessionMetadata(eq(ORG_ID), eq(VALID_APP_ID)); @@ -302,7 +305,7 @@ void getRouteCoverage_should_handle_null_routes_list() throws Exception { assertThat(result.isSuccess()).isTrue(); assertThat(result.data()).isNotNull(); - assertThat(result.data().getRoutes()).isEmpty(); + assertThat(result.data().routes()).isEmpty(); } } @@ -350,13 +353,13 @@ void getRouteCoverage_should_return_observations_inline() throws Exception { var result = tool.getRouteCoverage(VALID_APP_ID, null, null, null); assertThat(result.isSuccess()).isTrue(); - assertThat(result.data().getRoutes()).hasSize(3); + assertThat(result.data().routes()).hasSize(3); // Verify observations are included inline (from expand=observations) - for (var route : result.data().getRoutes()) { - assertThat(route.getObservations()).isNotNull(); - assertThat(route.getObservations()).hasSize(1); - assertThat(route.getTotalObservations()).isEqualTo(1L); + for (var route : result.data().routes()) { + assertThat(route.observations()).isNotNull(); + assertThat(route.observations()).hasSize(1); + assertThat(route.totalObservations()).isEqualTo(1L); } // Verify N+1 calls to getRouteDetails are NOT made @@ -365,6 +368,52 @@ void getRouteCoverage_should_return_observations_inline() throws Exception { } } + // ========== Light response transformation tests ========== + + @Test + void getRouteCoverage_should_return_light_response_with_aggregate_statistics() throws Exception { + var mockResponse = createMockRouteCoverageResponse(4); + // Set status on routes: 2 exercised (even indices), 2 discovered (odd indices) + mockResponse.getRoutes().get(0).setStatus("EXERCISED"); + mockResponse.getRoutes().get(1).setStatus("DISCOVERED"); + mockResponse.getRoutes().get(2).setStatus("EXERCISED"); + mockResponse.getRoutes().get(3).setStatus("DISCOVERED"); + // Add vulnerability counts + mockResponse.getRoutes().get(0).setVulnerabilities(2); + mockResponse.getRoutes().get(0).setCriticalVulnerabilities(1); + mockResponse.getRoutes().get(1).setVulnerabilities(3); + mockResponse.getRoutes().get(1).setCriticalVulnerabilities(2); + + try (var mockedConstruction = + mockConstruction( + SDKExtension.class, + (mock, context) -> { + when(mock.getRouteCoverage(eq(ORG_ID), eq(VALID_APP_ID), isNull())) + .thenReturn(mockResponse); + })) { + + var result = tool.getRouteCoverage(VALID_APP_ID, null, null, null); + + assertThat(result.isSuccess()).isTrue(); + var lightResponse = result.data(); + + // Verify aggregate statistics are computed + assertThat(lightResponse.totalRoutes()).isEqualTo(4); + assertThat(lightResponse.exercisedCount()).isEqualTo(2); + assertThat(lightResponse.discoveredCount()).isEqualTo(2); + assertThat(lightResponse.coveragePercent()).isEqualTo(50.0); + assertThat(lightResponse.totalVulnerabilities()).isEqualTo(5); + assertThat(lightResponse.totalCriticalVulnerabilities()).isEqualTo(3); + + // Verify routes are transformed to light format + assertThat(lightResponse.routes()).hasSize(4); + var firstRoute = lightResponse.routes().get(0); + assertThat(firstRoute.signature()).isEqualTo("GET /api/endpoint0"); + assertThat(firstRoute.routeHash()).isEqualTo(ROUTE_HASH + "-0"); + assertThat(firstRoute.status()).isEqualTo("EXERCISED"); + } + } + // ========== Helper methods ========== private RouteCoverageResponse createMockRouteCoverageResponse(int routeCount) { From e442af932b9ade679fbc32db6cabb9184ca55f6f Mon Sep 17 00:00:00 2001 From: Chris Edwards Date: Sat, 10 Jan 2026 00:24:18 -0500 Subject: [PATCH 10/12] refactor: improve RouteMapper and RouteLight based on code review - RouteLight: add compact constructor to ensure observations is never null - RouteMapper: use case-insensitive status comparison for robustness - RouteMapper: count DISCOVERED explicitly instead of subtracting from total - RouteMapper: round coveragePercent to 2 decimal places - MetadataJsonFilterSpec: add validation error for empty array values --- .../labs/ai/mcp/contrast/data/RouteLight.java | 9 ++++- .../ai/mcp/contrast/mapper/RouteMapper.java | 8 ++-- .../validation/MetadataJsonFilterSpec.java | 7 +++- .../ai/mcp/contrast/data/RouteLightTest.java | 4 +- .../mcp/contrast/mapper/RouteMapperTest.java | 37 +++++++++++++++++++ .../MetadataJsonFilterSpecTest.java | 9 +++++ 6 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/data/RouteLight.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/data/RouteLight.java index bae0516b..5e39de71 100644 --- a/src/main/java/com/contrast/labs/ai/mcp/contrast/data/RouteLight.java +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/data/RouteLight.java @@ -16,6 +16,7 @@ package com.contrast.labs.ai.mcp.contrast.data; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Observation; +import java.util.Collections; import java.util.List; /** @@ -46,4 +47,10 @@ public record RouteLight( long discovered, int serversTotal, List observations, - Long totalObservations) {} + Long totalObservations) { + + /** Compact constructor ensures observations is never null. */ + public RouteLight { + observations = observations != null ? observations : Collections.emptyList(); + } +} diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapper.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapper.java index 29eda614..4f43e420 100644 --- a/src/main/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapper.java +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapper.java @@ -66,9 +66,11 @@ public RouteCoverageResponseLight toResponseLight(RouteCoverageResponse response int totalRoutes = routes.size(); int exercisedCount = - (int) routes.stream().filter(r -> "EXERCISED".equals(r.getStatus())).count(); - int discoveredCount = totalRoutes - exercisedCount; - double coveragePercent = totalRoutes > 0 ? (exercisedCount * 100.0) / totalRoutes : 0.0; + (int) routes.stream().filter(r -> "EXERCISED".equalsIgnoreCase(r.getStatus())).count(); + int discoveredCount = + (int) routes.stream().filter(r -> "DISCOVERED".equalsIgnoreCase(r.getStatus())).count(); + double coveragePercent = + totalRoutes > 0 ? Math.round((exercisedCount * 100.0) / totalRoutes * 100.0) / 100.0 : 0.0; int totalVulnerabilities = routes.stream().mapToInt(Route::getVulnerabilities).sum(); int totalCriticalVulnerabilities = routes.stream().mapToInt(Route::getCriticalVulnerabilities).sum(); diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/tool/validation/MetadataJsonFilterSpec.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/tool/validation/MetadataJsonFilterSpec.java index 557513d8..494d6ec4 100644 --- a/src/main/java/com/contrast/labs/ai/mcp/contrast/tool/validation/MetadataJsonFilterSpec.java +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/tool/validation/MetadataJsonFilterSpec.java @@ -94,6 +94,11 @@ private List parseValues(Object val, String fieldName, List inva } else if (val instanceof Number n) { return List.of(formatNumber(n)); } else if (val instanceof List list) { + if (list.isEmpty()) { + invalidEntries.add( + String.format("'%s' (empty array - must have at least one value)", fieldName)); + return null; + } var strings = new ArrayList(); for (Object item : list) { if (item instanceof String s) { @@ -105,7 +110,7 @@ private List parseValues(Object val, String fieldName, List inva return null; } } - return strings.isEmpty() ? null : List.copyOf(strings); + return List.copyOf(strings); } else if (val != null) { invalidEntries.add(String.format("'%s' (expected string or array of strings)", fieldName)); } diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/data/RouteLightTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/data/RouteLightTest.java index 96d45a4f..62bf0ed5 100644 --- a/src/test/java/com/contrast/labs/ai/mcp/contrast/data/RouteLightTest.java +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/data/RouteLightTest.java @@ -57,11 +57,11 @@ void should_create_route_light_with_all_fields() { } @Test - void should_handle_null_optional_fields() { + void should_convert_null_observations_to_empty_list() { var route = new RouteLight("signature", List.of(), "DISCOVERED", "hash", 0, 0, 0L, 0L, 0, null, null); - assertThat(route.observations()).isNull(); + assertThat(route.observations()).isEmpty(); assertThat(route.totalObservations()).isNull(); } } diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapperTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapperTest.java index d621ead3..b9bf9f18 100644 --- a/src/test/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapperTest.java +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapperTest.java @@ -180,4 +180,41 @@ void toResponseLight_should_handle_all_exercised() { assertThat(result.discoveredCount()).isZero(); assertThat(result.coveragePercent()).isCloseTo(100.0, within(0.01)); } + + @Test + void toResponseLight_should_match_status_case_insensitively() { + var route1 = new Route(); + route1.setStatus("exercised"); // lowercase + var route2 = new Route(); + route2.setStatus("Discovered"); // mixed case + + var response = new RouteCoverageResponse(); + response.setSuccess(true); + response.setRoutes(List.of(route1, route2)); + + var result = mapper.toResponseLight(response); + + assertThat(result.exercisedCount()).isEqualTo(1); + assertThat(result.discoveredCount()).isEqualTo(1); + } + + @Test + void toResponseLight_should_round_coverage_percent_to_two_decimals() { + // 1 out of 3 exercised = 33.333...% + var exercised = new Route(); + exercised.setStatus("EXERCISED"); + var discovered1 = new Route(); + discovered1.setStatus("DISCOVERED"); + var discovered2 = new Route(); + discovered2.setStatus("DISCOVERED"); + + var response = new RouteCoverageResponse(); + response.setSuccess(true); + response.setRoutes(List.of(exercised, discovered1, discovered2)); + + var result = mapper.toResponseLight(response); + + // Should be rounded to 33.33, not 33.333... + assertThat(result.coveragePercent()).isEqualTo(33.33); + } } diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/validation/MetadataJsonFilterSpecTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/validation/MetadataJsonFilterSpecTest.java index 6acc0ddf..1689cfe4 100644 --- a/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/validation/MetadataJsonFilterSpecTest.java +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/validation/MetadataJsonFilterSpecTest.java @@ -64,6 +64,15 @@ void get_should_return_null_for_empty_json_object() { assertThat(ctx.isValid()).isTrue(); } + @Test + void get_should_add_error_for_empty_array_value() { + var ctx = new ToolValidationContext(); + var result = ctx.metadataJsonFilterParam("{\"field\":[]}", "test").get(); + assertThat(result).isNull(); + assertThat(ctx.isValid()).isFalse(); + assertThat(ctx.errors()).anyMatch(e -> e.contains("field") && e.contains("empty array")); + } + @Test void get_should_add_error_for_invalid_json() { var ctx = new ToolValidationContext(); From f838feb35762f390336bbdf4d61c29c9ebce6411 Mon Sep 17 00:00:00 2001 From: Chris Edwards Date: Sat, 10 Jan 2026 00:33:12 -0500 Subject: [PATCH 11/12] refactor: remove unused route details code and optimize aggregate computation - Use single-pass loop in RouteMapper for aggregate statistics instead of 4 separate stream operations (minor performance improvement) - Remove unused routeDetailsResponse field from Route class - Remove getRouteDetails() method from SDKExtension (N+1 pattern eliminated) - Delete RouteDetailsResponse class (no longer needed) - Update tests to remove references to deleted method --- .../ai/mcp/contrast/mapper/RouteMapper.java | 29 ++++++++++++------- .../contrast/sdkextension/SDKExtension.java | 29 ------------------- .../data/routecoverage/Route.java | 2 -- .../routecoverage/RouteDetailsResponse.java | 29 ------------------- .../coverage/GetRouteCoverageToolTest.java | 10 +------ 5 files changed, 20 insertions(+), 79 deletions(-) delete mode 100644 src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/RouteDetailsResponse.java diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapper.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapper.java index 4f43e420..7a18a202 100644 --- a/src/main/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapper.java +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapper.java @@ -25,8 +25,7 @@ /** * Mapper for transforming Route objects into lightweight RouteLight representations. Eliminates - * redundant fields (app, servers, routeDetailsResponse, routeHashString) while preserving essential - * route coverage data. + * redundant fields (app, servers, routeHashString) while preserving essential route coverage data. */ @Component public class RouteMapper { @@ -54,7 +53,8 @@ public RouteLight toRouteLight(Route route) { } /** - * Transform full RouteCoverageResponse to lightweight version with aggregate statistics. + * Transform full RouteCoverageResponse to lightweight version with aggregate statistics. Uses + * single-pass computation for efficiency with large route lists. * * @param response Full route coverage response from SDK * @return Lightweight response with RouteLight objects and computed aggregates @@ -64,16 +64,25 @@ public RouteCoverageResponseLight toResponseLight(RouteCoverageResponse response var lightRoutes = routes.stream().map(this::toRouteLight).toList(); + // Single-pass computation for all aggregates + int exercisedCount = 0; + int discoveredCount = 0; + int totalVulnerabilities = 0; + int totalCriticalVulnerabilities = 0; + + for (var route : routes) { + if ("EXERCISED".equalsIgnoreCase(route.getStatus())) { + exercisedCount++; + } else if ("DISCOVERED".equalsIgnoreCase(route.getStatus())) { + discoveredCount++; + } + totalVulnerabilities += route.getVulnerabilities(); + totalCriticalVulnerabilities += route.getCriticalVulnerabilities(); + } + int totalRoutes = routes.size(); - int exercisedCount = - (int) routes.stream().filter(r -> "EXERCISED".equalsIgnoreCase(r.getStatus())).count(); - int discoveredCount = - (int) routes.stream().filter(r -> "DISCOVERED".equalsIgnoreCase(r.getStatus())).count(); double coveragePercent = totalRoutes > 0 ? Math.round((exercisedCount * 100.0) / totalRoutes * 100.0) / 100.0 : 0.0; - int totalVulnerabilities = routes.stream().mapToInt(Route::getVulnerabilities).sum(); - int totalCriticalVulnerabilities = - routes.stream().mapToInt(Route::getCriticalVulnerabilities).sum(); return new RouteCoverageResponseLight( response.isSuccess(), diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.java index 5234ef75..3cc074a4 100644 --- a/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.java +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.java @@ -22,7 +22,6 @@ import com.contrast.labs.ai.mcp.contrast.sdkextension.data.adr.AttacksResponse; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.application.ApplicationsResponse; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageResponse; -import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteDetailsResponse; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.sca.LibraryObservation; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.sca.LibraryObservationsResponse; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.sessionmetadata.SessionMetadataResponse; @@ -175,27 +174,6 @@ private String getLibraryObservationsUrl( organizationId, applicationId, libraryId, offset, limit); } - /** - * Retrieves the detailed observations for a specific route. - * - * @param organizationId The organization ID - * @param applicationId The application ID - * @param routeHash The unique hash identifying the route - * @return RouteDetailsResponse containing observations for the route - * @throws IOException If an I/O error occurs - * @throws UnauthorizedException If the request is not authorized - */ - public RouteDetailsResponse getRouteDetails( - String organizationId, String applicationId, String routeHash) - throws IOException, UnauthorizedException { - var url = getRouteDetailsUrl(organizationId, applicationId, routeHash); - - try (InputStream is = contrastSDK.makeRequest(HttpMethod.GET, url); - Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { - return gson.fromJson(reader, RouteDetailsResponse.class); - } - } - /** * Retrieves route coverage information for an application. * @@ -233,13 +211,6 @@ public RouteCoverageResponse getRouteCoverage( } } - /** Builds URL for retrieving route details observations */ - private String getRouteDetailsUrl(String organizationId, String applicationId, String routeHash) { - return String.format( - "/ng/%s/applications/%s/route/%s/observations?expand=skip_links,session_metadata", - organizationId, applicationId, routeHash); - } - /** * Retrieves all applications for an organization. * diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/Route.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/Route.java index 014e12a4..b8d14db8 100644 --- a/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/Route.java +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/Route.java @@ -32,8 +32,6 @@ public class Route { private long discovered; private String status; - private RouteDetailsResponse routeDetailsResponse; - @SerializedName("route_hash") private String routeHash; diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/RouteDetailsResponse.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/RouteDetailsResponse.java deleted file mode 100644 index 0d9ec976..00000000 --- a/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/RouteDetailsResponse.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2025 Contrast Security - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage; - -import java.util.List; -import lombok.Data; - -/** Represents the response for route details containing observations. */ -@Data -public class RouteDetailsResponse { - - private boolean success; - private List messages; - private List observations; - private int total; -} diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageToolTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageToolTest.java index 0e14400d..dd6eb912 100644 --- a/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageToolTest.java +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageToolTest.java @@ -130,10 +130,9 @@ void getRouteCoverage_should_return_data_when_unfiltered() throws Exception { assertThat(result.data()).isNotNull(); assertThat(result.data().routes()).hasSize(2); - // Observations are included inline - no N+1 calls to getRouteDetails + // Observations are included inline via expand=observations var constructedMock = mockedConstruction.constructed().get(0); verify(constructedMock).getRouteCoverage(eq(ORG_ID), eq(VALID_APP_ID), isNull()); - verify(constructedMock, never()).getRouteDetails(anyString(), anyString(), anyString()); } } @@ -154,9 +153,6 @@ void getRouteCoverage_should_return_empty_routes_when_none_found() throws Except assertThat(result.isSuccess()).isTrue(); assertThat(result.data()).isNotNull(); assertThat(result.data().routes()).isEmpty(); - - var constructedMock = mockedConstruction.constructed().get(0); - verify(constructedMock, never()).getRouteDetails(anyString(), anyString(), anyString()); } } @@ -361,10 +357,6 @@ void getRouteCoverage_should_return_observations_inline() throws Exception { assertThat(route.observations()).hasSize(1); assertThat(route.totalObservations()).isEqualTo(1L); } - - // Verify N+1 calls to getRouteDetails are NOT made - var constructedMock = mockedConstruction.constructed().get(0); - verify(constructedMock, never()).getRouteDetails(anyString(), anyString(), anyString()); } } From 7c34b034df7e96edacc55e93a5cc2348ebb151e4 Mon Sep 17 00:00:00 2001 From: Chris Edwards Date: Mon, 12 Jan 2026 09:41:49 -0500 Subject: [PATCH 12/12] chore: remove unused PaginationHandler and update gitignore - Delete PaginationHandler.java and PaginationHandlerTest.java (unused) - Add .tldr/ and .tldrignore to gitignore (TLDR tool artifacts) - Update CLAUDE.md with PR description guidance --- .gitignore | 2 + CLAUDE.md | 4 +- .../mcp/contrast/utils/PaginationHandler.java | 126 ---------- .../contrast/utils/PaginationHandlerTest.java | 221 ------------------ 4 files changed, 5 insertions(+), 348 deletions(-) delete mode 100644 src/main/java/com/contrast/labs/ai/mcp/contrast/utils/PaginationHandler.java delete mode 100644 src/test/java/com/contrast/labs/ai/mcp/contrast/utils/PaginationHandlerTest.java diff --git a/.gitignore b/.gitignore index 3f5e3482..320a59f6 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,5 @@ test-plan-results/ # MCP local config .mcp.json test-plans-archive/ +.tldr/ +.tldrignore diff --git a/CLAUDE.md b/CLAUDE.md index 8557de74..5a15bf30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -401,7 +401,9 @@ Human review is the bottleneck in AI-assisted development. Creating exceptional - Manual testing performed - Edge cases covered -**Goal**: Make reviewing effortless by providing all information the reviewer needs to understand and evaluate the changes with confidence and speed. The reviewer should not need to ask clarifying questions. +**Goal**: Make reviewing effortless by providing all information the reviewer needs to understand and evaluate the changes with confidence and speed. The reviewer should not need to ask clarifying questions. + +**IMPORTANT**: Ensure the text of the PR clearly explains all the changes and leads the developer through the logical progression of the chain of thought that produced it. ### Moving to Review diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/utils/PaginationHandler.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/utils/PaginationHandler.java deleted file mode 100644 index 8ad8db6d..00000000 --- a/src/main/java/com/contrast/labs/ai/mcp/contrast/utils/PaginationHandler.java +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright 2025 Contrast Security - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.contrast.labs.ai.mcp.contrast.utils; - -import com.contrast.labs.ai.mcp.contrast.PaginationParams; -import com.contrast.labs.ai.mcp.contrast.tool.base.PaginatedToolResponse; -import java.util.ArrayList; -import java.util.List; -import org.springframework.stereotype.Component; -import org.springframework.util.CollectionUtils; -import org.springframework.util.StringUtils; - -/** - * Centralized pagination handler for all MCP Server endpoints. Provides consistent pagination logic - * including: - hasMorePages calculation (based on totalCount or heuristic) - Empty result messaging - * - Response construction with validation messages - */ -@Component -public class PaginationHandler { - - /** - * Creates a PaginatedResponse from API-paginated items. Use this when the API has already - * paginated the data (e.g., SDK returns page of results). - * - * @param items The items returned by the API for this page - * @param params Validated pagination parameters - * @param totalCount Total count from API (null if unavailable) - * @param Type of items - * @return PaginatedResponse with calculated hasMorePages and messages - */ - public PaginatedToolResponse createPaginatedResponse( - List items, PaginationParams params, Integer totalCount) { - return createPaginatedResponse(items, params, totalCount, List.of()); - } - - /** - * Creates a PaginatedResponse from API-paginated items with additional warnings. Use this when - * the API has already paginated the data (e.g., SDK returns page of results). - * - * @param items The items returned by the API for this page - * @param params Validated pagination parameters - * @param totalCount Total count from API (null if unavailable) - * @param additionalWarnings Extra warnings to include (e.g., filter validation warnings) - * @param Type of items - * @return PaginatedResponse with calculated hasMorePages and messages - */ - public PaginatedToolResponse createPaginatedResponse( - List items, PaginationParams params, Integer totalCount, List additionalWarnings) { - boolean hasMorePages = calculateHasMorePages(params, totalCount, items.size()); - var emptyResultWarning = buildEmptyResultMessage(items, params, totalCount); - - // Collect all warnings - var warnings = new ArrayList(); - warnings.addAll(params.warnings()); - if (!CollectionUtils.isEmpty(additionalWarnings)) { - warnings.addAll(additionalWarnings); - } - if (StringUtils.hasText(emptyResultWarning)) { - warnings.add(emptyResultWarning); - } - - return PaginatedToolResponse.success( - items, params.page(), params.pageSize(), totalCount, hasMorePages, warnings, null); - } - - /** - * Calculate whether more pages exist. Uses totalCount if available, otherwise uses heuristic - * (full page = more exist). - * - * @param params Pagination parameters - * @param totalCount Total count from API (null if unavailable) - * @param itemsReturned Number of items in current page - * @return true if more pages likely exist - */ - private boolean calculateHasMorePages( - PaginationParams params, Integer totalCount, int itemsReturned) { - if (totalCount != null) { - // Accurate calculation: more pages exist if we haven't fetched everything - return (params.page() * params.pageSize()) < totalCount; - } else { - // Heuristic: if we got a full page, assume more exist - return itemsReturned == params.pageSize(); - } - } - - /** - * Build helpful message for empty results or page-beyond-bounds scenarios. - * - * @param items Items in current page - * @param params Pagination parameters - * @param totalCount Total count (null if unavailable) - * @return Message string or null if no message needed - */ - private String buildEmptyResultMessage( - List items, PaginationParams params, Integer totalCount) { - if (!items.isEmpty()) { - return null; // No message needed for non-empty results - } - - if (params.page() == 1) { - return "No items found."; - } else { - // Page > 1 with empty results - if (totalCount != null) { - int totalPages = (int) Math.ceil((double) totalCount / params.pageSize()); - return String.format( - "Requested page %d exceeds available pages (total: %d).", params.page(), totalPages); - } else { - return String.format("Requested page %d returned no results.", params.page()); - } - } - } -} diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/utils/PaginationHandlerTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/utils/PaginationHandlerTest.java deleted file mode 100644 index 29cc46a8..00000000 --- a/src/test/java/com/contrast/labs/ai/mcp/contrast/utils/PaginationHandlerTest.java +++ /dev/null @@ -1,221 +0,0 @@ -/* - * Copyright 2025 Contrast Security - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.contrast.labs.ai.mcp.contrast.utils; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.contrast.labs.ai.mcp.contrast.PaginationParams; -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.IntStream; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -/** Tests for PaginationHandler component. */ -class PaginationHandlerTest { - - private PaginationHandler handler; - - @BeforeEach - void setUp() { - handler = new PaginationHandler(); - } - - // ========== createPaginatedResponse Tests ========== - - @Test - void testCreatePaginatedResponse_withTotalCount_firstPage() { - var items = List.of("item1", "item2", "item3"); - var params = PaginationParams.of(1, 3); - - var response = handler.createPaginatedResponse(items, params, 10); - - assertThat(response.items().size()).isEqualTo(3); - assertThat(response.page()).isEqualTo(1); - assertThat(response.pageSize()).isEqualTo(3); - assertThat(response.totalItems()).isEqualTo(10); - assertThat(response.hasMorePages()) - .as("Should have more pages when 3 items fetched out of 10") - .isTrue(); - assertThat(response.errors()).as("No errors for successful page").isEmpty(); - assertThat(response.warnings()).as("No warnings for successful page").isEmpty(); - } - - @Test - void testCreatePaginatedResponse_withTotalCount_lastPage() { - var items = List.of("item8", "item9", "item10"); - var params = PaginationParams.of(4, 3); - - var response = handler.createPaginatedResponse(items, params, 10); - - assertThat(response.items().size()).isEqualTo(3); - assertThat(response.page()).isEqualTo(4); - assertThat(response.pageSize()).isEqualTo(3); - assertThat(response.totalItems()).isEqualTo(10); - assertThat(response.hasMorePages()) - .as("Should not have more pages (page 4 * pageSize 3 = 12 >= 10)") - .isFalse(); - assertThat(response.errors()).isEmpty(); - assertThat(response.warnings()).isEmpty(); - } - - @Test - void testCreatePaginatedResponse_withoutTotalCount_fullPage() { - var items = List.of("item1", "item2", "item3"); - var params = PaginationParams.of(1, 3); - - var response = handler.createPaginatedResponse(items, params, null); - - assertThat(response.items().size()).isEqualTo(3); - assertThat(response.totalItems()).isNull(); - assertThat(response.hasMorePages()) - .as("Full page without totalCount suggests more pages exist (heuristic)") - .isTrue(); - } - - @Test - void testCreatePaginatedResponse_withoutTotalCount_partialPage() { - var items = List.of("item1", "item2"); - var params = PaginationParams.of(1, 3); - - var response = handler.createPaginatedResponse(items, params, null); - - assertThat(response.items().size()).isEqualTo(2); - assertThat(response.totalItems()).isNull(); - assertThat(response.hasMorePages()) - .as("Partial page suggests no more pages (heuristic)") - .isFalse(); - } - - @Test - void testCreatePaginatedResponse_emptyFirstPage() { - var items = List.of(); - var params = PaginationParams.of(1, 10); - - var response = handler.createPaginatedResponse(items, params, 0); - - assertThat(response.items()).isEmpty(); - assertThat(response.totalItems()).isEqualTo(0); - assertThat(response.hasMorePages()).isFalse(); - assertThat(response.warnings()).containsExactly("No items found."); - } - - @Test - void testCreatePaginatedResponse_emptySecondPage_withTotalCount() { - var items = List.of(); - var params = PaginationParams.of(2, 10); - - var response = handler.createPaginatedResponse(items, params, 5); - - assertThat(response.items()).isEmpty(); - assertThat(response.totalItems()).isEqualTo(5); - assertThat(response.hasMorePages()).isFalse(); - assertThat(response.warnings()) - .anyMatch(w -> w.contains("Requested page 2 exceeds available pages (total: 1)")); - } - - @Test - void testCreatePaginatedResponse_emptySecondPage_withoutTotalCount() { - var items = List.of(); - var params = PaginationParams.of(2, 10); - - var response = handler.createPaginatedResponse(items, params, null); - - assertThat(response.items()).isEmpty(); - assertThat(response.totalItems()).isNull(); - assertThat(response.hasMorePages()).isFalse(); - assertThat(response.warnings()) - .anyMatch(w -> w.contains("Requested page 2 returned no results")); - } - - @Test - void testCreatePaginatedResponse_mergesWarnings() { - var items = List.of("item1"); - // Create params with warning (invalid page clamped to 1) - var params = PaginationParams.of(-5, 10); - - var response = handler.createPaginatedResponse(items, params, 1); - - assertThat(response.warnings()).isNotEmpty(); - assertThat(response.warnings()) - .as("Should include pagination warning") - .anyMatch(w -> w.contains("Invalid page number -5")); - } - - @Test - void testCreatePaginatedResponse_mergesWarningsWithEmptyMessage() { - var items = List.of(); - // Create params with warning (invalid pageSize) - var params = PaginationParams.of(2, 200); - - var response = handler.createPaginatedResponse(items, params, 5); - - assertThat(response.warnings()).isNotEmpty(); - assertThat(response.warnings()) - .as("Should include pageSize warning") - .anyMatch(w -> w.contains("Requested pageSize 200 exceeds maximum 100")); - assertThat(response.warnings()) - .as("Should include empty page message") - .anyMatch(w -> w.contains("Requested page 2 exceeds available pages")); - } - - // ========== Edge Cases ========== - - @Test - void testHasMorePages_boundaryCase() { - // Exactly 20 items, page 2, pageSize 10 - // Page 2 * 10 = 20, which equals totalItems, so no more pages - var items = List.of("item10", "item11"); - var params = PaginationParams.of(2, 10); - - var response = handler.createPaginatedResponse(items, params, 20); - - assertThat(response.hasMorePages()) - .as("No more pages when page*pageSize == totalItems") - .isFalse(); - } - - @Test - void testHasMorePages_justOverBoundary() { - // 21 items, page 2, pageSize 10 - // Page 2 * 10 = 20 < 21, so more pages exist - var items = List.of("item10", "item11"); - var params = PaginationParams.of(2, 10); - - var response = handler.createPaginatedResponse(items, params, 21); - - assertThat(response.hasMorePages()).as("More pages when page*pageSize < totalItems").isTrue(); - } - - @Test - void testMessagePriority_warningAndEmpty() { - // Both warning and empty message should appear - var items = List.of(); - var params = PaginationParams.of(-1, 10); // Invalid page - - var response = handler.createPaginatedResponse(items, params, 0); - - assertThat(response.warnings()).isNotEmpty(); - assertThat(response.warnings()).anyMatch(w -> w.contains("Invalid page number")); - assertThat(response.warnings()).anyMatch(w -> w.contains("No items found")); - } - - // ========== Helper Methods ========== - - private List createItems(int count) { - return IntStream.range(0, count).mapToObj(i -> "item" + i).collect(Collectors.toList()); - } -}