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