diff --git a/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/cf/clients/CustomControllerClient.java b/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/cf/clients/CustomControllerClient.java index 8edf474e8b..aca1514279 100644 --- a/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/cf/clients/CustomControllerClient.java +++ b/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/cf/clients/CustomControllerClient.java @@ -17,6 +17,8 @@ public abstract class CustomControllerClient { + protected static final int MAX_URI_QUERY_LENGTH = 4000; + private final WebClient webClient; private String correlationId = StringUtils.EMPTY; private final CloudControllerHeaderConfiguration headerConfiguration; @@ -61,6 +63,74 @@ private MultiValueMap generateRequestHeaders() { return result; } + protected List getListOfResourcesInBatches(ResourcesResponseMapper responseMapper, String uriPrefix, + List batchValues, Object... urlVariables) { + int expandedPrefixLength = calculateExpandedLength(uriPrefix, urlVariables); + List> batches = splitIntoBatches(batchValues, expandedPrefixLength); + return batches.stream() + .map(batch -> + getListOfResources(responseMapper, uriPrefix, batch, urlVariables) + ) + .flatMap(List::stream) + .toList(); + } + + private int calculateExpandedLength(String uriPrefix, Object[] urlVariables) { + if (urlVariables == null || urlVariables.length == 0) { + return uriPrefix.length(); + } + int urlVariablesLength = 0; + for (Object variable : urlVariables) { + urlVariablesLength += String.valueOf(variable) + .length(); + } + int placeholdersLength = calculatePlaceholdersLength(uriPrefix); + return (uriPrefix.length() - placeholdersLength) + urlVariablesLength; + } + + private int calculatePlaceholdersLength(String uriPrefix) { + int length = 0; + int searchFrom = 0; + int start; + int end; + while ((start = uriPrefix.indexOf('{', searchFrom)) >= 0 && (end = uriPrefix.indexOf('}', start)) >= 0) { + length += end - start + 1; + searchFrom = end + 1; + } + return length; + } + + protected List> splitIntoBatches(List values, int fixedUriLength) { + int maxBatchLength = Math.max(1, MAX_URI_QUERY_LENGTH - fixedUriLength); + List> batches = new ArrayList<>(); + List currentBatch = new ArrayList<>(); + int currentLength = 0; + + for (String value : values) { + // Account for the comma separator between values + int addedLength = currentBatch.isEmpty() ? value.length() : value.length() + 1; + if (!currentBatch.isEmpty() && currentLength + addedLength > maxBatchLength) { + batches.add(currentBatch); + currentBatch = new ArrayList<>(); + currentLength = 0; + addedLength = value.length(); + } + currentBatch.add(value); + currentLength += addedLength; + } + + if (!currentBatch.isEmpty()) { + batches.add(currentBatch); + } + return batches; + } + + private List getListOfResources(ResourcesResponseMapper responseMapper, String uriPrefix, + List batch, Object... urlVariables) { + String uri = uriPrefix + String.join(",", batch); + return getListOfResources(responseMapper, uri, urlVariables); + } + public static abstract class ResourcesResponseMapper { List> queriedResources = new ArrayList<>(); Map> includedResources = new HashMap<>(); diff --git a/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/cf/clients/CustomServiceKeysClient.java b/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/cf/clients/CustomServiceKeysClient.java index 3d4def6631..8f4bfdd489 100644 --- a/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/cf/clients/CustomServiceKeysClient.java +++ b/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/cf/clients/CustomServiceKeysClient.java @@ -18,11 +18,10 @@ import org.cloudfoundry.multiapps.controller.core.util.ApplicationConfiguration; public class CustomServiceKeysClient extends CustomControllerClient { - private static final String SERVICE_KEYS_RESOURCE_BASE_URI = "/v3/service_credential_bindings"; private static final String SERVICE_KEYS_BY_METADATA_SELECTOR_URI = SERVICE_KEYS_RESOURCE_BASE_URI + "?type=key&label_selector={value}"; private static final String INCLUDE_SERVICE_INSTANCE_RESOURCES_PARAM = "&include=service_instance"; - + private static final String SERVICE_INSTANCE_GUIDS_PARAM_PREFIX = "&service_instance_guids="; private final CloudEntityResourceMapper resourceMapper = new CloudEntityResourceMapper(); public CustomServiceKeysClient(ApplicationConfiguration configuration, WebClientFactory webClientFactory, CloudCredentials credentials, @@ -35,17 +34,13 @@ public List getServiceKeysByMetadataAndExistingGuids( String mtaId, String mtaNamespace, List existingServiceGuids) { - String labelSelector = buildMtaMetadataLabelSelector(spaceGuid, mtaId, mtaNamespace); - List allServiceGuids = existingServiceGuids.stream() .filter(Objects::nonNull) .toList(); - if (allServiceGuids.isEmpty()) { return List.of(); } - return new CustomControllerClientErrorHandler() .handleErrorsOrReturnResult( () -> getServiceKeysByMetadataInternal(labelSelector, allServiceGuids) @@ -57,15 +52,11 @@ public List getServiceKeysByMetadataAndManagedServices( String mtaId, String mtaNamespace, List services) { - String labelSelector = buildMtaMetadataLabelSelector(spaceGuid, mtaId, mtaNamespace); - List managedGuids = extractManagedServiceGuids(services); - if (managedGuids.isEmpty()) { return List.of(); } - return new CustomControllerClientErrorHandler() .handleErrorsOrReturnResult( () -> getServiceKeysByMetadataInternal(labelSelector, managedGuids) @@ -75,7 +66,6 @@ public List getServiceKeysByMetadataAndManagedServices( private String buildMtaMetadataLabelSelector(String spaceGuid, String mtaId, String mtaNamespace) { - return MtaMetadataCriteriaBuilder.builder() .label(MtaMetadataLabels.SPACE_GUID) .hasValue(spaceGuid) @@ -98,13 +88,11 @@ private List extractManagedServiceGuids(List service } private List getServiceKeysByMetadataInternal(String labelSelector, List guids) { - - String uriSuffix = INCLUDE_SERVICE_INSTANCE_RESOURCES_PARAM - + "&service_instance_guids=" + String.join(",", guids); - - return getListOfResources(new ServiceKeysResponseMapper(), - SERVICE_KEYS_BY_METADATA_SELECTOR_URI + uriSuffix, - labelSelector); + String expandedUriPrefix = + SERVICE_KEYS_BY_METADATA_SELECTOR_URI + INCLUDE_SERVICE_INSTANCE_RESOURCES_PARAM + SERVICE_INSTANCE_GUIDS_PARAM_PREFIX; + return getListOfResourcesInBatches(new ServiceKeysResponseMapper(), + expandedUriPrefix, + guids, labelSelector); } private List getManagedServices(List services) { @@ -132,12 +120,10 @@ public List getMappedResources() { public Map getIncludedServiceInstancesMapping() { List serviceInstances = getIncludedResources().getOrDefault("service_instances", Collections.emptyList()); - return serviceInstances.stream() .distinct() .map(service -> (Map) service) .collect(Collectors.toMap(service -> (String) service.get("guid"), resourceMapper::mapService)); - } } -} \ No newline at end of file +} diff --git a/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/cf/clients/ServiceInstanceRoutesGetter.java b/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/cf/clients/ServiceInstanceRoutesGetter.java index 280c5e0786..5a44b6fc23 100644 --- a/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/cf/clients/ServiceInstanceRoutesGetter.java +++ b/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/cf/clients/ServiceInstanceRoutesGetter.java @@ -1,7 +1,5 @@ package org.cloudfoundry.multiapps.controller.core.cf.clients; -import java.util.ArrayList; -import java.util.Collection; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -13,48 +11,22 @@ public class ServiceInstanceRoutesGetter extends CustomControllerClient { - private static final int MAX_CHAR_LENGTH_FOR_PARAMS_IN_REQUEST = 4000; + private static final String SERVICE_ROUTE_BINDINGS_URI_PREFIX = "/v3/service_route_bindings?"; + private static final String ROUTE_GUIDS_PARAM_PREFIX = "route_guids="; public ServiceInstanceRoutesGetter(ApplicationConfiguration configuration, WebClientFactory webClientFactory, CloudCredentials credentials, String correlationId) { super(configuration, webClientFactory, credentials, correlationId); } - public List getServiceRouteBindings(Collection routeGuids) { + public List getServiceRouteBindings(List routeGuids) { return new CustomControllerClientErrorHandler().handleErrorsOrReturnResult(() -> doGetServiceRouteBindings(routeGuids)); } - private List doGetServiceRouteBindings(Collection routeGuids) { - var batchedRouteGuids = getBatchedRouteGuids(routeGuids); - var responseMapper = new ServiceRouteBindingsResponseMapper(); - return batchedRouteGuids.stream() - .map(this::getServiceRouteBindingsUrl) - .map(url -> getListOfResources(responseMapper, url)) - .flatMap(List::stream) - .collect(Collectors.toList()); - } - - private List> getBatchedRouteGuids(Collection routeGuids) { - List> batches = new ArrayList<>(); - int currentBatchLength = 0, currentBatchIndex = 0; - batches.add(new ArrayList<>()); - - for (String routeGuid : routeGuids) { - int elementLength = routeGuid.length(); - if (elementLength + currentBatchLength >= MAX_CHAR_LENGTH_FOR_PARAMS_IN_REQUEST) { - batches.add(new ArrayList<>()); - currentBatchIndex++; - currentBatchLength = 0; - } - batches.get(currentBatchIndex) - .add(routeGuid); - currentBatchLength += elementLength; - } - return batches; - } - - private String getServiceRouteBindingsUrl(Collection routeGuids) { - return "/v3/service_route_bindings?route_guids=" + String.join(",", routeGuids); + private List doGetServiceRouteBindings(List routeGuids) { + return getListOfResourcesInBatches(new ServiceRouteBindingsResponseMapper(), + SERVICE_ROUTE_BINDINGS_URI_PREFIX + ROUTE_GUIDS_PARAM_PREFIX, + routeGuids); } protected static class ServiceRouteBindingsResponseMapper extends ResourcesResponseMapper { diff --git a/multiapps-controller-core/src/test/java/org/cloudfoundry/multiapps/controller/core/cf/clients/AppBoundServiceInstanceNamesGetterTest.java b/multiapps-controller-core/src/test/java/org/cloudfoundry/multiapps/controller/core/cf/clients/AppBoundServiceInstanceNamesGetterTest.java new file mode 100644 index 0000000000..180ac8a851 --- /dev/null +++ b/multiapps-controller-core/src/test/java/org/cloudfoundry/multiapps/controller/core/cf/clients/AppBoundServiceInstanceNamesGetterTest.java @@ -0,0 +1,245 @@ +package org.cloudfoundry.multiapps.controller.core.cf.clients; + +import java.util.List; +import java.util.UUID; + +import org.cloudfoundry.multiapps.controller.client.facade.CloudCredentials; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AppBoundServiceInstanceNamesGetterTest extends CustomControllerClientBaseTest { + + private static final String CORRELATION_ID = "test-correlation-id"; + + private AppBoundServiceInstanceNamesGetter client; + + @BeforeEach + void setUp() throws Exception { + MockitoAnnotations.openMocks(this) + .close(); + Mockito.when(applicationConfiguration.getVersion()) + .thenReturn("1.0.0"); + Mockito.when(webClientFactory.getWebClient(Mockito.any(CloudCredentials.class))) + .thenReturn(webClient); + + client = new AppBoundServiceInstanceNamesGetter(applicationConfiguration, webClientFactory, new CloudCredentials("user", "pass"), + CORRELATION_ID); + } + + @Test + void testGetServiceInstanceNamesBoundToAppBuildsCorrectUri() { + stubWebClientToReturnResponse(); + + UUID appGuid = UUID.randomUUID(); + client.getServiceInstanceNamesBoundToApp(appGuid); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(String.class); + Mockito.verify(requestHeadersUriSpec) + .uri(uriCaptor.capture()); + + String capturedUri = uriCaptor.getValue(); + assertTrue(capturedUri.startsWith("/v3/service_credential_bindings?"), + "URI should start with the service credential bindings base path"); + assertTrue(capturedUri.contains("include=service_instance"), + "URI should include service_instance"); + assertTrue(capturedUri.contains("app_guids=" + appGuid), + "URI should contain the app guid"); + } + + @Test + void testGetServiceInstanceNamesBoundToAppWithEmptyResponseReturnsEmptyList() { + stubWebClientToReturnResponse(); + + UUID appGuid = UUID.randomUUID(); + List result = client.getServiceInstanceNamesBoundToApp(appGuid); + + assertTrue(result.isEmpty()); + } + + @Test + void testGetServiceInstanceNamesBoundToAppReturnsSingleServiceName() { + String serviceName = "my-database"; + String responseJson = buildResponseWithIncludedServiceInstances(List.of(serviceName), null); + stubWebClientToReturnResponse(responseJson); + + UUID appGuid = UUID.randomUUID(); + List result = client.getServiceInstanceNamesBoundToApp(appGuid); + + assertEquals(1, result.size()); + assertEquals(serviceName, result.getFirst()); + } + + @Test + void testGetServiceInstanceNamesBoundToAppReturnsMultipleServiceNames() { + List serviceNames = List.of("db-service", "cache-service", "queue-service"); + String responseJson = buildResponseWithIncludedServiceInstances(serviceNames, null); + stubWebClientToReturnResponse(responseJson); + + UUID appGuid = UUID.randomUUID(); + List result = client.getServiceInstanceNamesBoundToApp(appGuid); + + assertEquals(3, result.size()); + assertEquals("db-service", result.get(0)); + assertEquals("cache-service", result.get(1)); + assertEquals("queue-service", result.get(2)); + } + + @Test + void testGetServiceInstanceNamesBoundToAppDeduplicatesServiceNames() { + List serviceNames = List.of("my-service", "my-service", "other-service"); + String responseJson = buildResponseWithIncludedServiceInstances(serviceNames, null); + stubWebClientToReturnResponse(responseJson); + + UUID appGuid = UUID.randomUUID(); + List result = client.getServiceInstanceNamesBoundToApp(appGuid); + + assertEquals(2, result.size()); + assertEquals("my-service", result.get(0)); + assertEquals("other-service", result.get(1)); + } + + @Test + void testGetServiceInstanceNamesBoundToAppWithNoIncludedResourcesReturnsEmptyList() { + // Response has resources but no "included" section + String responseJson = "{\"resources\":[{\"guid\":\"" + UUID.randomUUID() + "\"}],\"pagination\":{\"next\":null}}"; + stubWebClientToReturnResponse(responseJson); + + UUID appGuid = UUID.randomUUID(); + List result = client.getServiceInstanceNamesBoundToApp(appGuid); + + assertTrue(result.isEmpty()); + } + + @Test + void testGetServiceInstanceNamesBoundToAppPaginatedResponseFollowsAllPages() { + String page1Json = buildResponseWithIncludedServiceInstances( + List.of("svc-page1"), "/v3/service_credential_bindings?page=2"); + String page2Json = buildResponseWithIncludedServiceInstances(List.of("svc-page2"), null); + + stubWebClientToReturnResponse(page1Json, page2Json); + + UUID appGuid = UUID.randomUUID(); + List result = client.getServiceInstanceNamesBoundToApp(appGuid); + + assertEquals(2, result.size()); + assertEquals("svc-page1", result.get(0)); + assertEquals("svc-page2", result.get(1)); + } + + @Test + void testGetServiceInstanceNamesBoundToAppMakesExactlyOneHttpCallForSinglePage() { + stubWebClientToReturnResponse(); + + UUID appGuid = UUID.randomUUID(); + client.getServiceInstanceNamesBoundToApp(appGuid); + + Mockito.verify(webClient, Mockito.times(1)) + .get(); + } + + @Test + void testGetServiceInstanceNamesBoundToAppMakesTwoHttpCallsForTwoPages() { + String page1Json = buildResponseWithIncludedServiceInstances( + List.of("svc-1"), "/v3/service_credential_bindings?page=2"); + String page2Json = buildResponseWithIncludedServiceInstances(List.of("svc-2"), null); + + stubWebClientToReturnResponse(page1Json, page2Json); + + UUID appGuid = UUID.randomUUID(); + client.getServiceInstanceNamesBoundToApp(appGuid); + + Mockito.verify(webClient, Mockito.times(2)) + .get(); + } + + @Test + void testGetServiceInstanceNamesBoundToAppWithResourcesButEmptyIncludedServiceInstancesReturnsEmptyList() { + // Response has "included" but with an empty "service_instances" list + String responseJson = "{\"resources\":[{\"guid\":\"" + UUID.randomUUID() + "\"}]," + + "\"included\":{\"service_instances\":[]}," + + "\"pagination\":{\"next\":null}}"; + stubWebClientToReturnResponse(responseJson); + + UUID appGuid = UUID.randomUUID(); + List result = client.getServiceInstanceNamesBoundToApp(appGuid); + + assertTrue(result.isEmpty()); + } + + @Test + void testGetServiceInstanceNamesBoundToAppDeduplicatesAcrossPages() { + // Same service name appears on both pages + String page1Json = buildResponseWithIncludedServiceInstances( + List.of("shared-service"), "/v3/service_credential_bindings?page=2"); + String page2Json = buildResponseWithIncludedServiceInstances(List.of("shared-service"), null); + + stubWebClientToReturnResponse(page1Json, page2Json); + + UUID appGuid = UUID.randomUUID(); + List result = client.getServiceInstanceNamesBoundToApp(appGuid); + + assertEquals(1, result.size(), "Duplicate service names across pages should be deduplicated"); + assertEquals("shared-service", result.getFirst()); + } + + private String buildResponseWithIncludedServiceInstances(List serviceNames, String nextPageHref) { + List serviceInstanceGuids = serviceNames.stream() + .map(service -> UUID.randomUUID()) + .toList(); + String bindingResourcesJson = buildBindingResourcesJson(serviceInstanceGuids); + String serviceInstancesJson = buildServiceInstanceResourcesJson(serviceNames, serviceInstanceGuids); + String paginationJson = buildPaginationJson(nextPageHref); + + return "{\"resources\":[" + bindingResourcesJson + "]," + + "\"included\":{\"service_instances\":[" + serviceInstancesJson + "]}," + + "\"pagination\":" + paginationJson + "}"; + } + + private String buildBindingResourcesJson(List serviceInstanceGuids) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < serviceInstanceGuids.size(); i++) { + if (i > 0) { + sb.append(","); + } + sb.append(buildSingleBindingResourceJson(serviceInstanceGuids.get(i))); + } + return sb.toString(); + } + + private String buildSingleBindingResourceJson(UUID serviceInstanceGuid) { + return "{\"guid\":\"" + UUID.randomUUID() + "\"," + + "\"type\":\"app\"," + + "\"relationships\":{" + + "\"service_instance\":{\"data\":{\"guid\":\"" + serviceInstanceGuid + "\"}}" + + "}}"; + } + + private String buildServiceInstanceResourcesJson(List serviceNames, List serviceInstanceGuids) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < serviceNames.size(); i++) { + if (i > 0) { + sb.append(","); + } + sb.append(buildSingleServiceInstanceJson(serviceNames.get(i), serviceInstanceGuids.get(i))); + } + return sb.toString(); + } + + private String buildSingleServiceInstanceJson(String serviceName, UUID serviceInstanceGuid) { + return "{\"guid\":\"" + serviceInstanceGuid + "\"," + + "\"name\":\"" + serviceName + "\"," + + "\"type\":\"managed\"}"; + } + + private String buildPaginationJson(String nextPageHref) { + String nextPage = nextPageHref == null ? "null" : "{\"href\":\"" + nextPageHref + "\"}"; + return "{\"next\":" + nextPage + "}"; + } +} + diff --git a/multiapps-controller-core/src/test/java/org/cloudfoundry/multiapps/controller/core/cf/clients/CFOptimizedEventGetterTest.java b/multiapps-controller-core/src/test/java/org/cloudfoundry/multiapps/controller/core/cf/clients/CFOptimizedEventGetterTest.java new file mode 100644 index 0000000000..35ca4f393d --- /dev/null +++ b/multiapps-controller-core/src/test/java/org/cloudfoundry/multiapps/controller/core/cf/clients/CFOptimizedEventGetterTest.java @@ -0,0 +1,231 @@ +package org.cloudfoundry.multiapps.controller.core.cf.clients; + +import java.util.List; +import java.util.UUID; + +import org.cloudfoundry.multiapps.controller.client.facade.CloudCredentials; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CFOptimizedEventGetterTest extends CustomControllerClientBaseTest { + + private static final String EVENT_TYPE = "audit.app.update"; + private static final String TIMESTAMP = "2024-06-01T00:00:00Z"; + + private CFOptimizedEventGetter client; + + @BeforeEach + void setUp() throws Exception { + MockitoAnnotations.openMocks(this) + .close(); + Mockito.when(applicationConfiguration.getVersion()) + .thenReturn("1.0.0"); + Mockito.when(webClientFactory.getWebClient(Mockito.any(CloudCredentials.class))) + .thenReturn(webClient); + + client = new CFOptimizedEventGetter(applicationConfiguration, webClientFactory, new CloudCredentials("user", "pass")); + } + + @Test + void testFindEventsBuildsCorrectUri() { + stubWebClientToReturnResponse(); + + client.findEvents(EVENT_TYPE, TIMESTAMP); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(String.class); + Mockito.verify(requestHeadersUriSpec) + .uri(uriCaptor.capture()); + + String capturedUri = uriCaptor.getValue(); + assertTrue(capturedUri.startsWith("/v3/audit_events?"), "URI should start with the audit events base path"); + assertTrue(capturedUri.contains("types=" + EVENT_TYPE), "URI should contain the event type"); + assertTrue(capturedUri.contains("per_page=100"), "URI should contain per_page=100"); + assertTrue(capturedUri.contains("created_ats[gt]=" + TIMESTAMP), "URI should contain the timestamp filter"); + } + + @Test + void testFindEventsWithEmptyResponseReturnsEmptyList() { + stubWebClientToReturnResponse(); + + List result = client.findEvents(EVENT_TYPE, TIMESTAMP); + + assertTrue(result.isEmpty()); + } + + @Test + void testFindEventsReturnsSingleSpaceId() { + String spaceGuid = UUID.randomUUID() + .toString(); + String responseJson = buildAuditEventsResponse(List.of(spaceGuid)); + stubWebClientToReturnResponse(responseJson); + + List result = client.findEvents(EVENT_TYPE, TIMESTAMP); + + assertEquals(1, result.size()); + assertEquals(spaceGuid, result.getFirst()); + } + + @Test + void testFindEventsReturnsMultipleSpaceIds() { + String spaceGuid1 = UUID.randomUUID() + .toString(); + String spaceGuid2 = UUID.randomUUID() + .toString(); + String spaceGuid3 = UUID.randomUUID() + .toString(); + String responseJson = buildAuditEventsResponse(List.of(spaceGuid1, spaceGuid2, spaceGuid3)); + stubWebClientToReturnResponse(responseJson); + + List result = client.findEvents(EVENT_TYPE, TIMESTAMP); + + assertEquals(3, result.size()); + assertEquals(spaceGuid1, result.get(0)); + assertEquals(spaceGuid2, result.get(1)); + assertEquals(spaceGuid3, result.get(2)); + } + + @Test + void testFindEventsFiltersNullSpaceGuids() { + String spaceGuid = UUID.randomUUID() + .toString(); + // Build a response with one event having a valid space guid and one with null guid + String responseJson = buildAuditEventsResponseWithNullSpaceGuid(spaceGuid); + stubWebClientToReturnResponse(responseJson); + + List result = client.findEvents(EVENT_TYPE, TIMESTAMP); + + assertEquals(1, result.size()); + assertEquals(spaceGuid, result.getFirst()); + } + + @Test + void testFindEventsPaginatedResponseFollowsAllPages() { + String spaceGuid1 = UUID.randomUUID() + .toString(); + String spaceGuid2 = UUID.randomUUID() + .toString(); + + String page1Json = buildAuditEventsResponseWithPagination(List.of(spaceGuid1), "/v3/audit_events?page=2"); + String page2Json = buildAuditEventsResponse(List.of(spaceGuid2)); + + stubWebClientToReturnResponse(page1Json, page2Json); + + List result = client.findEvents(EVENT_TYPE, TIMESTAMP); + + assertEquals(2, result.size()); + assertEquals(spaceGuid1, result.getFirst()); + assertEquals(spaceGuid2, result.get(1)); + } + + @Test + void testFindEventsMakesExactlyOneHttpCallForSinglePage() { + stubWebClientToReturnResponse(); + + client.findEvents(EVENT_TYPE, TIMESTAMP); + + Mockito.verify(webClient, Mockito.times(1)) + .get(); + } + + @Test + void testFindEventsMakesTwoHttpCallsForTwoPages() { + String spaceGuid = UUID.randomUUID() + .toString(); + + String page1Json = buildAuditEventsResponseWithPagination(List.of(spaceGuid), "/v3/audit_events?page=2"); + String page2Json = buildAuditEventsResponse(List.of(spaceGuid)); + + stubWebClientToReturnResponse(page1Json, page2Json); + + client.findEvents(EVENT_TYPE, TIMESTAMP); + + Mockito.verify(webClient, Mockito.times(2)) + .get(); + } + + @Test + void testFindEventsWithDifferentEventType() { + stubWebClientToReturnResponse(); + + String customType = "audit.app.delete"; + client.findEvents(customType, TIMESTAMP); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(String.class); + Mockito.verify(requestHeadersUriSpec) + .uri(uriCaptor.capture()); + + String capturedUri = uriCaptor.getValue(); + assertTrue(capturedUri.contains("types=" + customType), "URI should contain the custom event type"); + } + + @Test + void testFindEventsWithDifferentTimestamp() { + stubWebClientToReturnResponse(); + + String customTimestamp = "2025-12-31T23:59:59Z"; + client.findEvents(EVENT_TYPE, customTimestamp); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(String.class); + Mockito.verify(requestHeadersUriSpec) + .uri(uriCaptor.capture()); + + String capturedUri = uriCaptor.getValue(); + assertTrue(capturedUri.contains("created_ats[gt]=" + customTimestamp), "URI should contain the custom timestamp"); + } + + @Test + void testFindEventsDuplicateSpaceIdsArePreserved() { + String spaceGuid = UUID.randomUUID() + .toString(); + // Same space guid appearing in multiple events + String responseJson = buildAuditEventsResponse(List.of(spaceGuid, spaceGuid, spaceGuid)); + stubWebClientToReturnResponse(responseJson); + + List result = client.findEvents(EVENT_TYPE, TIMESTAMP); + + assertEquals(3, result.size(), "Duplicate space IDs should be preserved since the mapper does not deduplicate"); + } + + private String buildAuditEventsResponse(List spaceGuids) { + return buildAuditEventsResponseWithPagination(spaceGuids, null); + } + + private String buildAuditEventsResponseWithPagination(List spaceGuids, String nextPageHref) { + String nextPage = nextPageHref == null ? "null" : "{\"href\":\"" + nextPageHref + "\"}"; + StringBuilder resources = new StringBuilder(); + for (int i = 0; i < spaceGuids.size(); i++) { + if (i > 0) { + resources.append(","); + } + resources.append("{") + .append("\"guid\":\"") + .append(UUID.randomUUID()) + .append("\",") + .append("\"type\":\"audit.app.update\",") + .append("\"created_at\":\"2024-06-15T10:00:00Z\",") + .append("\"space\":{\"guid\":\"") + .append(spaceGuids.get(i)) + .append("\"}") + .append("}"); + } + return "{\"resources\":[" + resources + "],\"pagination\":{\"next\":" + nextPage + "}}"; + } + + private String buildAuditEventsResponseWithNullSpaceGuid(String validSpaceGuid) { + return "{\"resources\":[" + + "{\"guid\":\"" + UUID.randomUUID() + "\",\"type\":\"audit.app.update\"," + + "\"created_at\":\"2024-06-15T10:00:00Z\"," + + "\"space\":{\"guid\":\"" + validSpaceGuid + "\"}}," + + "{\"guid\":\"" + UUID.randomUUID() + "\",\"type\":\"audit.app.update\"," + + "\"created_at\":\"2024-06-15T10:00:00Z\"," + + "\"space\":{\"guid\":null}}" + + "],\"pagination\":{\"next\":null}}"; + } +} + diff --git a/multiapps-controller-core/src/test/java/org/cloudfoundry/multiapps/controller/core/cf/clients/CfRolesGetterTest.java b/multiapps-controller-core/src/test/java/org/cloudfoundry/multiapps/controller/core/cf/clients/CfRolesGetterTest.java new file mode 100644 index 0000000000..68c454d34c --- /dev/null +++ b/multiapps-controller-core/src/test/java/org/cloudfoundry/multiapps/controller/core/cf/clients/CfRolesGetterTest.java @@ -0,0 +1,235 @@ +package org.cloudfoundry.multiapps.controller.core.cf.clients; + +import java.util.Arrays; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.cloudfoundry.multiapps.controller.client.facade.CloudCredentials; +import org.cloudfoundry.multiapps.controller.client.facade.domain.UserRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CfRolesGetterTest extends CustomControllerClientBaseTest { + + private CfRolesGetter client; + + @BeforeEach + void setUp() throws Exception { + MockitoAnnotations.openMocks(this) + .close(); + Mockito.when(applicationConfiguration.getVersion()) + .thenReturn("1.0.0"); + Mockito.when(webClientFactory.getWebClient(Mockito.any(CloudCredentials.class))) + .thenReturn(webClient); + + client = new CfRolesGetter(applicationConfiguration, webClientFactory, new CloudCredentials("user", "pass")); + } + + @Test + void testGetRolesBuildsCorrectUri() { + stubWebClientToReturnResponse(); + + UUID spaceGuid = UUID.randomUUID(); + UUID userGuid = UUID.randomUUID(); + client.getRoles(spaceGuid, userGuid); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(String.class); + Mockito.verify(requestHeadersUriSpec) + .uri(uriCaptor.capture()); + + String capturedUri = uriCaptor.getValue(); + assertTrue(capturedUri.startsWith("/v3/roles?"), "URI should start with /v3/roles?"); + assertTrue(capturedUri.contains("space_guids=" + spaceGuid), "URI should contain the space guid"); + assertTrue(capturedUri.contains("user_guids=" + userGuid), "URI should contain the user guid"); + } + + @Test + void testGetRolesUriContainsAllRoleTypes() { + stubWebClientToReturnResponse(); + + UUID spaceGuid = UUID.randomUUID(); + UUID userGuid = UUID.randomUUID(); + client.getRoles(spaceGuid, userGuid); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(String.class); + Mockito.verify(requestHeadersUriSpec) + .uri(uriCaptor.capture()); + + String capturedUri = uriCaptor.getValue(); + String expectedTypesFilter = Arrays.stream(UserRole.values()) + .map(UserRole::getName) + .collect(Collectors.joining(",")); + assertTrue(capturedUri.contains("types=" + expectedTypesFilter), "URI should contain all UserRole types as a filter"); + } + + @Test + void testGetRolesWithEmptyResponseReturnsEmptySet() { + stubWebClientToReturnResponse(); + + UUID spaceGuid = UUID.randomUUID(); + UUID userGuid = UUID.randomUUID(); + Set result = client.getRoles(spaceGuid, userGuid); + + assertTrue(result.isEmpty()); + } + + @Test + void testGetRolesReturnsSingleRole() { + String responseJson = buildRolesResponse("space_developer"); + stubWebClientToReturnResponse(responseJson); + + UUID spaceGuid = UUID.randomUUID(); + UUID userGuid = UUID.randomUUID(); + Set result = client.getRoles(spaceGuid, userGuid); + + assertEquals(1, result.size()); + assertTrue(result.contains(UserRole.SPACE_DEVELOPER)); + } + + @Test + void testGetRolesReturnsMultipleRoles() { + String responseJson = buildRolesResponse("space_developer", "space_manager", "space_auditor"); + stubWebClientToReturnResponse(responseJson); + + UUID spaceGuid = UUID.randomUUID(); + UUID userGuid = UUID.randomUUID(); + Set result = client.getRoles(spaceGuid, userGuid); + + assertEquals(3, result.size()); + assertTrue(result.contains(UserRole.SPACE_DEVELOPER)); + assertTrue(result.contains(UserRole.SPACE_MANAGER)); + assertTrue(result.contains(UserRole.SPACE_AUDITOR)); + } + + @Test + void testGetRolesReturnsOrganizationRoles() { + String responseJson = buildRolesResponse("organization_manager", "organization_auditor"); + stubWebClientToReturnResponse(responseJson); + + UUID spaceGuid = UUID.randomUUID(); + UUID userGuid = UUID.randomUUID(); + Set result = client.getRoles(spaceGuid, userGuid); + + assertEquals(2, result.size()); + assertTrue(result.contains(UserRole.ORGANIZATION_MANAGER)); + assertTrue(result.contains(UserRole.ORGANIZATION_AUDITOR)); + } + + @Test + void testGetRolesDeduplicatesDuplicateRoles() { + String responseJson = buildRolesResponse("space_developer", "space_developer"); + stubWebClientToReturnResponse(responseJson); + + UUID spaceGuid = UUID.randomUUID(); + UUID userGuid = UUID.randomUUID(); + Set result = client.getRoles(spaceGuid, userGuid); + + assertEquals(1, result.size(), "Duplicate roles should be deduplicated in the EnumSet"); + assertTrue(result.contains(UserRole.SPACE_DEVELOPER)); + } + + @Test + void testGetRolesPaginatedResponseFollowsAllPages() { + String page1Json = buildRolesResponseWithPagination(new String[] { "space_developer" }, "/v3/roles?page=2"); + String page2Json = buildRolesResponse("space_manager"); + + stubWebClientToReturnResponse(page1Json, page2Json); + + UUID spaceGuid = UUID.randomUUID(); + UUID userGuid = UUID.randomUUID(); + Set result = client.getRoles(spaceGuid, userGuid); + + assertEquals(2, result.size()); + assertTrue(result.contains(UserRole.SPACE_DEVELOPER)); + assertTrue(result.contains(UserRole.SPACE_MANAGER)); + } + + @Test + void testGetRolesMakesExactlyOneHttpCallForSinglePage() { + stubWebClientToReturnResponse(); + + UUID spaceGuid = UUID.randomUUID(); + UUID userGuid = UUID.randomUUID(); + client.getRoles(spaceGuid, userGuid); + + Mockito.verify(webClient, Mockito.times(1)) + .get(); + } + + @Test + void testGetRolesMakesTwoHttpCallsForTwoPages() { + String page1Json = buildRolesResponseWithPagination(new String[] { "space_developer" }, "/v3/roles?page=2"); + String page2Json = buildRolesResponse("space_auditor"); + + stubWebClientToReturnResponse(page1Json, page2Json); + + UUID spaceGuid = UUID.randomUUID(); + UUID userGuid = UUID.randomUUID(); + client.getRoles(spaceGuid, userGuid); + + Mockito.verify(webClient, Mockito.times(2)) + .get(); + } + + @Test + void testGetRolesReturnsAllSpaceRoles() { + String responseJson = buildRolesResponse("space_developer", "space_manager", "space_auditor"); + stubWebClientToReturnResponse(responseJson); + + UUID spaceGuid = UUID.randomUUID(); + UUID userGuid = UUID.randomUUID(); + Set result = client.getRoles(spaceGuid, userGuid); + + assertTrue(result.contains(UserRole.SPACE_DEVELOPER)); + assertTrue(result.contains(UserRole.SPACE_MANAGER)); + assertTrue(result.contains(UserRole.SPACE_AUDITOR)); + } + + @Test + void testGetRolesReturnsMixedSpaceAndOrgRoles() { + String responseJson = buildRolesResponse("space_developer", "organization_manager", "organization_user"); + stubWebClientToReturnResponse(responseJson); + + UUID spaceGuid = UUID.randomUUID(); + UUID userGuid = UUID.randomUUID(); + Set result = client.getRoles(spaceGuid, userGuid); + + assertEquals(3, result.size()); + assertTrue(result.contains(UserRole.SPACE_DEVELOPER)); + assertTrue(result.contains(UserRole.ORGANIZATION_MANAGER)); + assertTrue(result.contains(UserRole.ORGANIZATION_USER)); + } + + private String buildRolesResponse(String... roleTypes) { + return buildRolesResponseWithPagination(roleTypes, null); + } + + private String buildRolesResponseWithPagination(String[] roleTypes, String nextPageHref) { + String nextPage = nextPageHref == null ? "null" : "{\"href\":\"" + nextPageHref + "\"}"; + StringBuilder resources = new StringBuilder(); + for (int i = 0; i < roleTypes.length; i++) { + if (i > 0) { + resources.append(","); + } + resources.append("{") + .append("\"guid\":\"") + .append(UUID.randomUUID()) + .append("\",") + .append("\"type\":\"") + .append(roleTypes[i]) + .append("\",") + .append("\"created_at\":\"2024-01-01T00:00:00Z\",") + .append("\"updated_at\":\"2024-01-01T00:00:00Z\"") + .append("}"); + } + return "{\"resources\":[" + resources + "],\"pagination\":{\"next\":" + nextPage + "}}"; + } +} + diff --git a/multiapps-controller-core/src/test/java/org/cloudfoundry/multiapps/controller/core/cf/clients/CustomControllerClientBaseTest.java b/multiapps-controller-core/src/test/java/org/cloudfoundry/multiapps/controller/core/cf/clients/CustomControllerClientBaseTest.java new file mode 100644 index 0000000000..87271c84e2 --- /dev/null +++ b/multiapps-controller-core/src/test/java/org/cloudfoundry/multiapps/controller/core/cf/clients/CustomControllerClientBaseTest.java @@ -0,0 +1,97 @@ +package org.cloudfoundry.multiapps.controller.core.cf.clients; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.cloudfoundry.multiapps.controller.core.util.ApplicationConfiguration; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +abstract class CustomControllerClientBaseTest { + + private static final Pattern URI_VARIABLE_PATTERN = Pattern.compile("\\{[^}]+}"); + + @Mock + protected WebClientFactory webClientFactory; + @Mock + protected ApplicationConfiguration applicationConfiguration; + @Mock + protected WebClient webClient; + + @SuppressWarnings("rawtypes") + protected final WebClient.RequestHeadersUriSpec requestHeadersUriSpec = Mockito.mock(WebClient.RequestHeadersUriSpec.class); + @SuppressWarnings("rawtypes") + protected final WebClient.RequestHeadersSpec requestHeadersSpec = Mockito.mock(WebClient.RequestHeadersSpec.class); + @Mock + protected WebClient.ResponseSpec responseSpec; + + protected final List capturedResolvedUris = new ArrayList<>(); + + @SuppressWarnings("unchecked") + protected void stubWebClientToReturnResponse(String... responses) { + Mockito.when(webClient.get()) + .thenReturn(requestHeadersUriSpec); + Mockito.when(requestHeadersUriSpec.uri(Mockito.anyString())) + .thenAnswer(this::resolveUriTemplateAndReturn); + Mockito.when(requestHeadersUriSpec.uri(Mockito.anyString(), Mockito. any())) + .thenAnswer(this::resolveUriTemplateAndReturn); + Mockito.when(requestHeadersSpec.headers(Mockito.any(Consumer.class))) + .thenReturn(requestHeadersSpec); + Mockito.when(requestHeadersSpec.retrieve()) + .thenReturn(responseSpec); + + if (responses.length == 0) { + Mockito.when(responseSpec.bodyToMono(String.class)) + .thenReturn(Mono.just("{\"resources\":[],\"pagination\":{\"next\":null}}")); + return; + } + + if (responses.length == 1) { + Mockito.when(responseSpec.bodyToMono(String.class)) + .thenReturn(Mono.just(responses[0])); + return; + } + @SuppressWarnings("rawtypes") Mono[] remaining = new Mono[responses.length - 1]; + for (int i = 1; i < responses.length; i++) { + remaining[i - 1] = Mono.just(responses[i]); + } + Mockito.when(responseSpec.bodyToMono(String.class)) + .thenReturn(Mono.just(responses[0]), remaining); + } + + @SuppressWarnings("rawtypes") + private WebClient.RequestHeadersSpec resolveUriTemplateAndReturn(InvocationOnMock invocation) { + String uriTemplate = invocation.getArgument(0); + Object[] allArgs = invocation.getArguments(); + Object[] uriVariables; + if (allArgs.length > 1 && allArgs[1] instanceof Object[]) { + uriVariables = (Object[]) allArgs[1]; + } else { + uriVariables = new Object[allArgs.length - 1]; + System.arraycopy(allArgs, 1, uriVariables, 0, allArgs.length - 1); + } + String resolved = resolveTemplate(uriTemplate, uriVariables); + capturedResolvedUris.add(resolved); + return requestHeadersSpec; + } + + private String resolveTemplate(String uriTemplate, Object[] uriVariables) { + if (uriVariables == null || uriVariables.length == 0) { + return uriTemplate; + } + Matcher matcher = URI_VARIABLE_PATTERN.matcher(uriTemplate); + StringBuilder sb = new StringBuilder(); + int varIndex = 0; + while (matcher.find() && varIndex < uriVariables.length) { + matcher.appendReplacement(sb, Matcher.quoteReplacement(String.valueOf(uriVariables[varIndex++]))); + } + matcher.appendTail(sb); + return sb.toString(); + } +} diff --git a/multiapps-controller-core/src/test/java/org/cloudfoundry/multiapps/controller/core/cf/clients/CustomServiceKeysClientTest.java b/multiapps-controller-core/src/test/java/org/cloudfoundry/multiapps/controller/core/cf/clients/CustomServiceKeysClientTest.java new file mode 100644 index 0000000000..cb8a092634 --- /dev/null +++ b/multiapps-controller-core/src/test/java/org/cloudfoundry/multiapps/controller/core/cf/clients/CustomServiceKeysClientTest.java @@ -0,0 +1,485 @@ +package org.cloudfoundry.multiapps.controller.core.cf.clients; + +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import org.cloudfoundry.client.v3.serviceinstances.ServiceInstanceType; +import org.cloudfoundry.multiapps.controller.client.facade.CloudCredentials; +import org.cloudfoundry.multiapps.controller.client.facade.domain.ImmutableCloudMetadata; +import org.cloudfoundry.multiapps.controller.core.model.DeployedMtaService; +import org.cloudfoundry.multiapps.controller.core.model.DeployedMtaServiceKey; +import org.cloudfoundry.multiapps.controller.core.model.ImmutableDeployedMtaService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CustomServiceKeysClientTest extends CustomControllerClientBaseTest { + + private static final String SPACE_GUID = "space-guid-123"; + private static final String MTA_ID = "my-mta"; + private static final String MTA_NAMESPACE = "my-namespace"; + private static final String CORRELATION_ID = "test-correlation-id"; + + private CustomServiceKeysClient client; + + @BeforeEach + void setUp() throws Exception { + MockitoAnnotations.openMocks(this) + .close(); + Mockito.when(applicationConfiguration.getVersion()) + .thenReturn("1.0.0"); + Mockito.when(webClientFactory.getWebClient(Mockito.any(CloudCredentials.class))) + .thenReturn(webClient); + + client = new CustomServiceKeysClient(applicationConfiguration, webClientFactory, new CloudCredentials("user", "pass"), + CORRELATION_ID); + } + + @Test + void testGetServiceKeysByExistingGuidsWithEmptyGuidsReturnsEmptyList() { + List result = client.getServiceKeysByMetadataAndExistingGuids(SPACE_GUID, MTA_ID, MTA_NAMESPACE, + Collections.emptyList()); + assertTrue(result.isEmpty()); + Mockito.verifyNoInteractions(webClient); + } + + @Test + void testGetServiceKeysByExistingGuidsWithAllNullGuidsReturnsEmptyList() { + List nullGuids = new ArrayList<>(); + nullGuids.add(null); + nullGuids.add(null); + + List result = client.getServiceKeysByMetadataAndExistingGuids(SPACE_GUID, MTA_ID, MTA_NAMESPACE, nullGuids); + assertTrue(result.isEmpty()); + Mockito.verifyNoInteractions(webClient); + } + + @Test + void testGetServiceKeysByExistingGuidsFiltersNullGuids() { + stubWebClientToReturnResponse(); + + String randomServiceGuid = UUID.randomUUID() + .toString(); + List guidsWithNulls = new ArrayList<>(); + guidsWithNulls.add(null); + guidsWithNulls.add(randomServiceGuid); + guidsWithNulls.add(null); + + client.getServiceKeysByMetadataAndExistingGuids(SPACE_GUID, MTA_ID, MTA_NAMESPACE, + guidsWithNulls); + + String capturedUri = capturedResolvedUris.getFirst(); + assertTrue(capturedUri.endsWith(MessageFormat.format("&service_instance_guids={0}", randomServiceGuid)), + "service_instance_guids should contain only the non-null guid"); + } + + @Test + void testGetServiceKeysByExistingGuidsBuildsCorrectUri() { + stubWebClientToReturnResponse(); + + String guid = UUID.randomUUID() + .toString(); + client.getServiceKeysByMetadataAndExistingGuids(SPACE_GUID, MTA_ID, MTA_NAMESPACE, List.of(guid)); + + String capturedUri = capturedResolvedUris.getFirst(); + assertTrue(capturedUri.startsWith("/v3/service_credential_bindings?type=key&label_selector="), + "URI should start with the service keys base path"); + assertTrue(capturedUri.contains("space_guid=" + SPACE_GUID), "URI should contain the space_guid label selector"); + assertTrue(capturedUri.contains("&include=service_instance"), "URI should include service_instance"); + assertTrue(capturedUri.contains("&service_instance_guids=" + guid), + "URI should contain the service instance guid parameter"); + } + + @Test + void testGetServiceKeysByExistingGuidsJoinsMultipleGuidsWithComma() { + stubWebClientToReturnResponse(); + + String guid1 = UUID.randomUUID() + .toString(); + String guid2 = UUID.randomUUID() + .toString(); + client.getServiceKeysByMetadataAndExistingGuids(SPACE_GUID, MTA_ID, MTA_NAMESPACE, List.of(guid1, guid2)); + + String capturedUri = capturedResolvedUris.getFirst(); + assertTrue(capturedUri.contains("&service_instance_guids=" + guid1 + "," + guid2), + "URI should contain both guids joined by comma"); + } + + @Test + void testGetServiceKeysByExistingGuidsReturnsServiceKeys() { + UUID serviceInstanceGuid = UUID.randomUUID(); + UUID serviceKeyGuid = UUID.randomUUID(); + + String responseJson = buildServiceKeysResponse(serviceKeyGuid, serviceInstanceGuid, "my-key", "my-service", null); + stubWebClientToReturnResponse(responseJson); + + List result = client.getServiceKeysByMetadataAndExistingGuids(SPACE_GUID, MTA_ID, MTA_NAMESPACE, + List.of(serviceInstanceGuid.toString())); + + assertEquals(1, result.size()); + DeployedMtaServiceKey key = result.getFirst(); + assertEquals("my-key", key.getName()); + assertEquals(serviceKeyGuid, key.getMetadata() + .getGuid()); + assertNotNull(key.getServiceInstance()); + assertEquals("my-service", key.getServiceInstance() + .getName()); + } + + @Test + void testGetServiceKeysByExistingGuidsWithEmptyResponseReturnsEmptyList() { + stubWebClientToReturnResponse(); + + String guid = UUID.randomUUID() + .toString(); + List result = client.getServiceKeysByMetadataAndExistingGuids(SPACE_GUID, MTA_ID, MTA_NAMESPACE, + List.of(guid)); + assertTrue(result.isEmpty()); + } + + @Test + void testGetServiceKeysByManagedServicesWithEmptyServiceListReturnsEmptyList() { + List result = client.getServiceKeysByMetadataAndManagedServices(SPACE_GUID, MTA_ID, MTA_NAMESPACE, + Collections.emptyList()); + assertTrue(result.isEmpty()); + Mockito.verifyNoInteractions(webClient); + } + + @Test + void testGetServiceKeysByManagedServicesWithOnlyUserProvidedReturnsEmptyList() { + DeployedMtaService userProvidedService = ImmutableDeployedMtaService.builder() + .name("ups") + .type(ServiceInstanceType.USER_PROVIDED) + .metadata(ImmutableCloudMetadata.builder() + .guid(UUID.randomUUID()) + .build()) + .build(); + + List result = client.getServiceKeysByMetadataAndManagedServices(SPACE_GUID, MTA_ID, MTA_NAMESPACE, + List.of(userProvidedService)); + assertTrue(result.isEmpty()); + Mockito.verifyNoInteractions(webClient); + } + + @Test + void testGetServiceKeysByManagedServicesWithNullMetadataReturnsEmptyList() { + DeployedMtaService serviceWithNullMetadata = ImmutableDeployedMtaService.builder() + .name("no-meta") + .type(ServiceInstanceType.MANAGED) + .build(); + + List result = client.getServiceKeysByMetadataAndManagedServices(SPACE_GUID, MTA_ID, MTA_NAMESPACE, + List.of(serviceWithNullMetadata)); + assertTrue(result.isEmpty()); + Mockito.verifyNoInteractions(webClient); + } + + @Test + void testGetServiceKeysByManagedServicesWithNullGuidInMetadataReturnsEmptyList() { + DeployedMtaService serviceWithNullGuid = ImmutableDeployedMtaService.builder() + .name("null-guid") + .type(ServiceInstanceType.MANAGED) + .metadata(ImmutableCloudMetadata.builder() + .build()) + .build(); + + List result = client.getServiceKeysByMetadataAndManagedServices(SPACE_GUID, MTA_ID, MTA_NAMESPACE, + List.of(serviceWithNullGuid)); + assertTrue(result.isEmpty()); + Mockito.verifyNoInteractions(webClient); + } + + @Test + void testGetServiceKeysByManagedServicesMakesHttpCall() { + stubWebClientToReturnResponse(); + + UUID serviceGuid = UUID.randomUUID(); + DeployedMtaService managedService = buildManagedService("managed-svc", serviceGuid); + + client.getServiceKeysByMetadataAndManagedServices(SPACE_GUID, MTA_ID, MTA_NAMESPACE, + List.of(managedService)); + + String capturedUri = capturedResolvedUris.getFirst(); + assertTrue(capturedUri.contains("&service_instance_guids=" + serviceGuid), "URI should contain the guid of the managed service"); + Mockito.verify(webClient, Mockito.times(1)) + .get(); + } + + @Test + void testGetServiceKeysByManagedServicesUsesOnlyManagedGuids() { + stubWebClientToReturnResponse(); + + UUID managedGuid = UUID.randomUUID(); + UUID userProvidedGuid = UUID.randomUUID(); + DeployedMtaService managedService = buildManagedService("managed-svc", managedGuid); + DeployedMtaService userProvidedService = ImmutableDeployedMtaService.builder() + .name("ups") + .type(ServiceInstanceType.USER_PROVIDED) + .metadata(ImmutableCloudMetadata.builder() + .guid(userProvidedGuid) + .build()) + .build(); + + client.getServiceKeysByMetadataAndManagedServices(SPACE_GUID, MTA_ID, MTA_NAMESPACE, List.of(managedService, userProvidedService)); + + String capturedUri = capturedResolvedUris.getFirst(); + assertTrue(capturedUri.endsWith("&service_instance_guids=" + managedGuid), + "URI should contain only the managed service guid"); + assertFalse(capturedUri.contains(userProvidedGuid.toString()), "URI should not contain the user-provided service guid"); + } + + @Test + void testGetServiceKeysByManagedServicesReturnsServiceKeys() { + UUID serviceInstanceGuid = UUID.randomUUID(); + UUID serviceKeyGuid = UUID.randomUUID(); + + String serviceKeyName = "sk-1"; + String serviceInstanceName = "svc-1"; + String responseJson = buildServiceKeysResponse(serviceKeyGuid, serviceInstanceGuid, serviceKeyName, serviceInstanceName, null); + stubWebClientToReturnResponse(responseJson); + + DeployedMtaService managedService = buildManagedService(serviceInstanceName, serviceInstanceGuid); + + List result = client.getServiceKeysByMetadataAndManagedServices(SPACE_GUID, MTA_ID, MTA_NAMESPACE, + List.of(managedService)); + + assertEquals(1, result.size()); + assertEquals(serviceKeyName, result.getFirst() + .getName()); + assertEquals(serviceInstanceName, result.getFirst() + .getServiceInstance() + .getName()); + } + + @Test + void testLabelSelectorContainsSpaceGuidMtaIdAndMtaNamespace() { + stubWebClientToReturnResponse(); + + String guid = UUID.randomUUID() + .toString(); + client.getServiceKeysByMetadataAndExistingGuids(SPACE_GUID, MTA_ID, MTA_NAMESPACE, List.of(guid)); + + String capturedUri = capturedResolvedUris.getFirst(); + assertTrue(capturedUri.contains("space_guid=" + SPACE_GUID)); + assertTrue(capturedUri.contains("mta_id=")); + assertTrue(capturedUri.contains("mta_namespace=")); + } + + @Test + void testLabelSelectorWithNullNamespaceUsesDoesNotExist() { + stubWebClientToReturnResponse(); + + String guid = UUID.randomUUID() + .toString(); + client.getServiceKeysByMetadataAndExistingGuids(SPACE_GUID, MTA_ID, null, List.of(guid)); + + String capturedUri = capturedResolvedUris.getFirst(); + // When namespace is null/empty, the label selector uses "!" prefix (doesNotExist) + assertTrue(capturedUri.contains("!mta_namespace"), "When namespace is null, label selector should use !mta_namespace"); + } + + @Test + void testLabelSelectorWithEmptyNamespaceUsesDoesNotExist() { + stubWebClientToReturnResponse(); + + String guid = UUID.randomUUID() + .toString(); + client.getServiceKeysByMetadataAndExistingGuids(SPACE_GUID, MTA_ID, "", List.of(guid)); + + String capturedUri = capturedResolvedUris.getFirst(); + assertTrue(capturedUri.contains("!mta_namespace"), "When namespace is empty, label selector should use !mta_namespace"); + } + + @Test + void testBatchingTriggeredWithManyGuids() { + stubWebClientToReturnResponse(); + + // Generate enough GUIDs to force multiple batches (each UUID is 36 chars, limit is 4000) + List manyGuids = generateRandomGuids(200); + + client.getServiceKeysByMetadataAndExistingGuids(SPACE_GUID, MTA_ID, MTA_NAMESPACE, manyGuids); + + // With 200 GUIDs and a fixed prefix consuming some of the 4000-char budget, multiple requests are expected + Mockito.verify(webClient, Mockito.atLeast(2)) + .get(); + } + + @Test + void testSingleBatchWithFewGuids() { + stubWebClientToReturnResponse(); + + List fewGuids = generateRandomGuids(3); + + client.getServiceKeysByMetadataAndExistingGuids(SPACE_GUID, MTA_ID, MTA_NAMESPACE, fewGuids); + + Mockito.verify(webClient, Mockito.times(1)) + .get(); + } + + @Test + void testBatchedUrisNeverExceedMaxLength() { + stubWebClientToReturnResponse(); + + List manyGuids = generateRandomGuids(800); + client.getServiceKeysByMetadataAndExistingGuids(SPACE_GUID, MTA_ID, MTA_NAMESPACE, manyGuids); + + for (String uri : capturedResolvedUris) { + assertTrue(uri.length() <= CustomControllerClient.MAX_URI_QUERY_LENGTH, + "URI length " + uri.length() + " exceeds MAX_URI_QUERY_LENGTH " + + CustomControllerClient.MAX_URI_QUERY_LENGTH); + } + } + + @Test + void testBatchedRequestsContainAllGuidsInOrder() { + stubWebClientToReturnResponse(); + + List manyGuids = generateRandomGuids(200); + client.getServiceKeysByMetadataAndExistingGuids(SPACE_GUID, MTA_ID, MTA_NAMESPACE, manyGuids); + + List allCapturedGuids = new ArrayList<>(); + for (String uri : capturedResolvedUris) { + int idx = uri.indexOf("&service_instance_guids="); + assertTrue(idx >= 0, "URI should contain &service_instance_guids="); + String guidsStr = uri.substring(idx + "&service_instance_guids=".length()); + String[] guids = guidsStr.split(","); + Collections.addAll(allCapturedGuids, guids); + } + + assertEquals(manyGuids.size(), allCapturedGuids.size(), "All guids must be sent across batched requests"); + assertEquals(manyGuids, allCapturedGuids, "Guids must appear in original order across batches"); + } + + @Test + void testPaginatedResponseFollowsAllPages() { + UUID siGuid = UUID.randomUUID(); + + UUID keyGuid1 = UUID.randomUUID(); + UUID keyGuid2 = UUID.randomUUID(); + + String page1Json = buildServiceKeysResponse(keyGuid1, siGuid, "key-1", "svc-1", + "/v3/service_credential_bindings?page=2"); + String page2Json = buildServiceKeysResponse(keyGuid2, siGuid, "key-2", "svc-1", null); + + stubWebClientToReturnResponse(page1Json, page2Json); + + List result = client.getServiceKeysByMetadataAndExistingGuids(SPACE_GUID, MTA_ID, MTA_NAMESPACE, + List.of(siGuid.toString())); + + assertEquals(2, result.size()); + assertEquals("key-1", result.getFirst() + .getName()); + assertEquals("key-2", result.get(1) + .getName()); + } + + @Test + void testMultipleKeysForSameServiceAreMappedCorrectly() { + UUID siGuid = UUID.randomUUID(); + UUID keyGuid1 = UUID.randomUUID(); + UUID keyGuid2 = UUID.randomUUID(); + + String responseJson = buildMultiKeyResponse(List.of(keyGuid1, keyGuid2), siGuid, List.of("key-a", "key-b"), "shared-service"); + stubWebClientToReturnResponse(responseJson); + + List result = client.getServiceKeysByMetadataAndExistingGuids(SPACE_GUID, MTA_ID, MTA_NAMESPACE, + List.of(siGuid.toString())); + + assertEquals(2, result.size()); + assertEquals("key-a", result.getFirst() + .getName()); + assertEquals("key-b", result.get(1) + .getName()); + // Both keys should reference the same service instance + assertEquals("shared-service", result.getFirst() + .getServiceInstance() + .getName()); + assertEquals("shared-service", result.get(1) + .getServiceInstance() + .getName()); + } + + private DeployedMtaService buildManagedService(String name, UUID guid) { + return ImmutableDeployedMtaService.builder() + .name(name) + .type(ServiceInstanceType.MANAGED) + .metadata(ImmutableCloudMetadata.builder() + .guid(guid) + .build()) + .build(); + } + + private List generateRandomGuids(int count) { + List guids = new ArrayList<>(); + for (int i = 0; i < count; i++) { + guids.add(UUID.randomUUID() + .toString()); + } + return guids; + } + + private String buildServiceKeysResponse(UUID serviceKeyGuid, UUID serviceInstanceGuid, String keyName, String serviceName, + String nextPageHref) { + String keyResourceJson = buildServiceKeyResourceJson(serviceKeyGuid, keyName, serviceInstanceGuid); + String serviceInstanceJson = buildServiceInstanceJson(serviceInstanceGuid, serviceName); + return assembleResponseJson(keyResourceJson, serviceInstanceJson, buildPaginationJson(nextPageHref)); + } + + private String buildServiceKeyResourceJson(UUID keyGuid, String keyName, UUID serviceInstanceGuid) { + return "{\"guid\":\"" + keyGuid + "\"," + + "\"name\":\"" + keyName + "\"," + + "\"type\":\"key\"," + + "\"created_at\":\"2024-01-01T00:00:00Z\"," + + "\"updated_at\":\"2024-01-01T00:00:00Z\"," + + "\"metadata\":{\"labels\":{},\"annotations\":{}}," + + "\"relationships\":{\"service_instance\":{\"data\":{\"guid\":\"" + serviceInstanceGuid + "\"}}}" + + "}"; + } + + private String buildServiceInstanceJson(UUID serviceInstanceGuid, String serviceName) { + return "{\"guid\":\"" + serviceInstanceGuid + "\"," + + "\"name\":\"" + serviceName + "\"," + + "\"type\":\"managed\"," + + "\"created_at\":\"2024-01-01T00:00:00Z\"," + + "\"updated_at\":\"2024-01-01T00:00:00Z\"," + + "\"metadata\":{\"labels\":{},\"annotations\":{}}}"; + } + + private String buildPaginationJson(String nextPageHref) { + String nextPage = nextPageHref == null ? "null" : "{\"href\":\"" + nextPageHref + "\"}"; + return "{\"next\":" + nextPage + "}"; + } + + private String assembleResponseJson(String resourcesJson, String serviceInstancesJson, String paginationJson) { + return "{\"resources\":[" + resourcesJson + "]," + + "\"included\":{\"service_instances\":[" + serviceInstancesJson + "]}," + + "\"pagination\":" + paginationJson + "}"; + } + + private String buildMultiKeyResponse(List keyGuids, UUID serviceInstanceGuid, List keyNames, String serviceName) { + String keyResourcesJson = buildMultipleServiceKeyResourcesJson(keyGuids, keyNames, serviceInstanceGuid); + String serviceInstanceJson = buildServiceInstanceJson(serviceInstanceGuid, serviceName); + return assembleResponseJson(keyResourcesJson, serviceInstanceJson, buildPaginationJson(null)); + } + + private String buildMultipleServiceKeyResourcesJson(List keyGuids, List keyNames, UUID serviceInstanceGuid) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < keyGuids.size(); i++) { + if (i > 0) { + sb.append(","); + } + sb.append(buildServiceKeyResourceJson(keyGuids.get(i), keyNames.get(i), serviceInstanceGuid)); + } + return sb.toString(); + } +} + diff --git a/multiapps-controller-core/src/test/java/org/cloudfoundry/multiapps/controller/core/cf/clients/ServiceInstanceRoutesGetterTest.java b/multiapps-controller-core/src/test/java/org/cloudfoundry/multiapps/controller/core/cf/clients/ServiceInstanceRoutesGetterTest.java new file mode 100644 index 0000000000..0d4f72b55d --- /dev/null +++ b/multiapps-controller-core/src/test/java/org/cloudfoundry/multiapps/controller/core/cf/clients/ServiceInstanceRoutesGetterTest.java @@ -0,0 +1,329 @@ +package org.cloudfoundry.multiapps.controller.core.cf.clients; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import org.cloudfoundry.multiapps.controller.client.facade.CloudCredentials; +import org.cloudfoundry.multiapps.controller.client.lib.domain.ServiceRouteBinding; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ServiceInstanceRoutesGetterTest extends CustomControllerClientBaseTest { + + private static final String CORRELATION_ID = "test-correlation-id"; + + private ServiceInstanceRoutesGetter client; + + @BeforeEach + void setUp() throws Exception { + MockitoAnnotations.openMocks(this) + .close(); + Mockito.when(applicationConfiguration.getVersion()) + .thenReturn("1.0.0"); + Mockito.when(webClientFactory.getWebClient(Mockito.any(CloudCredentials.class))) + .thenReturn(webClient); + + client = new ServiceInstanceRoutesGetter(applicationConfiguration, webClientFactory, new CloudCredentials("user", "pass"), + CORRELATION_ID); + } + + @Test + void testGetServiceRouteBindingsWithEmptyGuidsReturnsEmptyList() { + List result = client.getServiceRouteBindings(Collections.emptyList()); + assertTrue(result.isEmpty()); + Mockito.verifyNoInteractions(webClient); + } + + @Test + void testGetServiceRouteBindingsBuildsCorrectUri() { + stubWebClientToReturnResponse(); + + String routeGuid = UUID.randomUUID() + .toString(); + client.getServiceRouteBindings(List.of(routeGuid)); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(String.class); + Mockito.verify(requestHeadersUriSpec) + .uri(uriCaptor.capture()); + + String capturedUri = uriCaptor.getValue(); + assertTrue(capturedUri.startsWith("/v3/service_route_bindings?"), + "URI should start with the service route bindings base path"); + assertTrue(capturedUri.contains("route_guids=" + routeGuid), + "URI should contain the route_guids parameter with the provided guid"); + } + + @Test + void testGetServiceRouteBindingsJoinsMultipleGuidsWithComma() { + stubWebClientToReturnResponse(); + + String routeGuid1 = UUID.randomUUID() + .toString(); + String routeGuid2 = UUID.randomUUID() + .toString(); + client.getServiceRouteBindings(List.of(routeGuid1, routeGuid2)); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(String.class); + Mockito.verify(requestHeadersUriSpec) + .uri(uriCaptor.capture()); + + String capturedUri = uriCaptor.getValue(); + assertTrue(capturedUri.contains("route_guids=" + routeGuid1 + "," + routeGuid2), + "URI should contain both guids joined by comma"); + } + + @Test + void testGetServiceRouteBindingsReturnsMappedBindings() { + String routeGuid = UUID.randomUUID() + .toString(); + String serviceInstanceGuid = UUID.randomUUID() + .toString(); + + String responseJson = buildServiceRouteBindingsResponse(routeGuid, serviceInstanceGuid, null); + stubWebClientToReturnResponse(responseJson); + + List result = client.getServiceRouteBindings(List.of(routeGuid)); + + assertEquals(1, result.size()); + ServiceRouteBinding binding = result.getFirst(); + assertEquals(routeGuid, binding.getRouteId()); + assertEquals(serviceInstanceGuid, binding.getServiceInstanceId()); + } + + @Test + void testGetServiceRouteBindingsWithEmptyResponseReturnsEmptyList() { + stubWebClientToReturnResponse(); + + String routeGuid = UUID.randomUUID() + .toString(); + List result = client.getServiceRouteBindings(List.of(routeGuid)); + assertTrue(result.isEmpty()); + } + + @Test + void testGetServiceRouteBindingsReturnsMultipleBindings() { + String routeGuid1 = UUID.randomUUID() + .toString(); + String routeGuid2 = UUID.randomUUID() + .toString(); + String serviceInstanceGuid1 = UUID.randomUUID() + .toString(); + String serviceInstanceGuid2 = UUID.randomUUID() + .toString(); + + String responseJson = buildMultipleServiceRouteBindingsResponse( + List.of(routeGuid1, routeGuid2), + List.of(serviceInstanceGuid1, serviceInstanceGuid2)); + stubWebClientToReturnResponse(responseJson); + + List result = client.getServiceRouteBindings(List.of(routeGuid1, routeGuid2)); + + assertEquals(2, result.size()); + assertEquals(routeGuid1, result.getFirst() + .getRouteId()); + assertEquals(serviceInstanceGuid1, result.getFirst() + .getServiceInstanceId()); + assertEquals(routeGuid2, result.get(1) + .getRouteId()); + assertEquals(serviceInstanceGuid2, result.get(1) + .getServiceInstanceId()); + } + + @Test + void testGetServiceRouteBindingsMultipleBindingsForSameRoute() { + String routeGuid = UUID.randomUUID() + .toString(); + String serviceInstanceGuid1 = UUID.randomUUID() + .toString(); + String serviceInstanceGuid2 = UUID.randomUUID() + .toString(); + + String responseJson = buildMultipleServiceRouteBindingsResponse( + List.of(routeGuid, routeGuid), + List.of(serviceInstanceGuid1, serviceInstanceGuid2)); + stubWebClientToReturnResponse(responseJson); + + List result = client.getServiceRouteBindings(List.of(routeGuid)); + + assertEquals(2, result.size()); + assertEquals(routeGuid, result.getFirst() + .getRouteId()); + assertEquals(serviceInstanceGuid1, result.getFirst() + .getServiceInstanceId()); + assertEquals(routeGuid, result.get(1) + .getRouteId()); + assertEquals(serviceInstanceGuid2, result.get(1) + .getServiceInstanceId()); + } + + @Test + void testGetServiceRouteBindingsPaginatedResponseFollowsAllPages() { + String routeGuid = UUID.randomUUID() + .toString(); + String serviceInstanceGuid1 = UUID.randomUUID() + .toString(); + String serviceInstanceGuid2 = UUID.randomUUID() + .toString(); + + String page1Json = buildServiceRouteBindingsResponse(routeGuid, serviceInstanceGuid1, + "/v3/service_route_bindings?page=2"); + String page2Json = buildServiceRouteBindingsResponse(routeGuid, serviceInstanceGuid2, null); + + stubWebClientToReturnResponse(page1Json, page2Json); + + List result = client.getServiceRouteBindings(List.of(routeGuid)); + + assertEquals(2, result.size()); + assertEquals(serviceInstanceGuid1, result.getFirst() + .getServiceInstanceId()); + assertEquals(serviceInstanceGuid2, result.get(1) + .getServiceInstanceId()); + } + + @Test + void testBatchingTriggeredWithManyGuids() { + stubWebClientToReturnResponse(); + + List manyGuids = generateRandomGuids(200); + + client.getServiceRouteBindings(manyGuids); + + Mockito.verify(webClient, Mockito.atLeast(2)) + .get(); + } + + @Test + void testSingleBatchWithFewGuids() { + stubWebClientToReturnResponse(); + + List fewGuids = generateRandomGuids(3); + + client.getServiceRouteBindings(fewGuids); + + Mockito.verify(webClient, Mockito.times(1)) + .get(); + } + + @Test + void testBatchedUrisNeverExceedMaxLength() { + stubWebClientToReturnResponse(); + + List manyGuids = generateRandomGuids(800); + client.getServiceRouteBindings(manyGuids); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(String.class); + Mockito.verify(requestHeadersUriSpec, Mockito.atLeastOnce()) + .uri(uriCaptor.capture()); + + for (String uri : uriCaptor.getAllValues()) { + assertTrue(uri.length() <= CustomControllerClient.MAX_URI_QUERY_LENGTH, + "URI length " + uri.length() + " exceeds MAX_URI_QUERY_LENGTH " + + CustomControllerClient.MAX_URI_QUERY_LENGTH); + } + } + + @Test + void testBatchedRequestsContainAllGuidsInOrder() { + stubWebClientToReturnResponse(); + + List manyGuids = generateRandomGuids(200); + client.getServiceRouteBindings(manyGuids); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(String.class); + Mockito.verify(requestHeadersUriSpec, Mockito.atLeastOnce()) + .uri(uriCaptor.capture()); + + List allCapturedGuids = new ArrayList<>(); + for (String uri : uriCaptor.getAllValues()) { + int idx = uri.indexOf("route_guids="); + assertTrue(idx >= 0, "URI should contain route_guids="); + String guidsStr = uri.substring(idx + "route_guids=".length()); + String[] guids = guidsStr.split(","); + Collections.addAll(allCapturedGuids, guids); + } + + assertEquals(manyGuids.size(), allCapturedGuids.size(), "All guids must be sent across batched requests"); + assertEquals(manyGuids, allCapturedGuids, "Guids must appear in original order across batches"); + } + + @Test + void testBatchedRequestsAggregateResultsFromAllBatches() { + String routeGuid1 = UUID.randomUUID() + .toString(); + String serviceInstanceGuid1 = UUID.randomUUID() + .toString(); + + String response = buildServiceRouteBindingsResponse(routeGuid1, serviceInstanceGuid1, null); + stubWebClientToReturnResponse(response); + + List result = client.getServiceRouteBindings(List.of(routeGuid1)); + + assertEquals(1, result.size()); + assertEquals(routeGuid1, result.getFirst() + .getRouteId()); + assertEquals(serviceInstanceGuid1, result.getFirst() + .getServiceInstanceId()); + } + + private List generateRandomGuids(int count) { + List guids = new ArrayList<>(); + for (int i = 0; i < count; i++) { + guids.add(UUID.randomUUID() + .toString()); + } + return guids; + } + + private String buildServiceRouteBindingsResponse(String routeGuid, String serviceInstanceGuid, String nextPageHref) { + String resourceJson = buildRouteBindingResourceJson(routeGuid, serviceInstanceGuid); + return assembleRouteBindingsResponseJson(resourceJson, buildPaginationJson(nextPageHref)); + } + + private String buildRouteBindingResourceJson(String routeGuid, String serviceInstanceGuid) { + return "{\"guid\":\"" + UUID.randomUUID() + "\"," + + "\"created_at\":\"2024-01-01T00:00:00Z\"," + + "\"updated_at\":\"2024-01-01T00:00:00Z\"," + + "\"relationships\":{" + + "\"route\":{\"data\":{\"guid\":\"" + routeGuid + "\"}}," + + "\"service_instance\":{\"data\":{\"guid\":\"" + serviceInstanceGuid + "\"}}" + + "}}"; + } + + private String buildPaginationJson(String nextPageHref) { + String nextPage = nextPageHref == null ? "null" : "{\"href\":\"" + nextPageHref + "\"}"; + return "{\"next\":" + nextPage + "}"; + } + + private String assembleRouteBindingsResponseJson(String resourcesJson, String paginationJson) { + return "{\"resources\":[" + resourcesJson + "]," + + "\"pagination\":" + paginationJson + "}"; + } + + private String buildMultipleServiceRouteBindingsResponse(List routeGuids, List serviceInstanceGuids) { + String resourcesJson = buildMultipleRouteBindingResourcesJson(routeGuids, serviceInstanceGuids); + return assembleRouteBindingsResponseJson(resourcesJson, buildPaginationJson(null)); + } + + private String buildMultipleRouteBindingResourcesJson(List routeGuids, List serviceInstanceGuids) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < routeGuids.size(); i++) { + if (i > 0) { + sb.append(","); + } + sb.append(buildRouteBindingResourceJson(routeGuids.get(i), serviceInstanceGuids.get(i))); + } + return sb.toString(); + } +} + + + +