diff --git a/cds-feature-ai-core/pom.xml b/cds-feature-ai-core/pom.xml index c49b701..ac235b8 100644 --- a/cds-feature-ai-core/pom.xml +++ b/cds-feature-ai-core/pom.xml @@ -44,12 +44,19 @@ com.sap.cds cds-services-impl - test ${project.artifactId} + + + src/test/resources + + + src/gen/srv/src/main/resources + + com.sap.cds diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java index 40374bb..2955b33 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java @@ -3,7 +3,7 @@ */ package com.sap.cds.feature.aicore.api; -import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; /** @@ -25,10 +25,10 @@ *

The implementation is tenant-aware: it reads the current tenant from the {@code * RequestContext}. Callers do not need to pass tenant identifiers explicitly. */ -public interface AICoreService extends CqnService { +public interface AICoreService extends RemoteService { /** Default service name under which an instance is registered in the service catalog. */ - String DEFAULT_NAME = "AICore"; + String DEFAULT_NAME = "AICore$Default"; /** Qualified name of the {@code resourceGroups} entity exposed by this service. */ String RESOURCE_GROUPS = "AICore.resourceGroups"; diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java index bfd4c8c..43d9127 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java @@ -8,7 +8,6 @@ import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.handler.AICoreApplicationServiceHandler; import com.sap.cds.feature.aicore.core.handler.ActionHandler; import com.sap.cds.feature.aicore.core.handler.ConfigurationHandler; import com.sap.cds.feature.aicore.core.handler.DeploymentHandler; @@ -37,19 +36,17 @@ public class AICoreServiceConfiguration implements CdsRuntimeConfiguration { private static final Logger logger = LoggerFactory.getLogger(AICoreServiceConfiguration.class); + private static boolean hasAICoreModel(CdsRuntime runtime) { + return runtime.getCdsModel().findService("AICore").isPresent(); + } + private static boolean hasAICoreBinding(CdsRuntime runtime) { - boolean hasServiceBinding = - runtime - .getEnvironment() - .getServiceBindings() - .filter(b -> ServiceBindingUtils.matches(b, "aicore")) - .findFirst() - .isPresent(); - if (hasServiceBinding) { - return true; - } - String envKey = System.getenv("AICORE_SERVICE_KEY"); - return envKey != null && !envKey.isBlank(); + return runtime + .getEnvironment() + .getServiceBindings() + .filter(b -> ServiceBindingUtils.matches(b, "aicore")) + .findFirst() + .isPresent(); } /** @@ -70,6 +67,11 @@ private static boolean detectMultiTenancy(CdsRuntime runtime) { public void services(CdsRuntimeConfigurer configurer) { CdsRuntime runtime = configurer.getCdsRuntime(); + if (!hasAICoreModel(runtime)) { + logger.debug("AICore CDS model not found in runtime model — skipping service registration."); + return; + } + boolean hasBinding = hasAICoreBinding(runtime); boolean multiTenancyEnabled = detectMultiTenancy(runtime); @@ -106,7 +108,6 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { configurer.eventHandler(new DeploymentHandler(service)); configurer.eventHandler(new ConfigurationHandler(service)); configurer.eventHandler(new ActionHandler(service)); - configurer.eventHandler(new AICoreApplicationServiceHandler(service)); logger.debug("Registered Prod AI-Core Implementation"); if (service.isMultiTenancyEnabled()) { @@ -115,7 +116,6 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { } } else if (registered instanceof MockAICoreServiceImpl mockService) { configurer.eventHandler(new MockEntityHandler()); - configurer.eventHandler(new AICoreApplicationServiceHandler(mockService)); if (mockService.isMultiTenancyEnabled()) { configurer.eventHandler(new MockAICoreSetupHandler(mockService)); logger.debug("Registered Mock AI-Core Setup Handler for MTX subscribe/unsubscribe."); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java index 0e7ec79..2e4fe7a 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java @@ -4,10 +4,10 @@ package com.sap.cds.feature.aicore.core; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.services.impl.cds.AbstractCdsDefinedService; import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.request.UserInfo; import com.sap.cds.services.runtime.CdsRuntime; -import com.sap.cds.services.utils.services.AbstractCqnService; import io.github.resilience4j.retry.Retry; import java.util.Map; @@ -16,10 +16,14 @@ * cache access, configuration, and resource group resolution. These methods are not part of the * public {@link AICoreService} contract but are shared between the real and mock implementations. */ -public abstract class AbstractAICoreService extends AbstractCqnService implements AICoreService { +public abstract class AbstractAICoreService extends AbstractCdsDefinedService + implements AICoreService { + + /** The qualified CDS service definition name. */ + private static final String CDS_DEFINITION_NAME = "AICore"; protected AbstractAICoreService(String name, CdsRuntime runtime) { - super(name, runtime); + super(name, CDS_DEFINITION_NAME, runtime); } /** Returns the {@link CdsRuntime} that this service was created with. */ diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApplicationServiceHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApplicationServiceHandler.java deleted file mode 100644 index 1b340cd..0000000 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApplicationServiceHandler.java +++ /dev/null @@ -1,103 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. - */ -package com.sap.cds.feature.aicore.core.handler; - -import com.sap.cds.ql.CQL; -import com.sap.cds.ql.cqn.CqnAnalyzer; -import com.sap.cds.ql.cqn.CqnSelect; -import com.sap.cds.ql.cqn.CqnStructuredTypeRef; -import com.sap.cds.ql.cqn.Modifier; -import com.sap.cds.reflect.CdsEntity; -import com.sap.cds.reflect.CdsModel; -import com.sap.cds.services.cds.ApplicationService; -import com.sap.cds.services.cds.CdsCreateEventContext; -import com.sap.cds.services.cds.CdsDeleteEventContext; -import com.sap.cds.services.cds.CdsReadEventContext; -import com.sap.cds.services.cds.CdsUpdateEventContext; -import com.sap.cds.services.cds.CqnService; -import com.sap.cds.services.handler.EventHandler; -import com.sap.cds.services.handler.annotations.HandlerOrder; -import com.sap.cds.services.handler.annotations.On; -import com.sap.cds.services.handler.annotations.ServiceName; -import com.sap.cds.services.utils.OrderConstants; - -/** - * Intercepts CRUD events on application service entities that are projections on AICore entities - * and delegates them to the AICore service. Without this, the framework would try to forward these - * to the PersistenceService, which fails since AICore entities have no database tables. - */ -@ServiceName(value = "*", type = ApplicationService.class) -public class AICoreApplicationServiceHandler implements EventHandler { - - private final CqnService aiCoreService; - - public AICoreApplicationServiceHandler(CqnService aiCoreService) { - this.aiCoreService = aiCoreService; - } - - @On - @HandlerOrder(OrderConstants.On.FEATURE) - public void onRead(CdsReadEventContext context) { - String sourceEntity = resolveAICoreSource(context.getTarget(), context.getModel()); - if (sourceEntity == null) { - return; - } - CqnSelect rewritten = CQL.copy(context.getCqn(), entityModifier(sourceEntity)); - context.setResult(aiCoreService.run(rewritten)); - } - - @On - @HandlerOrder(OrderConstants.On.FEATURE) - public void onCreate(CdsCreateEventContext context) { - String sourceEntity = resolveAICoreSource(context.getTarget(), context.getModel()); - if (sourceEntity == null) { - return; - } - context.setResult(aiCoreService.run(CQL.copy(context.getCqn(), entityModifier(sourceEntity)))); - } - - @On - @HandlerOrder(OrderConstants.On.FEATURE) - public void onUpdate(CdsUpdateEventContext context) { - String sourceEntity = resolveAICoreSource(context.getTarget(), context.getModel()); - if (sourceEntity == null) { - return; - } - context.setResult(aiCoreService.run(CQL.copy(context.getCqn(), entityModifier(sourceEntity)))); - } - - @On - @HandlerOrder(OrderConstants.On.FEATURE) - public void onDelete(CdsDeleteEventContext context) { - String sourceEntity = resolveAICoreSource(context.getTarget(), context.getModel()); - if (sourceEntity == null) { - return; - } - context.setResult(aiCoreService.run(CQL.copy(context.getCqn(), entityModifier(sourceEntity)))); - } - - private String resolveAICoreSource(CdsEntity entity, CdsModel model) { - if (entity == null || !entity.isProjection()) { - return null; - } - return entity - .query() - .filter(q -> q.from().isRef()) - .map(q -> CqnAnalyzer.create(model).analyze(q).targetEntity()) - .map(CdsEntity::getQualifiedName) - .filter(name -> name.startsWith("AICore.")) - .orElse(null); - } - - private static Modifier entityModifier(String targetEntity) { - return new Modifier() { - @Override - public CqnStructuredTypeRef ref(CqnStructuredTypeRef ref) { - var copy = CQL.copy(ref); - copy.rootSegment().id(targetEntity); - return copy.build(); - } - }; - } -} diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java index d2feaaa..77f7a2c 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java @@ -3,175 +3,75 @@ */ package com.sap.cds.feature.aicore.core; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.atLeastOnce; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.assertj.core.api.Assertions.assertThat; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.handler.AICoreApplicationServiceHandler; -import com.sap.cds.feature.aicore.core.handler.MockEntityHandler; -import com.sap.cds.services.ServiceCatalog; -import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.environment.CdsProperties; -import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; -import java.util.stream.Stream; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -@ExtendWith(MockitoExtension.class) +/** + * Tests {@link AICoreServiceConfiguration} using a real {@link CdsRuntime} booted with the AICore + * CDS model. This verifies the full service registration and handler wiring lifecycle without heavy + * Mockito mocks. + * + *

Since the test runtime has no service bindings, the configuration always registers a {@link + * MockAICoreServiceImpl} regardless of environment variables. + */ class AICoreServiceConfigurationTest { - @Mock private CdsRuntimeConfigurer configurer; - @Mock private CdsRuntime runtime; - @Mock private CdsEnvironment environment; - @Mock private ServiceCatalog serviceCatalog; - - /** - * Tests the eventHandlers() branch where the registered service is a MockAICoreServiceImpl - * (lines 116-123) with multi-tenancy disabled. - */ @Test - void eventHandlers_mockService_noMultiTenancy_registersBasicHandlers() { - when(configurer.getCdsRuntime()).thenReturn(runtime); - when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); - when(runtime.getEnvironment()).thenReturn(environment); - lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) - .thenAnswer(invocation -> invocation.getArgument(2)); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("default"); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("cds-"); - - MockAICoreServiceImpl mockService = - new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, false); - when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) - .thenReturn(mockService); - - AICoreServiceConfiguration config = new AICoreServiceConfiguration(); - config.eventHandlers(configurer); - - ArgumentCaptor captor = ArgumentCaptor.forClass(EventHandler.class); - verify(configurer, atLeastOnce()).eventHandler(captor.capture()); - - var handlers = captor.getAllValues(); - assert handlers.stream().anyMatch(h -> h instanceof MockEntityHandler); - assert handlers.stream().anyMatch(h -> h instanceof AICoreApplicationServiceHandler); - // No setup handler registered when MT is disabled - assert handlers.stream().noneMatch(h -> h instanceof MockAICoreSetupHandler); + void noBinding_noMultiTenancy_registersMockService() { + CdsRuntime runtime = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())) + .cdsModel("edmx/csn.json") + .serviceConfigurations() + .eventHandlerConfigurations() + .complete(); + + AICoreService service = + runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + + assertThat(service).isNotNull().isInstanceOf(MockAICoreServiceImpl.class); + assertThat(((MockAICoreServiceImpl) service).isMultiTenancyEnabled()).isFalse(); } - /** - * Tests the eventHandlers() branch where the registered service is a MockAICoreServiceImpl with - * multi-tenancy enabled — verifies that MockAICoreSetupHandler is registered (lines 119-121). - */ @Test - void eventHandlers_mockService_withMultiTenancy_registersSetupHandler() { - when(configurer.getCdsRuntime()).thenReturn(runtime); - when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); - when(runtime.getEnvironment()).thenReturn(environment); - lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) - .thenAnswer(invocation -> invocation.getArgument(2)); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("default"); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("cds-"); - - MockAICoreServiceImpl mockService = - new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, true); - when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) - .thenReturn(mockService); - - AICoreServiceConfiguration config = new AICoreServiceConfiguration(); - config.eventHandlers(configurer); - - ArgumentCaptor captor = ArgumentCaptor.forClass(EventHandler.class); - verify(configurer, atLeastOnce()).eventHandler(captor.capture()); - - var handlers = captor.getAllValues(); - assert handlers.stream().anyMatch(h -> h instanceof MockEntityHandler); - assert handlers.stream().anyMatch(h -> h instanceof AICoreApplicationServiceHandler); - assert handlers.stream().anyMatch(h -> h instanceof MockAICoreSetupHandler); - } - - /** - * Tests detectMultiTenancy returning true when sidecar URL is set (the - * `if (sidecarUrl != null && !sidecarUrl.isBlank()) { return true; }` branch). - * This is exercised through services() with no AI Core binding present. - */ - @Test - void services_noBinding_sidecarUrlSet_createsMultiTenantMockService() { - String envKey = System.getenv("AICORE_SERVICE_KEY"); - org.junit.jupiter.api.Assumptions.assumeTrue( - envKey == null || envKey.isBlank(), "Skipped: AICORE_SERVICE_KEY is set"); - when(configurer.getCdsRuntime()).thenReturn(runtime); - when(runtime.getEnvironment()).thenReturn(environment); - when(environment.getServiceBindings()).thenReturn(Stream.empty()); - lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) - .thenAnswer(invocation -> invocation.getArgument(2)); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("default"); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("cds-"); - - CdsProperties cdsProperties = new CdsProperties(); + void noBinding_withSidecarUrl_registersMultiTenantMockService() { + CdsProperties props = new CdsProperties(); CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar(); sidecar.setUrl("http://localhost:4004"); mt.setSidecar(sidecar); - cdsProperties.setMultiTenancy(mt); - when(environment.getCdsProperties()).thenReturn(cdsProperties); + props.setMultiTenancy(mt); + + CdsRuntime runtime = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)) + .cdsModel("edmx/csn.json") + .serviceConfigurations() + .eventHandlerConfigurations() + .complete(); - AICoreServiceConfiguration config = new AICoreServiceConfiguration(); - config.services(configurer); + AICoreService service = + runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); - ArgumentCaptor captor = - ArgumentCaptor.forClass(MockAICoreServiceImpl.class); - verify(configurer).service(captor.capture()); - assert captor.getValue().isMultiTenancyEnabled(); + assertThat(service).isNotNull().isInstanceOf(MockAICoreServiceImpl.class); + assertThat(((MockAICoreServiceImpl) service).isMultiTenancyEnabled()).isTrue(); } - /** - * Tests detectMultiTenancy returning false when no sidecar URL and no DeploymentService. - */ @Test - void services_noBinding_noSidecarUrl_noDeploymentService_singleTenant() { - String envKey = System.getenv("AICORE_SERVICE_KEY"); - org.junit.jupiter.api.Assumptions.assumeTrue( - envKey == null || envKey.isBlank(), "Skipped: AICORE_SERVICE_KEY is set"); - when(configurer.getCdsRuntime()).thenReturn(runtime); - when(runtime.getEnvironment()).thenReturn(environment); - when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); - when(environment.getServiceBindings()).thenReturn(Stream.empty()); - lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) - .thenAnswer(invocation -> invocation.getArgument(2)); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("default"); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("cds-"); - - CdsProperties cdsProperties = new CdsProperties(); - CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); - CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar(); - // No URL set - defaults to null - mt.setSidecar(sidecar); - cdsProperties.setMultiTenancy(mt); - when(environment.getCdsProperties()).thenReturn(cdsProperties); - when(serviceCatalog.getService(any(Class.class), any())).thenReturn(null); + void noModel_skipsServiceRegistration() { + CdsRuntime runtime = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())) + .serviceConfigurations() + .eventHandlerConfigurations() + .complete(); - AICoreServiceConfiguration config = new AICoreServiceConfiguration(); - config.services(configurer); + AICoreService service = + runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); - ArgumentCaptor captor = - ArgumentCaptor.forClass(MockAICoreServiceImpl.class); - verify(configurer).service(captor.capture()); - assert !captor.getValue().isMultiTenancyEnabled(); + assertThat(service).isNull(); } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java index f5e3396..4dc62c2 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java @@ -4,6 +4,7 @@ package com.sap.cds.feature.aicore.core; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -24,10 +25,14 @@ import com.sap.ai.sdk.core.model.AiDeploymentStatus; import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; -import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -52,30 +57,28 @@ class AICoreServiceImplDeploymentIdTest { new ModelDeploymentSpec(SCENARIO, "exec", CONFIG_NAME, List.of(), d -> true); private String cacheKey() { - // Derive via the production helper rather than hardcoding RG + "::" + CONFIG_NAME so a - // change to the cache-key format is caught here instead of silently passing wrong-path. return AICoreServiceImpl.deploymentCacheKey(RG, spec); } + /** Boots a real CdsRuntime with the AICore model and fast retry settings. */ + private static CdsRuntime createTestRuntime() { + TestPropertiesProvider props = new TestPropertiesProvider(); + props.setProperty("cds.ai.core.maxRetries", 1); + props.setProperty("cds.ai.core.initialDelayMs", 1L); + + return CdsRuntimeConfigurer.create(props) + .cdsModel("edmx/csn.json") + .serviceConfigurations() + .complete(); + } + @BeforeEach void setUp() { deploymentApi = mock(DeploymentApi.class); configurationApi = mock(ConfigurationApi.class); resourceGroupApi = mock(ResourceGroupApi.class); - AiCoreService sdkService = mock(AiCoreService.class); - - CdsRuntime runtime = mock(CdsRuntime.class); - CdsEnvironment env = mock(CdsEnvironment.class); - when(runtime.getEnvironment()).thenReturn(env); - // Use small retry counts so failures don't slow tests. - when(env.getProperty(eq("cds.ai.core.maxRetries"), eq(Integer.class), any())) - .thenReturn(1); - when(env.getProperty(eq("cds.ai.core.initialDelayMs"), eq(Long.class), any())) - .thenReturn(1L); - when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("default"); - when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("cds-"); + + CdsRuntime runtime = createTestRuntime(); service = new AICoreServiceImpl( @@ -85,7 +88,7 @@ void setUp() { deploymentApi, configurationApi, resourceGroupApi, - sdkService); + mock(AiCoreService.class)); } @Test @@ -131,15 +134,12 @@ void cacheStale_404OnGet_invalidatesAndReturnsExistingFromQuery() { @Test void cacheStale_5xxOnGet_propagatesAndPreservesCacheEntry() { - // Transient 5xx must NOT invalidate a potentially valid cache entry. The exception is - // propagated so the caller's retry/backoff policy can handle it. service.getResourceGroupDeploymentCache().put(cacheKey(), "still-valid-id"); OpenApiRequestException serverError = new OpenApiRequestException("boom").statusCode(503); when(deploymentApi.get(RG, "still-valid-id")).thenThrow(serverError); - org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.deploymentId(RG, spec)) - .isSameAs(serverError); + assertThatThrownBy(() -> service.deploymentId(RG, spec)).isSameAs(serverError); assertThat(service.getResourceGroupDeploymentCache()) .containsEntry(cacheKey(), "still-valid-id"); @@ -186,7 +186,6 @@ void secondCallUsesCachedResult_singleQueryToApi() { assertThat(first).isEqualTo(DEPLOYMENT_ID); assertThat(second).isEqualTo(DEPLOYMENT_ID); - // First call queries, second call hits the cache and only verifies via get. verify(deploymentApi, times(1)) .query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any()); verify(deploymentApi, times(1)).get(RG, DEPLOYMENT_ID); @@ -194,21 +193,12 @@ void secondCallUsesCachedResult_singleQueryToApi() { @Test void resourceGroupForTenant_nullTenantId_returnsDefault() { - // Even with MT enabled, a null tenantId should fall back to the default resource group. - CdsRuntime rtMt = mock(CdsRuntime.class); - CdsEnvironment envMt = mock(CdsEnvironment.class); - when(rtMt.getEnvironment()).thenReturn(envMt); - when(envMt.getProperty(eq("cds.ai.core.maxRetries"), eq(Integer.class), any())).thenReturn(1); - when(envMt.getProperty(eq("cds.ai.core.initialDelayMs"), eq(Long.class), any())).thenReturn(1L); - when(envMt.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("my-default"); - when(envMt.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("cds-"); + CdsRuntime runtime = createTestRuntime(); AICoreServiceImpl mtService = new AICoreServiceImpl( AICoreService.DEFAULT_NAME, - rtMt, + runtime, true, // multi-tenancy enabled deploymentApi, configurationApi, @@ -216,25 +206,22 @@ void resourceGroupForTenant_nullTenantId_returnsDefault() { mock(AiCoreService.class)); String result = mtService.resourceGroupForTenant(null); - assertThat(result).isEqualTo("my-default"); + assertThat(result).isEqualTo("default"); } @Test void resourceGroupForTenant_multiTenancyDisabled_returnsDefault() { - // Single-tenancy always returns default regardless of the tenantId passed. String result = service.resourceGroupForTenant("any-tenant"); assertThat(result).isEqualTo("default"); } @Test void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() { - // Empty deployment list → falls through to create path. AiDeploymentList emptyList = mock(AiDeploymentList.class); when(emptyList.getResources()).thenReturn(List.of()); when(deploymentApi.query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any())) .thenReturn(emptyList); - // Existing config with the same name, so createConfiguration is skipped. AiConfigurationList configList = mock(AiConfigurationList.class); var existingConfig = mock(com.sap.ai.sdk.core.model.AiConfiguration.class); when(existingConfig.getId()).thenReturn("cfg-1"); @@ -258,4 +245,27 @@ void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() { verify(configurationApi, never()).create(any(), any()); verify(deploymentApi).create(eq(RG), any()); } + + /** Properties provider that allows overriding specific keys for test configuration. */ + private static class TestPropertiesProvider extends SimplePropertiesProvider { + private final Map properties = new HashMap<>(); + + TestPropertiesProvider() { + super(new CdsProperties()); + } + + void setProperty(String key, Object value) { + properties.put(key, value); + } + + @Override + @SuppressWarnings("unchecked") + public T getProperty(String key, Class asClazz, T defaultValue) { + Object value = properties.get(key); + if (value != null && asClazz.isInstance(value)) { + return (T) value; + } + return defaultValue; + } + } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java index a8dd214..0dd1660 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java @@ -10,6 +10,8 @@ import static org.mockito.Mockito.when; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.reflect.CdsService; import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.Test; @@ -20,6 +22,9 @@ private MockAICoreServiceImpl createService(boolean multiTenancyEnabled) { CdsRuntime runtime = mock(CdsRuntime.class); CdsEnvironment env = mock(CdsEnvironment.class); when(runtime.getEnvironment()).thenReturn(env); + CdsModel cdsModel = mock(CdsModel.class); + when(runtime.getCdsModel()).thenReturn(cdsModel); + when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) .thenReturn("test-rg"); when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) @@ -32,6 +37,9 @@ void defaultConstructor_setsMultiTenancyFalse() { CdsRuntime runtime = mock(CdsRuntime.class); CdsEnvironment env = mock(CdsEnvironment.class); when(runtime.getEnvironment()).thenReturn(env); + CdsModel cdsModel = mock(CdsModel.class); + when(runtime.getCdsModel()).thenReturn(cdsModel); + when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) .thenReturn("default"); when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) diff --git a/cds-feature-recommendations/pom.xml b/cds-feature-recommendations/pom.xml index 4d7f75e..b86d41b 100644 --- a/cds-feature-recommendations/pom.xml +++ b/cds-feature-recommendations/pom.xml @@ -49,7 +49,6 @@ com.sap.cds cds-services-impl - test diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ApplicationServiceDelegationTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ApplicationServiceDelegationTest.java deleted file mode 100644 index c8f1296..0000000 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ApplicationServiceDelegationTest.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. - */ -package com.sap.cds.feature.aicore.itest; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import org.junit.jupiter.api.Test; -import org.springframework.security.test.context.support.WithMockUser; - -class ApplicationServiceDelegationTest extends BaseIntegrationTest { - - @Test - @WithMockUser(username = "test-user") - void readConfigurations_viaApplicationService() throws Exception { - mockMvc - .perform(get("/odata/v4/TestService/Configurations")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.value").isArray()); - } - - @Test - @WithMockUser(username = "test-user") - void readDeployments_viaApplicationService() throws Exception { - mockMvc - .perform(get("/odata/v4/TestService/Deployments")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.value").isArray()); - } - - @Test - @WithMockUser(username = "test-user") - void readResourceGroups_viaApplicationService() throws Exception { - mockMvc - .perform(get("/odata/v4/TestService/ResourceGroups")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.value").isArray()); - } -} diff --git a/integration-tests/spring/test-service.cds b/integration-tests/spring/test-service.cds index ecf43c6..232f698 100644 --- a/integration-tests/spring/test-service.cds +++ b/integration-tests/spring/test-service.cds @@ -2,10 +2,7 @@ using {itest} from '../db/schema'; using { AICore } from 'com.sap.cds/ai'; service TestService { - entity Products as projection on itest.Products; - entity Configurations as projection on AICore.configurations; - entity Deployments as projection on AICore.deployments; - entity ResourceGroups as projection on AICore.resourceGroups; + entity Products as projection on itest.Products; } service RecommendationTestService @(requires: 'any') { diff --git a/pom.xml b/pom.xml index ac2ecb8..e8de8c6 100644 --- a/pom.xml +++ b/pom.xml @@ -59,6 +59,9 @@ 1.0.0-SNAPSHOT + + **/Mock*.java + 17