From a11086051186514634122a76b328c8ca55c439bf Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 9 Jun 2026 20:02:23 +0200 Subject: [PATCH 01/24] Make cache for entitiesWithoutPredictionsPerTenant tenant specific --- .../FioriRecommendationHandler.java | 21 ++++++-- .../RecommendationConfiguration.java | 4 +- .../RecommendationModelChangedHandler.java | 26 ++++++++++ .../FioriRecommendationHandlerTest.java | 48 +++++++++++++++++++ 4 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index 4771c9d..dc26ca4 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -31,7 +31,10 @@ class FioriRecommendationHandler implements EventHandler { private final AICoreService aiCoreService; private final RecommendationClientResolver clientResolver; private final RecommendationResultParser resultParser = new RecommendationResultParser(); - private final Cache entitiesWithoutPredictions = + // Avoids re-evaluating the CDS model on every read to check whether an entity has prediction + // columns. Keys are ":" because if an entity needs a prediction can be + // different across tenants. + private final Cache entitiesWithoutPredictionsPerTenant = Caffeine.newBuilder().maximumSize(10_000).build(); FioriRecommendationHandler( @@ -40,14 +43,25 @@ class FioriRecommendationHandler implements EventHandler { this.clientResolver = clientResolver; } + void invalidateTenant(String tenantId) { + String prefix = tenantKey(tenantId) + ":"; + entitiesWithoutPredictionsPerTenant.asMap().keySet().removeIf(k -> k.startsWith(prefix)); + } + + private static String tenantKey(String tenantId) { + return tenantId != null ? tenantId : ""; + } + @After(entity = "*") public void afterRead(CdsReadEventContext context, List dataList) { CdsStructuredType target = context.getTarget(); if (target == null) { return; } + String tenantId = context.getUserInfo().getTenant(); String entityName = target.getQualifiedName(); - if (entitiesWithoutPredictions.getIfPresent(entityName) != null) { + String cacheKey = tenantKey(tenantId) + ":" + entityName; + if (entitiesWithoutPredictionsPerTenant.getIfPresent(cacheKey) != null) { return; } @@ -82,7 +96,7 @@ public void afterRead(CdsReadEventContext context, List dataList) { var builder = new RecommendationContextBuilder(target, rowType, limit); if (builder.predictionElementNames().isEmpty()) { - entitiesWithoutPredictions.put(entityName, Boolean.TRUE); + entitiesWithoutPredictionsPerTenant.put(cacheKey, Boolean.TRUE); return; } @@ -109,7 +123,6 @@ public void afterRead(CdsReadEventContext context, List dataList) { List allRows = builder.assembleRows(contextRows, predictRow, row); - String tenantId = context.getUserInfo().getTenant(); RecommendationClient client = clientResolver.resolve(aiCoreService, tenantId); List predictions = client.predict(allRows, builder.predictionElementNames(), builder.indexColumn()); diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java index 427d8a4..ee85b46 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java @@ -34,7 +34,9 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { ? (service, tenantId) -> new MockRecommendationClient() : RecommendationConfiguration::resolveRptClient; - configurer.eventHandler(new FioriRecommendationHandler(aiCoreService, resolver)); + FioriRecommendationHandler handler = new FioriRecommendationHandler(aiCoreService, resolver); + configurer.eventHandler(handler); + configurer.eventHandler(new RecommendationModelChangedHandler(handler)); } private static RecommendationClient resolveRptClient(AICoreService service, String tenantId) { diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java new file mode 100644 index 0000000..edeebd7 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java @@ -0,0 +1,26 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.mt.ExtensibilityService; +import com.sap.cds.services.mt.ModelChangedEventContext; + +@ServiceName(value = ExtensibilityService.DEFAULT_NAME, type = ExtensibilityService.class) +class RecommendationModelChangedHandler implements EventHandler { + + private final FioriRecommendationHandler recommendationHandler; + + RecommendationModelChangedHandler(FioriRecommendationHandler recommendationHandler) { + this.recommendationHandler = recommendationHandler; + } + + @On(event = ExtensibilityService.EVENT_MODEL_CHANGED) + public void onModelChanged(ModelChangedEventContext context) { + String tenantId = context.getUserInfo().getTenant(); + recommendationHandler.invalidateTenant(tenantId); + } +} diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java index 6ab17fa..6434ce7 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java @@ -296,6 +296,50 @@ void rptStyleClient_filledColumns_areExcludedFromRecommendations() { }); } + @Test + void invalidateTenant_removesOnlyThatTenantsEntries() { + // populate cache for two tenants + runInTenant( + "tenant-a", + () -> { + Map row = draftRow("title", "foo"); + CdsReadEventContext ctx = readContext("test.PlainEntity", List.of(row)); + cut.afterRead(ctx, dataList(row)); + }); + runInTenant( + "tenant-b", + () -> { + Map row = draftRow("title", "foo"); + CdsReadEventContext ctx = readContext("test.PlainEntity", List.of(row)); + cut.afterRead(ctx, dataList(row)); + }); + + cut.invalidateTenant("tenant-a"); + + // tenant-a entry gone → db is called again (no early exit) + runInTenant( + "tenant-a", + () -> { + Map row = draftRow("genre_ID", null); + CdsReadEventContext ctx = readContext("test.Books", List.of(row)); + when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); + cut.afterRead(ctx, dataList(row)); + assertThat(row).containsKey("SAP_Recommendations"); + }); + + // tenant-b entry still cached → db is NOT called + reset(db); + when(db.getName()).thenReturn(PersistenceService.DEFAULT_NAME); + runInTenant( + "tenant-b", + () -> { + Map row = draftRow("title", "foo"); + CdsReadEventContext ctx = readContext("test.PlainEntity", List.of(row)); + cut.afterRead(ctx, dataList(row)); + verifyNoInteractions(db); + }); + } + // ── helpers ──────────────────────────────────────────────────────────────── private CdsReadEventContext readContext(String entityName, List> resultRows) { @@ -317,6 +361,10 @@ private void runIn(Runnable test) { runtime.requestContext().run((Consumer) rc -> test.run()); } + private void runInTenant(String tenantId, Runnable test) { + runtime.requestContext().systemUser(tenantId).run((Consumer) rc -> test.run()); + } + private Map draftRow(String col, Object val) { Map row = new HashMap<>(); row.put("ID", "a009c640-434a-4542-ac68-51b400c880ec"); From 2231d619cdbe87b1a397733256199e5978659090 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 10 Jun 2026 14:23:41 +0200 Subject: [PATCH 02/24] refactor(ai-core): make AICoreService tenant-agnostic and DI-friendly - Replace resourceGroupForTenant(String) with resourceGroup() on the public AICoreService interface. The implementation reads the tenant from the current RequestContext internally. - Remove isMultiTenancyEnabled() and getRetry() from the public interface; they remain accessible on AbstractAICoreService for internal callers. - Remove the CDS function 'resourceGroupForTenant' from index.cds and its action handler. - Detect multi-tenancy via standard CAP Java cds.multiTenancy.sidecar.url property and DeploymentService presence instead of custom flag. - Update RecommendationClientResolver to drop tenantId parameter. - Update samples, tests, and javadoc accordingly. Addresses review comments from PR #49 (Issue 2). --- .../cds/feature/aicore/api/AICoreService.java | 35 +++++------------- .../core/AICoreServiceConfiguration.java | 23 +++++++++--- .../aicore/core/AICoreServiceImpl.java | 4 +- .../aicore/core/AbstractAICoreService.java | 37 +++++++++++++++++++ .../aicore/core/MockAICoreServiceImpl.java | 7 +++- .../aicore/core/handler/ActionHandler.java | 13 ------- .../core/handler/ResourceGroupHandler.java | 2 +- .../resources/cds/com.sap.cds/ai/index.cds | 2 - .../FioriRecommendationHandler.java | 2 +- .../RecommendationConfiguration.java | 10 +++-- .../api/RecommendationClientResolver.java | 2 +- .../api/RptInferenceClient.java | 5 ++- .../FioriRecommendationHandlerTest.java | 2 +- .../handlers/AICoreShowcaseHandler.java | 15 ++++---- 14 files changed, 91 insertions(+), 68 deletions(-) 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 6c90866..40374bb 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 @@ -5,7 +5,6 @@ import com.sap.cds.services.cds.CqnService; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; -import io.github.resilience4j.retry.Retry; /** * CAP service contract for SAP AI Core integration. @@ -15,20 +14,16 @@ * provides programmatic helpers to: * *
    - *
  • Resolve the resource group ID for a CDS tenant ({@link #resourceGroupForTenant(String)}), - * creating it on-demand when multi-tenancy is enabled. + *
  • Resolve the resource group ID for the current tenant ({@link #resourceGroup()}), creating + * it on-demand when multi-tenancy is enabled. *
  • Resolve (or create) a deployment matching a {@link ModelDeploymentSpec} ({@link * #deploymentId(String, ModelDeploymentSpec)}). *
  • Build an {@link ApiClient} preconfigured for inference against a specific deployment * ({@link #inferenceClient(String, String)}). - *
  • Expose a shared retry/backoff policy ({@link #getRetry()}) for downstream callers that want - * consistent transient-error handling. *
* - *

Two implementations are provided: {@link com.sap.cds.feature.aicore.core.AICoreServiceImpl} - * (when an SAP AI Core service binding is detected) and {@link - * com.sap.cds.feature.aicore.core.MockAICoreServiceImpl} (in-memory fallback for local - * development). + *

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 { @@ -45,16 +40,15 @@ public interface AICoreService extends CqnService { String CONFIGURATIONS = "AICore.configurations"; /** - * Returns the AI Core resource group ID associated with the given CDS tenant. + * Returns the AI Core resource group ID associated with the current tenant. * - *

When multi-tenancy is disabled the configured {@code cds.requires.AICore.resourceGroup} is - * returned for every tenant. When enabled, the resource group is looked up by the {@code - * ext.ai.sap.com/CDS_TENANT_ID} label and created on first call if it does not exist. + *

When multi-tenancy is disabled the configured default resource group is returned. When + * enabled, the resource group is looked up by the {@code ext.ai.sap.com/CDS_TENANT_ID} label and + * created on first call if it does not exist. * - * @param tenantId the CDS tenant identifier; may be {@code null} when multi-tenancy is disabled - * @return the AI Core resource group ID + * @return the AI Core resource group ID for the current tenant */ - String resourceGroupForTenant(String tenantId); + String resourceGroup(); /** * Returns the deployment ID for the given model spec inside the given resource group. @@ -80,13 +74,4 @@ public interface AICoreService extends CqnService { * @return a configured {@link ApiClient} pointing at the deployment's inference endpoint */ ApiClient inferenceClient(String resourceGroupId, String deploymentId); - - /** Returns whether multi-tenancy is enabled for this service. */ - boolean isMultiTenancyEnabled(); - - /** - * Returns the shared {@link Retry} used internally for transient AI Core errors. Exposed so - * downstream inference clients can reuse the same backoff policy. - */ - Retry getRetry(); } 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 1e31c9b..bfd4c8c 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 @@ -14,6 +14,8 @@ import com.sap.cds.feature.aicore.core.handler.DeploymentHandler; import com.sap.cds.feature.aicore.core.handler.MockEntityHandler; import com.sap.cds.feature.aicore.core.handler.ResourceGroupHandler; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.mt.DeploymentService; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; @@ -50,16 +52,27 @@ private static boolean hasAICoreBinding(CdsRuntime runtime) { return envKey != null && !envKey.isBlank(); } + /** + * Detects multi-tenancy by checking the standard CAP Java {@code cds.multiTenancy.sidecar.url} + * property or the presence of a {@link DeploymentService} in the service catalog. This aligns + * with the standard CAP Java convention — no custom property flag is needed. + */ + private static boolean detectMultiTenancy(CdsRuntime runtime) { + CdsProperties props = runtime.getEnvironment().getCdsProperties(); + String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl(); + if (sidecarUrl != null && !sidecarUrl.isBlank()) { + return true; + } + return runtime.getServiceCatalog().getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) != null; + } + @Override public void services(CdsRuntimeConfigurer configurer) { CdsRuntime runtime = configurer.getCdsRuntime(); boolean hasBinding = hasAICoreBinding(runtime); - boolean multiTenancyEnabled = - runtime - .getEnvironment() - .getProperty("cds.requires.AICore.multiTenancy", Boolean.class, false); + boolean multiTenancyEnabled = detectMultiTenancy(runtime); if (hasBinding) { AICoreServiceImpl service = @@ -75,7 +88,7 @@ public void services(CdsRuntimeConfigurer configurer) { logger.info("Registered AICoreService backed by AI Core binding."); } else { MockAICoreServiceImpl mockService = - new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); + new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, multiTenancyEnabled); configurer.service(mockService); logger.info("Registered MockAICoreService (no AI Core binding found)."); } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java index 91174cd..1fa5ee4 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java @@ -25,7 +25,6 @@ import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.ServiceException; import com.sap.cds.services.environment.CdsEnvironment; -import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; @@ -243,8 +242,7 @@ public String resolveResourceGroupFromKeys(Map keys) { if (rgObj instanceof Map rgMap && rgMap.containsKey("resourceGroupId")) { return (String) rgMap.get("resourceGroupId"); } - String tenantId = RequestContext.getCurrent(runtime).getUserInfo().getTenant(); - return resourceGroupForTenant(tenantId); + return resourceGroup(); } @Override 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 8b8d080..f04a995 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,8 +4,10 @@ package com.sap.cds.feature.aicore.core; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.services.request.RequestContext; 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; /** @@ -24,6 +26,41 @@ public CdsRuntime getRuntime() { return runtime; } + /** + * Returns the tenant ID from the current {@link RequestContext}. May return {@code null} if no + * tenant is set (e.g. in single-tenant mode). + */ + protected String currentTenantId() { + return RequestContext.getCurrent(runtime).getUserInfo().getTenant(); + } + + /** + * Returns whether multi-tenancy is enabled. Not part of the public {@link AICoreService} + * interface — callers should not need to be aware of multi-tenancy. + */ + public abstract boolean isMultiTenancyEnabled(); + + /** + * Returns the shared {@link Retry} used internally for transient AI Core errors. Not part of the + * public {@link AICoreService} interface but accessible to internal callers (e.g. the + * recommendations module) that need consistent backoff behaviour. + */ + public abstract Retry getRetry(); + + /** + * Returns the resource group for the given tenant ID. This is an internal method used by setup + * handlers where the tenant ID is explicitly available from the subscribe/unsubscribe context. + * + * @param tenantId the CDS tenant identifier + * @return the AI Core resource group ID + */ + public abstract String resourceGroupForTenant(String tenantId); + + @Override + public String resourceGroup() { + return resourceGroupForTenant(currentTenantId()); + } + /** Returns the configured default resource group identifier. */ public abstract String getDefaultResourceGroup(); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java index c54b4b9..84a3602 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java @@ -26,6 +26,10 @@ public class MockAICoreServiceImpl extends AbstractAICoreService { private final boolean multiTenancyEnabled; public MockAICoreServiceImpl(String name, CdsRuntime runtime) { + this(name, runtime, false); + } + + public MockAICoreServiceImpl(String name, CdsRuntime runtime, boolean multiTenancyEnabled) { super(name, runtime); logger.info("MockAICoreService initialized - all operations use in-memory storage."); this.retry = Retry.of("mock-aicore", RetryConfig.custom().maxAttempts(1).build()); @@ -34,8 +38,7 @@ public MockAICoreServiceImpl(String name, CdsRuntime runtime) { env.getProperty("cds.requires.AICore.resourceGroup", String.class, "default"); this.resourceGroupPrefix = env.getProperty("cds.requires.AICore.resourceGroupPrefix", String.class, "cds-"); - this.multiTenancyEnabled = - env.getProperty("cds.requires.AICore.multiTenancy", Boolean.class, false); + this.multiTenancyEnabled = multiTenancyEnabled; } @Override diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java index b454a2f..810068b 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java @@ -12,7 +12,6 @@ import com.sap.cds.services.EventContext; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; -import com.sap.cds.services.request.RequestContext; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,18 +25,6 @@ public ActionHandler(AICoreServiceImpl service) { super(service); } - @On(event = "resourceGroupForTenant") - public void onResourceGroupForTenant(EventContext context) { - Map params = asMap(context.get("data")); - String tenant = (String) params.get("tenant"); - if (tenant == null) { - tenant = RequestContext.getCurrent(service.getRuntime()).getUserInfo().getTenant(); - } - String result = service.resourceGroupForTenant(tenant); - context.put("result", result); - context.setCompleted(); - } - @On(event = "stop", entity = AICoreService.DEPLOYMENTS) public void onStop(EventContext context) { Map keys = asMap(context.get("keys")); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java index 3295ebe..8afd43d 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java @@ -159,7 +159,7 @@ private String resolveResourceGroupId(Map keys) { if (keys.containsKey(ResourceGroups.TENANT_ID)) { return service.resourceGroupForTenant((String) keys.get(ResourceGroups.TENANT_ID)); } - return service.getDefaultResourceGroup(); + return service.resourceGroup(); } private static List toSdkLabels(List> labels) { diff --git a/cds-feature-ai-core/src/main/resources/cds/com.sap.cds/ai/index.cds b/cds-feature-ai-core/src/main/resources/cds/com.sap.cds/ai/index.cds index f60e272..d823f49 100644 --- a/cds-feature-ai-core/src/main/resources/cds/com.sap.cds/ai/index.cds +++ b/cds-feature-ai-core/src/main/resources/cds/com.sap.cds/ai/index.cds @@ -90,8 +90,6 @@ service AICore { on 1 = 1; }; - function resourceGroupForTenant(tenant: String) returns String; - type BckndResourceGroupLabels : many BckndResourceGroupLabel; type BckndResourceGroupLabel { diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index ff78d57..c28af81 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -125,7 +125,7 @@ public void afterRead(CdsReadEventContext context, List dataList) { List allRows = builder.assembleRows(contextRows, predictRow, row); - RecommendationClient client = clientResolver.resolve(aiCoreService, tenantId); + RecommendationClient client = clientResolver.resolve(aiCoreService); List predictions = client.predict(allRows, builder.predictionElementNames(), builder.indexColumn()); diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java index b32a120..879f0fe 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java @@ -4,6 +4,7 @@ package com.sap.cds.feature.recommendation; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.feature.aicore.core.MockAICoreServiceImpl; import com.sap.cds.feature.recommendation.api.RecommendationClient; import com.sap.cds.feature.recommendation.api.RecommendationClientResolver; @@ -35,7 +36,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { RecommendationClientResolver resolver = aiCoreService instanceof MockAICoreServiceImpl - ? (service, tenantId) -> new MockRecommendationClient() + ? service -> new MockRecommendationClient() : RecommendationConfiguration::resolveRptClient; FioriRecommendationHandler handler = new FioriRecommendationHandler(aiCoreService, resolver); @@ -43,10 +44,11 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { configurer.eventHandler(new RecommendationModelChangedHandler(handler)); } - private static RecommendationClient resolveRptClient(AICoreService service, String tenantId) { - String resourceGroup = service.resourceGroupForTenant(tenantId); + private static RecommendationClient resolveRptClient(AICoreService service) { + String resourceGroup = service.resourceGroup(); String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); return new RptInferenceClient( - service.inferenceClient(resourceGroup, deploymentId), service.getRetry()); + service.inferenceClient(resourceGroup, deploymentId), + ((AbstractAICoreService) service).getRetry()); } } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java index 38db2ad..ecc6837 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java @@ -8,5 +8,5 @@ @FunctionalInterface public interface RecommendationClientResolver { - RecommendationClient resolve(AICoreService aiCoreService, String tenantId); + RecommendationClient resolve(AICoreService aiCoreService); } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 0616340..2865dd3 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -31,10 +31,11 @@ * *

{@code
  * AICoreService service = ...;
- * String rg = service.resourceGroupForTenant(tenantId);
+ * String rg = service.resourceGroup();
  * String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1());
  * RptInferenceClient client =
- *     new RptInferenceClient(service.inferenceClient(rg, deploymentId), service.getRetry());
+ *     new RptInferenceClient(service.inferenceClient(rg, deploymentId),
+ *         ((AbstractAICoreService) service).getRetry());
  * List predictions = client.predict(rows, List.of("targetColumn"), "ID");
  * }
*/ diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java index c007160..c7f1a22 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java @@ -67,7 +67,7 @@ void setup() { reset(db); when(db.getName()).thenReturn(PersistenceService.DEFAULT_NAME); predictionClient = randomPickClient(); - cut = new FioriRecommendationHandler(aiCoreService, (service, tenantId) -> predictionClient); + cut = new FioriRecommendationHandler(aiCoreService, (service) -> predictionClient); } // ── tests ────────────────────────────────────────────────────────────────── diff --git a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java index 8923382..8023ca2 100644 --- a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java @@ -3,6 +3,7 @@ import com.sap.cds.CdsData; import com.sap.cds.Result; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.feature.recommendation.api.RptInferenceClient; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.ql.Insert; @@ -14,7 +15,6 @@ import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; -import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.runtime.CdsRuntime; import java.util.ArrayList; import java.util.HashMap; @@ -43,16 +43,14 @@ public void onReadConfigurations(CdsReadEventContext context) { @On(event = "setupTenantResources") public void onSetupTenantResources(EventContext context) { - String tenantId = (String) context.get("tenantId"); - String rgId = getAICoreService().resourceGroupForTenant(tenantId); + String rgId = getAICoreService().resourceGroup(); context.put("result", rgId); context.setCompleted(); } @On(event = "getMyResourceGroup") public void onGetMyResourceGroup(EventContext context) { - String tenant = RequestContext.getCurrent(runtime).getUserInfo().getTenant(); - String rgId = getAICoreService().resourceGroupForTenant(tenant); + String rgId = getAICoreService().resourceGroup(); context.put("result", rgId); context.setCompleted(); } @@ -139,11 +137,12 @@ public void onPredictCategory(EventContext context) { } AICoreService service = getAICoreService(); - String tenant = RequestContext.getCurrent(runtime).getUserInfo().getTenant(); - String rg = service.resourceGroupForTenant(tenant); + String rg = service.resourceGroup(); String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1()); RptInferenceClient client = - new RptInferenceClient(service.inferenceClient(rg, deploymentId), service.getRetry()); + new RptInferenceClient( + service.inferenceClient(rg, deploymentId), + ((AbstractAICoreService) service).getRetry()); List predictions = client.predict(rows, List.of("category"), "ID"); List> results = new ArrayList<>(); From de4f63701f9c4c28a3021d05be75cce617b97456 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 10 Jun 2026 14:27:53 +0200 Subject: [PATCH 03/24] feat(ai-core): restrict AICore entity APIs to current tenant - Add tenant ownership verification on ResourceGroupHandler for READ (by-key), UPDATE, and DELETE operations. Returns 404 if the resource group belongs to a different tenant. - Scope list queries (READ without key) to the current tenant's resource groups via the tenant label filter in multi-tenancy mode. - Add ensureResourceGroupAccessible() guard to DeploymentHandler and ConfigurationHandler, validating the addressed resource group belongs to the current tenant before forwarding to AI Core. - Provider/system users are exempt from tenant restrictions and can access all resource groups (useful for ops/debug scenarios). - Add isProviderUser() and currentTenantId() as public helpers on AbstractAICoreService for use by handler classes. Addresses review comments from PR #49 (Issue 3a). --- .../aicore/core/AbstractAICoreService.java | 12 +++- .../core/handler/AbstractCrudHandler.java | 28 +++++++++ .../core/handler/ConfigurationHandler.java | 2 + .../core/handler/DeploymentHandler.java | 4 ++ .../core/handler/ResourceGroupHandler.java | 59 +++++++++++++++++-- 5 files changed, 99 insertions(+), 6 deletions(-) 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 f04a995..0e7ec79 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 @@ -5,6 +5,7 @@ import com.sap.cds.feature.aicore.api.AICoreService; 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; @@ -30,10 +31,19 @@ public CdsRuntime getRuntime() { * Returns the tenant ID from the current {@link RequestContext}. May return {@code null} if no * tenant is set (e.g. in single-tenant mode). */ - protected String currentTenantId() { + public String currentTenantId() { return RequestContext.getCurrent(runtime).getUserInfo().getTenant(); } + /** + * Returns whether the current request is running as a system/provider user. Provider users are + * allowed to see all tenants' resources. + */ + public boolean isProviderUser() { + UserInfo userInfo = RequestContext.getCurrent(runtime).getUserInfo(); + return userInfo.isSystemUser() || userInfo.isInternalUser(); + } + /** * Returns whether multi-tenancy is enabled. Not part of the public {@link AICoreService} * interface — callers should not need to be aware of multi-tenancy. diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java index 402ab9b..0549daa 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java @@ -3,7 +3,10 @@ */ package com.sap.cds.feature.aicore.core.handler; +import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; import com.sap.cds.services.handler.EventHandler; import java.util.HashMap; import java.util.List; @@ -22,6 +25,31 @@ protected String resolveResourceGroup(Map keys) { return service.resolveResourceGroupFromKeys(keys); } + /** + * Validates that the given resource group is accessible by the current tenant. Provider/system + * users may access any resource group. In single-tenancy mode, no restriction is applied. Throws + * 404 if the resource group does not belong to the current tenant. + */ + protected void ensureResourceGroupAccessible(String resourceGroupId) { + if (service.isProviderUser() || !service.isMultiTenancyEnabled()) { + return; + } + String currentTenant = service.currentTenantId(); + if (currentTenant == null) { + return; + } + BckndResourceGroup rg = service.getResourceGroupApi().get(resourceGroupId); + if (rg.getLabels() != null + && rg.getLabels().stream() + .anyMatch( + l -> + AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.getKey()) + && currentTenant.equals(l.getValue()))) { + return; + } + throw new ServiceException(ErrorStatuses.NOT_FOUND, "Resource not found"); + } + protected static Map merge(Map keys, Map values) { Map merged = new HashMap<>(values); keys.forEach( diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java index 7ad70de..bafdbff 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java @@ -52,6 +52,7 @@ public void onRead(CdsReadEventContext context) { Map values = analysis.targetValues(); String resourceGroupId = resolveResourceGroup(merge(keys, values)); + ensureResourceGroupAccessible(resourceGroupId); logger.debug( "Reading configurations for resourceGroup={}, keys={}, values={}", resourceGroupId, @@ -79,6 +80,7 @@ public void onCreate(CdsCreateEventContext context, List entries for (Configurations entry : entries) { String resourceGroupId = resolveResourceGroup(entry); + ensureResourceGroupAccessible(resourceGroupId); AiConfigurationBaseData request = AiConfigurationBaseData.create() diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java index d5907b7..0b3df10 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java @@ -56,6 +56,7 @@ public void onRead(CdsReadEventContext context) { Map values = analysis.targetValues(); String resourceGroupId = resolveResourceGroup(merge(keys, values)); + ensureResourceGroupAccessible(resourceGroupId); String id = (String) keys.get(Deployments.ID); if (id != null) { @@ -75,6 +76,7 @@ public void onCreate(CdsCreateEventContext context, List entries) { for (Deployments entry : entries) { String resourceGroupId = resolveResourceGroup(entry); + ensureResourceGroupAccessible(resourceGroupId); String configurationId = entry.getConfigurationId(); AiDeploymentCreationRequest request = @@ -111,6 +113,7 @@ public void onUpdate(CdsUpdateEventContext context, List entries) { String deploymentId = (String) keys.get(Deployments.ID); String resourceGroupId = resolveResourceGroup(merge(keys, data)); + ensureResourceGroupAccessible(resourceGroupId); AiDeploymentModificationRequest modRequest = AiDeploymentModificationRequest.create(); @@ -135,6 +138,7 @@ public void onDelete(CdsDeleteEventContext context) { String deploymentId = (String) keys.get(Deployments.ID); String resourceGroupId = resolveResourceGroup(keys); + ensureResourceGroupAccessible(resourceGroupId); deploymentApi.delete(resourceGroupId, deploymentId); logger.debug("Deleted deployment {} in resource group {}", deploymentId, resourceGroupId); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java index 8afd43d..b0e7094 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java @@ -19,6 +19,8 @@ import com.sap.cds.ql.cqn.CqnSelect; import com.sap.cds.ql.cqn.CqnUpdate; import com.sap.cds.reflect.CdsModel; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; import com.sap.cds.services.cds.CdsCreateEventContext; import com.sap.cds.services.cds.CdsDeleteEventContext; import com.sap.cds.services.cds.CdsReadEventContext; @@ -60,13 +62,10 @@ public void onRead(CdsReadEventContext context) { if (resourceGroupId != null) { BckndResourceGroup rg = resourceGroupApi.get(resourceGroupId); + ensureOwnedByCurrentTenant(rg); context.setResult(List.of(toMap(rg))); } else { - List labelSelector = null; - if (values.containsKey(ResourceGroups.TENANT_ID)) { - String tenantId = (String) values.get(ResourceGroups.TENANT_ID); - labelSelector = List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + tenantId); - } + List labelSelector = buildTenantLabelSelector(values); BckndResourceGroupList result = resourceGroupApi.getAll(null, null, null, null, null, null, labelSelector); context.setResult(mapResources(result.getResources(), this::toMap)); @@ -124,6 +123,7 @@ public void onUpdate(CdsUpdateEventContext context) { Map keys = analyzer.analyze(update).targetKeys(); String resourceGroupId = resolveResourceGroupId(keys); + ensureOwnedByCurrentTenant(resourceGroupApi.get(resourceGroupId)); Map data = update.entries().get(0); BckndResourceGroupPatchRequest patchRequest = BckndResourceGroupPatchRequest.create(); @@ -147,6 +147,8 @@ public void onDelete(CdsDeleteEventContext context) { Map keys = analyzer.analyze(delete).targetKeys(); String resourceGroupId = resolveResourceGroupId(keys); + ensureOwnedByCurrentTenant(resourceGroupApi.get(resourceGroupId)); + resourceGroupApi.delete(resourceGroupId); logger.debug("Deleted resource group {}", resourceGroupId); context.setResult(List.of()); @@ -162,6 +164,53 @@ private String resolveResourceGroupId(Map keys) { return service.resourceGroup(); } + /** + * Builds a tenant-scoped label selector for list queries. In multi-tenancy mode, non-provider + * users are restricted to their own tenant's resource groups. + */ + private List buildTenantLabelSelector(Map values) { + // If a specific tenantId is requested in the query, use that + if (values.containsKey(ResourceGroups.TENANT_ID)) { + String tenantId = (String) values.get(ResourceGroups.TENANT_ID); + return List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + tenantId); + } + // In MT mode, restrict non-provider users to their own tenant + if (service.isMultiTenancyEnabled() && !service.isProviderUser()) { + String currentTenant = service.currentTenantId(); + if (currentTenant != null) { + return List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + currentTenant); + } + } + return null; + } + + /** + * Verifies that the given resource group is owned by the current tenant. Provider/system users + * are allowed to access any resource group. Throws 404 if the resource group belongs to a + * different tenant. + */ + private void ensureOwnedByCurrentTenant(BckndResourceGroup rg) { + if (service.isProviderUser()) { + return; + } + if (!service.isMultiTenancyEnabled()) { + return; + } + String currentTenant = service.currentTenantId(); + if (currentTenant == null) { + return; + } + if (rg.getLabels() != null + && rg.getLabels().stream() + .anyMatch( + l -> + AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.getKey()) + && currentTenant.equals(l.getValue()))) { + return; + } + throw new ServiceException(ErrorStatuses.NOT_FOUND, "Resource group not found"); + } + private static List toSdkLabels(List> labels) { return labels.stream() .map( From c30080ba4dff346332e04461b86e34ba0737ef59 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 10 Jun 2026 14:29:55 +0200 Subject: [PATCH 04/24] chore(ai-core): rename config namespace to cds.ai.core - Rename all configuration properties from cds.requires.AICore.* to cds.ai.core.* to align with CAP Java property naming conventions. - Rename cds.requires.recommendations.contextRowLimit to cds.ai.recommendations.contextRowLimit. - Drop the cds.requires.AICore.multiTenancy flag entirely; multi- tenancy is now auto-detected from standard CAP Java properties. - Update README with new configuration namespace and examples. Addresses review comments from PR #49 (Issue 3b). --- cds-feature-ai-core/README.md | 20 ++++++++++--------- .../aicore/core/AICoreServiceImpl.java | 8 ++++---- .../aicore/core/MockAICoreServiceImpl.java | 4 ++-- .../AICoreServiceImplDeploymentIdTest.java | 8 ++++---- .../FioriRecommendationHandler.java | 2 +- 5 files changed, 22 insertions(+), 20 deletions(-) diff --git a/cds-feature-ai-core/README.md b/cds-feature-ai-core/README.md index c447859..1b1125f 100644 --- a/cds-feature-ai-core/README.md +++ b/cds-feature-ai-core/README.md @@ -36,19 +36,21 @@ Without a binding the plugin registers a mock implementation. ## Configuration -All configuration is under the `cds.requires.AICore` namespace in `application.yaml`: +All configuration is under the `cds.ai.core` namespace in `application.yaml`: ```yaml cds: - requires: - AICore: + ai: + core: resourceGroup: default # Resource group for single-tenant mode resourceGroupPrefix: "cds-" # Prefix for auto-created tenant resource groups - multiTenancy: false # Enable per-tenant resource groups maxRetries: 10 # Max retry attempts for transient AI Core errors initialDelayMs: 300 # Initial backoff delay (ms) ``` +Multi-tenancy is auto-detected from CAP Java's standard `cds.multiTenancy.sidecar.url` setting +and the presence of a `DeploymentService`. No additional configuration flag is required. + ## CDS Service: `AICore` The plugin registers a CAP service named `AICore` that proxies AI Core REST APIs as CDS entities: @@ -61,11 +63,11 @@ The plugin registers a CAP service named `AICore` that proxies AI Core REST APIs | `AICore.deployments` | READ, CREATE, DELETE | Deployment management with status tracking | | `AICore.configurations` | READ, CREATE | Configuration management for scenarios and executables | -### Functions & Actions +### Programmatic API ```java -// Get the resource group ID for a CDS tenant -String rgId = aiCoreService.resourceGroupForTenant(tenantId); +// Get the resource group for the current tenant +String rgId = aiCoreService.resourceGroup(); // Get (or auto-create) a deployment ID for a model spec in the given resource group String deploymentId = aiCoreService.deploymentId(rgId, RptModelSpec.rpt1()); @@ -73,7 +75,7 @@ String deploymentId = aiCoreService.deploymentId(rgId, RptModelSpec.rpt1()); ## Multi-Tenancy -When `cds.requires.AICore.multiTenancy=true`: +When multi-tenancy is active (detected via `cds.multiTenancy.sidecar.url`): 1. **Subscribe** - Creates resource group `{prefix}{tenantId}` with label `ext.ai.sap.com/CDS_TENANT_ID` 2. **Unsubscribe** - Deletes the tenant's resource group @@ -92,7 +94,7 @@ AICoreService aiCore = runtime.getServiceCatalog() Result rgs = aiCore.run(Select.from("AICore.resourceGroups")); // Resolve a deployment and obtain a configured ApiClient for it -String resourceGroupId = aiCore.resourceGroupForTenant(tenantId); +String resourceGroupId = aiCore.resourceGroup(); String deploymentId = aiCore.deploymentId(resourceGroupId, RptModelSpec.rpt1()); ApiClient client = aiCore.inferenceClient(resourceGroupId, deploymentId); ``` diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java index 1fa5ee4..f3d4129 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java @@ -99,14 +99,14 @@ public AICoreServiceImpl( this.multiTenancyEnabled = multiTenancyEnabled; CdsEnvironment env = runtime.getEnvironment(); this.maxRetries = - env.getProperty("cds.requires.AICore.maxRetries", Integer.class, DEFAULT_MAX_RETRIES); + env.getProperty("cds.ai.core.maxRetries", Integer.class, DEFAULT_MAX_RETRIES); this.initialDelayMs = - env.getProperty("cds.requires.AICore.initialDelayMs", Long.class, DEFAULT_INITIAL_DELAY_MS); + env.getProperty("cds.ai.core.initialDelayMs", Long.class, DEFAULT_INITIAL_DELAY_MS); this.defaultResourceGroup = - env.getProperty("cds.requires.AICore.resourceGroup", String.class, DEFAULT_RESOURCE_GROUP); + env.getProperty("cds.ai.core.resourceGroup", String.class, DEFAULT_RESOURCE_GROUP); this.resourceGroupPrefix = env.getProperty( - "cds.requires.AICore.resourceGroupPrefix", String.class, DEFAULT_RESOURCE_GROUP_PREFIX); + "cds.ai.core.resourceGroupPrefix", String.class, DEFAULT_RESOURCE_GROUP_PREFIX); this.retry = buildRetry(maxRetries, initialDelayMs); this.tenantResourceGroupCache = newCache(); this.resourceGroupDeploymentCache = newCache(); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java index 84a3602..aaee071 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java @@ -35,9 +35,9 @@ public MockAICoreServiceImpl(String name, CdsRuntime runtime, boolean multiTenan this.retry = Retry.of("mock-aicore", RetryConfig.custom().maxAttempts(1).build()); CdsEnvironment env = runtime.getEnvironment(); this.defaultResourceGroup = - env.getProperty("cds.requires.AICore.resourceGroup", String.class, "default"); + env.getProperty("cds.ai.core.resourceGroup", String.class, "default"); this.resourceGroupPrefix = - env.getProperty("cds.requires.AICore.resourceGroupPrefix", String.class, "cds-"); + env.getProperty("cds.ai.core.resourceGroupPrefix", String.class, "cds-"); this.multiTenancyEnabled = multiTenancyEnabled; } 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 1d7b670..76c7774 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 @@ -68,13 +68,13 @@ void setUp() { 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.requires.AICore.maxRetries"), eq(Integer.class), any())) + when(env.getProperty(eq("cds.ai.core.maxRetries"), eq(Integer.class), any())) .thenReturn(1); - when(env.getProperty(eq("cds.requires.AICore.initialDelayMs"), eq(Long.class), any())) + when(env.getProperty(eq("cds.ai.core.initialDelayMs"), eq(Long.class), any())) .thenReturn(1L); - when(env.getProperty(eq("cds.requires.AICore.resourceGroup"), eq(String.class), any())) + when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) .thenReturn("default"); - when(env.getProperty(eq("cds.requires.AICore.resourceGroupPrefix"), eq(String.class), any())) + when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) .thenReturn("cds-"); service = diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index c28af81..e7ea5d5 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -91,7 +91,7 @@ public void afterRead(CdsReadEventContext context, List dataList) { .getCdsRuntime() .getEnvironment() .getProperty( - "cds.requires.recommendations.contextRowLimit", + "cds.ai.recommendations.contextRowLimit", Integer.class, DEFAULT_CONTEXT_ROW_LIMIT); From 1eb8c33a87012e4460a793e1b0709dc3e472586d Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 10 Jun 2026 14:52:38 +0200 Subject: [PATCH 05/24] fix(ai-core): handle null tenant in resourceGroupForTenant When resourceGroupForTenant is called with a null tenantId (which happens when currentTenantId() returns null in single-tenant or non-tenant-scoped RequestContexts), fall back to the default resource group instead of passing null to the Caffeine cache (which throws NPE). This fixes integration test failures in the CI pipeline where the ApplicationServiceDelegation and Recommendation tests run without an explicit tenant in the RequestContext. --- .../com/sap/cds/feature/aicore/core/AICoreServiceImpl.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java index f3d4129..52eeb4f 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java @@ -125,8 +125,8 @@ private static Cache newCache() { @Override public String resourceGroupForTenant(String tenantId) { - if (!multiTenancyEnabled) { - logger.debug("Multi-tenancy disabled, using resource group {}", defaultResourceGroup); + if (!multiTenancyEnabled || tenantId == null) { + logger.debug("Using default resource group {}", defaultResourceGroup); return defaultResourceGroup; } return getOrCreateResourceGroupForTenant(tenantId); From e730fa80770e8e3004fd5ee49959901830822ab0 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 10 Jun 2026 15:05:19 +0200 Subject: [PATCH 06/24] fix(ci): cleanup all run attempts and cds-itest resource groups The cleanup step previously only deleted resource groups matching the exact current run_id AND run_attempt. When a run failed and was re-run, the previous attempt's resource groups were never cleaned up, eventually hitting the AI Core resource group limit (50). Changes: - Match prefix 'itest-{run_id}-' (all attempts) instead of the exact 'itest-{run_id}-{run_attempt}' string. - Same for 'sonar-{run_id}-' prefix. - Also delete 'cds-itest-' prefixed resource groups which are created by the multi-tenancy integration tests via resourceGroupForTenant() and were never cleaned up by the pipeline. --- .github/workflows/pipeline.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 2e7b238..24eb6d7 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -114,7 +114,9 @@ jobs: const tokenRes = await fetch(tokenUrl+'?'+tokenParams.toString(), {headers:{'Authorization':authHeader}}); const token = JSON.parse(tokenRes.body).access_token; const headers = {'Authorization':'Bearer '+token, 'AI-Resource-Group':'default'}; - const prefixes = ['itest-${{ github.run_id }}-${{ github.run_attempt }}', 'sonar-${{ github.run_id }}-${{ github.run_attempt }}']; + // Match ALL attempts for this run (not just current attempt) plus cds-itest- RGs + const runId = '${{ github.run_id }}'; + const prefixes = ['itest-' + runId + '-', 'sonar-' + runId + '-', 'cds-itest-']; const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers}); const groups = JSON.parse(rgRes.body).resources || []; const toDelete = groups.filter(rg => rg.resourceGroupId && prefixes.some(p => rg.resourceGroupId.startsWith(p))); @@ -122,7 +124,7 @@ jobs: const res = await fetch(apiUrl+'/v2/admin/resourceGroups/'+rg.resourceGroupId, {method:'DELETE', headers}); console.log('Delete', rg.resourceGroupId, '->', res.status); } - console.log('Cleaned up', toDelete.length, 'resource groups for run ${{ github.run_id }}'); + console.log('Cleaned up', toDelete.length, 'resource groups for run ' + runId + ' (all attempts)'); })().catch(e => { console.error(e.message); process.exit(1); }); " From 6ebc3fd65b86848b6077330f2cc72272e6708c14 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 10 Jun 2026 20:38:34 +0200 Subject: [PATCH 07/24] fix(itest): align config namespace with cds.ai.core rename The source code (commit c30080b) renamed properties from cds.requires.AICore.* to cds.ai.core.*, but the integration test application.yaml files were not updated. This meant the CDS_AICORE_TEST_RESOURCE_GROUP env var set by CI was silently ignored and tests always ran against the literal default resource group. - spring/application.yaml: cds.requires.AICore -> cds.ai.core - mtx-local/application.yaml: remove obsolete cds.requires.AICore.multiTenancy (now auto-detected from cds.multi-tenancy.sidecar.url) --- .../mtx-local/srv/src/main/resources/application.yaml | 3 --- integration-tests/spring/src/main/resources/application.yaml | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/integration-tests/mtx-local/srv/src/main/resources/application.yaml b/integration-tests/mtx-local/srv/src/main/resources/application.yaml index df8c3e4..2bf2b72 100644 --- a/integration-tests/mtx-local/srv/src/main/resources/application.yaml +++ b/integration-tests/mtx-local/srv/src/main/resources/application.yaml @@ -16,9 +16,6 @@ cds: spring: config.activate.on-profile: local-with-tenants cds: - requires: - AICore: - multiTenancy: true security: mock: tenants: diff --git a/integration-tests/spring/src/main/resources/application.yaml b/integration-tests/spring/src/main/resources/application.yaml index cefb50a..73e07c4 100644 --- a/integration-tests/spring/src/main/resources/application.yaml +++ b/integration-tests/spring/src/main/resources/application.yaml @@ -7,8 +7,8 @@ spring: mode: always cds: - requires: - AICore: + ai: + core: resourceGroup: ${CDS_AICORE_TEST_RESOURCE_GROUP:cap-java-ai-default} maxRetries: 15 security: From 8c0197e0ae56631a6dc7bf5fee66e2f9b3ae89ad Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 10 Jun 2026 20:38:43 +0200 Subject: [PATCH 08/24] test(ai-core): add unit tests for tenant scoping and mock service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover new code paths introduced by the tenant-scoping branch: - TenantScopingTest (7 tests): exercises every branch of AbstractCrudHandler.ensureResourceGroupAccessible() — provider bypass, single-tenancy bypass, null tenant, matching/non-matching labels, 404. - MockAICoreServiceImplTest (9 tests): both constructors, MT enabled/disabled, resourceGroupForTenant, cache isolation, clearTenantCache, getRetry, config property reads. - AICoreServiceImplDeploymentIdTest (+2 tests): resourceGroupForTenant(null) returns default even with MT enabled; single-tenancy always returns default. --- .../AICoreServiceImplDeploymentIdTest.java | 34 ++++ .../core/MockAICoreServiceImplTest.java | 106 ++++++++++++ .../core/handler/TenantScopingTest.java | 154 ++++++++++++++++++ 3 files changed, 294 insertions(+) create mode 100644 cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java create mode 100644 cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java 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 76c7774..f5e3396 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 @@ -192,6 +192,40 @@ void secondCallUsesCachedResult_singleQueryToApi() { verify(deploymentApi, times(1)).get(RG, DEPLOYMENT_ID); } + @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-"); + + AICoreServiceImpl mtService = + new AICoreServiceImpl( + AICoreService.DEFAULT_NAME, + rtMt, + true, // multi-tenancy enabled + deploymentApi, + configurationApi, + resourceGroupApi, + mock(AiCoreService.class)); + + String result = mtService.resourceGroupForTenant(null); + assertThat(result).isEqualTo("my-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. 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 new file mode 100644 index 0000000..a8dd214 --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java @@ -0,0 +1,106 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.runtime.CdsRuntime; +import org.junit.jupiter.api.Test; + +class MockAICoreServiceImplTest { + + private MockAICoreServiceImpl createService(boolean multiTenancyEnabled) { + CdsRuntime runtime = mock(CdsRuntime.class); + CdsEnvironment env = mock(CdsEnvironment.class); + when(runtime.getEnvironment()).thenReturn(env); + 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())) + .thenReturn("prefix-"); + return new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, multiTenancyEnabled); + } + + @Test + void defaultConstructor_setsMultiTenancyFalse() { + CdsRuntime runtime = mock(CdsRuntime.class); + CdsEnvironment env = mock(CdsEnvironment.class); + when(runtime.getEnvironment()).thenReturn(env); + 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-"); + + MockAICoreServiceImpl service = new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); + assertThat(service.isMultiTenancyEnabled()).isFalse(); + } + + @Test + void mtConstructor_setsMultiTenancyTrue() { + MockAICoreServiceImpl service = createService(true); + assertThat(service.isMultiTenancyEnabled()).isTrue(); + } + + @Test + void resourceGroupForTenant_mtDisabled_alwaysReturnsDefault() { + MockAICoreServiceImpl service = createService(false); + assertThat(service.resourceGroupForTenant("tenant-x")).isEqualTo("test-rg"); + assertThat(service.resourceGroupForTenant("tenant-y")).isEqualTo("test-rg"); + } + + @Test + void resourceGroupForTenant_mtEnabled_returnsPrefixedTenantId() { + MockAICoreServiceImpl service = createService(true); + String rg = service.resourceGroupForTenant("my-tenant"); + assertThat(rg).isEqualTo("prefix-my-tenant"); + } + + @Test + void resourceGroupForTenant_mtEnabled_cachesResult() { + MockAICoreServiceImpl service = createService(true); + String first = service.resourceGroupForTenant("t1"); + String second = service.resourceGroupForTenant("t1"); + assertThat(first).isSameAs(second); + assertThat(service.getTenantResourceGroupCache()).containsKey("t1"); + } + + @Test + void clearTenantCache_removesCorrectEntries() { + MockAICoreServiceImpl service = createService(true); + service.resourceGroupForTenant("t1"); + service.resourceGroupForTenant("t2"); + var spec = new com.sap.cds.feature.aicore.api.ModelDeploymentSpec( + "scenario", "exec", "cfg1", java.util.List.of(), d -> true); + service.deploymentId("prefix-t1", spec); + + service.clearTenantCache("t1"); + + assertThat(service.getTenantResourceGroupCache()).doesNotContainKey("t1"); + assertThat(service.getTenantResourceGroupCache()).containsKey("t2"); + assertThat(service.getResourceGroupDeploymentCache()).doesNotContainKeys("prefix-t1::cfg1"); + } + + @Test + void getRetry_returnsNonNull() { + MockAICoreServiceImpl service = createService(false); + assertThat(service.getRetry()).isNotNull(); + } + + @Test + void getDefaultResourceGroup_readsFromConfig() { + MockAICoreServiceImpl service = createService(false); + assertThat(service.getDefaultResourceGroup()).isEqualTo("test-rg"); + } + + @Test + void getResourceGroupPrefix_readsFromConfig() { + MockAICoreServiceImpl service = createService(false); + assertThat(service.getResourceGroupPrefix()).isEqualTo("prefix-"); + } +} diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java new file mode 100644 index 0000000..d1171ec --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java @@ -0,0 +1,154 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.BckndResourceGroup; +import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; +import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.services.ServiceException; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for the tenant-scoping guard methods in {@link AbstractCrudHandler}. + * Tests the {@code ensureResourceGroupAccessible} logic which is used by + * ConfigurationHandler and DeploymentHandler. + */ +@ExtendWith(MockitoExtension.class) +class TenantScopingTest { + + @Mock private AICoreServiceImpl service; + @Mock private ResourceGroupApi resourceGroupApi; + + /** Concrete subclass to expose the protected method for testing. */ + private static class TestableHandler extends AbstractCrudHandler { + TestableHandler(AICoreServiceImpl service) { + super(service); + } + + void callEnsureResourceGroupAccessible(String resourceGroupId) { + ensureResourceGroupAccessible(resourceGroupId); + } + } + + private TestableHandler handler; + + @BeforeEach + void setUp() { + handler = new TestableHandler(service); + } + + // ── ensureResourceGroupAccessible ────────────────────────────────────────── + + @Test + void providerUser_allowsAccessToAnyResourceGroup() { + when(service.isProviderUser()).thenReturn(true); + + assertThatCode(() -> handler.callEnsureResourceGroupAccessible("any-rg")) + .doesNotThrowAnyException(); + verify(resourceGroupApi, never()).get("any-rg"); + } + + @Test + void singleTenancy_allowsAccessToAnyResourceGroup() { + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(false); + + assertThatCode(() -> handler.callEnsureResourceGroupAccessible("any-rg")) + .doesNotThrowAnyException(); + verify(resourceGroupApi, never()).get("any-rg"); + } + + @Test + void multiTenancy_nullTenant_allowsAccess() { + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn(null); + + assertThatCode(() -> handler.callEnsureResourceGroupAccessible("any-rg")) + .doesNotThrowAnyException(); + verify(resourceGroupApi, never()).get("any-rg"); + } + + @Test + void multiTenancy_matchingTenantLabel_allowsAccess() { + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("tenant-a"); + when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); + when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); + when(label.getValue()).thenReturn("tenant-a"); + when(rg.getLabels()).thenReturn(List.of(label)); + when(resourceGroupApi.get("rg-for-a")).thenReturn(rg); + + assertThatCode(() -> handler.callEnsureResourceGroupAccessible("rg-for-a")) + .doesNotThrowAnyException(); + } + + @Test + void multiTenancy_nonMatchingTenantLabel_throws404() { + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("tenant-a"); + when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); + when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); + when(label.getValue()).thenReturn("tenant-b"); + when(rg.getLabels()).thenReturn(List.of(label)); + when(resourceGroupApi.get("rg-for-b")).thenReturn(rg); + + assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible("rg-for-b")) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("not found"); + } + + @Test + void multiTenancy_noLabels_throws404() { + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("tenant-a"); + when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getLabels()).thenReturn(null); + when(resourceGroupApi.get("rg-no-labels")).thenReturn(rg); + + assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible("rg-no-labels")) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("not found"); + } + + @Test + void multiTenancy_emptyLabels_throws404() { + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("tenant-a"); + when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getLabels()).thenReturn(List.of()); + when(resourceGroupApi.get("rg-empty-labels")).thenReturn(rg); + + assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible("rg-empty-labels")) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("not found"); + } +} From 751227395232ac2c1d068501e6c124aca95764a2 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 10 Jun 2026 20:38:49 +0200 Subject: [PATCH 09/24] chore(recommendations): add TODO for model-changed integration test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the missing E2E coverage for RecommendationModelChangedHandler. The proper test requires an extensibility-enabled sidecar with extension JSON that adds prediction columns — not yet set up in mtx-local. The cache-invalidation logic itself is covered by the existing unit test FioriRecommendationHandlerTest.invalidateTenant_removesOnlyThatTenantsEntries. --- .../RecommendationModelChangedHandler.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java index edeebd7..5ea3944 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java @@ -9,6 +9,16 @@ import com.sap.cds.services.mt.ExtensibilityService; import com.sap.cds.services.mt.ModelChangedEventContext; +// TODO Integration test needed for the cache-invalidation behaviour. +// The proper E2E pattern (cf. cds-services ExtendViaSidecarTest) requires: +// - extensibility-enabled mtx-local sidecar (/-/cds/extensibility/set) +// - an extension JSON adding a prediction column to a draft-enabled entity +// - per-tenant SQLite schema that survives the model mutation +// - assert that an OData read returns SAP_Recommendations only AFTER the +// extension is applied AND EVENT_MODEL_CHANGED has been emitted. +// The unit test in FioriRecommendationHandlerTest.invalidateTenant_* +// already covers the cache-invalidation logic in isolation; what is missing +// is the wiring + observable-effect assertion through MockMvc. @ServiceName(value = ExtensibilityService.DEFAULT_NAME, type = ExtensibilityService.class) class RecommendationModelChangedHandler implements EventHandler { From b105dde6833d27a9e9cbf3cb8198807fa54a5170 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 11 Jun 2026 10:27:44 +0200 Subject: [PATCH 10/24] update cleanup --- .github/actions/integration-tests/action.yml | 42 +++++++++++++ .github/actions/scan-with-sonar/action.yml | 42 +++++++++++++ .github/workflows/pipeline.yml | 63 -------------------- 3 files changed, 84 insertions(+), 63 deletions(-) diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml index 49a4060..c023e11 100644 --- a/.github/actions/integration-tests/action.yml +++ b/.github/actions/integration-tests/action.yml @@ -34,3 +34,45 @@ runs: run: cds bind --exec -- mvn clean verify -ntp -B -f pom.xml working-directory: integration-tests shell: bash + + - name: Cleanup AI Core test resource groups + if: always() + working-directory: integration-tests + shell: bash + run: | + cds bind --exec -- node -e " + const https = require('https'); + const vcap = JSON.parse(process.env.VCAP_SERVICES || '{}'); + const key = (vcap.aicore || vcap['ai-core'] || [{}])[0].credentials || JSON.parse(process.env.AICORE_SERVICE_KEY || 'null'); + if (!key) { console.log('No AI Core credentials found, skipping cleanup'); process.exit(0); } + const tokenUrl = key.url + '/oauth/token'; + const apiUrl = key.serviceurls.AI_API_URL; + function fetch(url, opts) { + return new Promise((resolve, reject) => { + const u = new URL(url); + const req = https.request({hostname:u.hostname, path:u.pathname+u.search, method:opts.method||'GET', headers:opts.headers||{}}, res => { + let data=''; res.on('data',c=>data+=c); res.on('end',()=>resolve({status:res.statusCode,body:data})); + }); + req.on('error',reject); + if(opts.body) req.write(opts.body); + req.end(); + }); + } + (async () => { + const tokenParams = new URLSearchParams({grant_type:'client_credentials'}); + const authHeader = 'Basic ' + Buffer.from(key.clientid+':'+key.clientsecret).toString('base64'); + const tokenRes = await fetch(tokenUrl+'?'+tokenParams.toString(), {headers:{'Authorization':authHeader}}); + const token = JSON.parse(tokenRes.body).access_token; + const headers = {'Authorization':'Bearer '+token, 'AI-Resource-Group':'default'}; + const runId = '${{ github.run_id }}'; + const prefixes = ['itest-' + runId + '-', 'sonar-' + runId + '-', 'cds-itest-', 'itest-rg-']; + const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers}); + const groups = JSON.parse(rgRes.body).resources || []; + const toDelete = groups.filter(rg => rg.resourceGroupId && prefixes.some(p => rg.resourceGroupId.startsWith(p))); + for (const rg of toDelete) { + const res = await fetch(apiUrl+'/v2/admin/resourceGroups/'+rg.resourceGroupId, {method:'DELETE', headers}); + console.log('Delete', rg.resourceGroupId, '->', res.status); + } + console.log('Cleaned up', toDelete.length, 'resource groups'); + })().catch(e => { console.error(e.message); process.exit(0); }); + " || true diff --git a/.github/actions/scan-with-sonar/action.yml b/.github/actions/scan-with-sonar/action.yml index c01bf24..86c3ded 100644 --- a/.github/actions/scan-with-sonar/action.yml +++ b/.github/actions/scan-with-sonar/action.yml @@ -48,6 +48,48 @@ runs: working-directory: integration-tests shell: bash + - name: Cleanup AI Core test resource groups + if: always() + working-directory: integration-tests + shell: bash + run: | + cds bind --exec -- node -e " + const https = require('https'); + const vcap = JSON.parse(process.env.VCAP_SERVICES || '{}'); + const key = (vcap.aicore || vcap['ai-core'] || [{}])[0].credentials || JSON.parse(process.env.AICORE_SERVICE_KEY || 'null'); + if (!key) { console.log('No AI Core credentials found, skipping cleanup'); process.exit(0); } + const tokenUrl = key.url + '/oauth/token'; + const apiUrl = key.serviceurls.AI_API_URL; + function fetch(url, opts) { + return new Promise((resolve, reject) => { + const u = new URL(url); + const req = https.request({hostname:u.hostname, path:u.pathname+u.search, method:opts.method||'GET', headers:opts.headers||{}}, res => { + let data=''; res.on('data',c=>data+=c); res.on('end',()=>resolve({status:res.statusCode,body:data})); + }); + req.on('error',reject); + if(opts.body) req.write(opts.body); + req.end(); + }); + } + (async () => { + const tokenParams = new URLSearchParams({grant_type:'client_credentials'}); + const authHeader = 'Basic ' + Buffer.from(key.clientid+':'+key.clientsecret).toString('base64'); + const tokenRes = await fetch(tokenUrl+'?'+tokenParams.toString(), {headers:{'Authorization':authHeader}}); + const token = JSON.parse(tokenRes.body).access_token; + const headers = {'Authorization':'Bearer '+token, 'AI-Resource-Group':'default'}; + const runId = '${{ github.run_id }}'; + const prefixes = ['itest-' + runId + '-', 'sonar-' + runId + '-', 'cds-itest-', 'itest-rg-']; + const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers}); + const groups = JSON.parse(rgRes.body).resources || []; + const toDelete = groups.filter(rg => rg.resourceGroupId && prefixes.some(p => rg.resourceGroupId.startsWith(p))); + for (const rg of toDelete) { + const res = await fetch(apiUrl+'/v2/admin/resourceGroups/'+rg.resourceGroupId, {method:'DELETE', headers}); + console.log('Delete', rg.resourceGroupId, '->', res.status); + } + console.log('Cleaned up', toDelete.length, 'resource groups'); + })().catch(e => { console.error(e.message); process.exit(0); }); + " || true + - name: Generate aggregate coverage report run: mvn verify -ntp -B -pl coverage-report -am -DskipTests shell: bash diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 24eb6d7..251b68a 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -65,69 +65,6 @@ jobs: java-version: ${{ matrix.java-version }} maven-version: ${{ env.MAVEN_VERSION }} - integration-tests-cleanup: - name: Cleanup Integration Test Resources - runs-on: ubuntu-latest - timeout-minutes: 10 - needs: [integration-tests, sonarqube-scan] - if: always() - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Bind CF Services - uses: ./.github/actions/cf-bind - with: - cf-api: ${{ secrets.CF_API_AWS }} - cf-username: ${{ secrets.CF_USERNAME }} - cf-password: ${{ secrets.CF_PASSWORD }} - cf-org: ${{ secrets.CF_ORG_AWS }} - cf-space: ${{ secrets.CF_SPACE_AWS }} - - - name: Cleanup resource groups - working-directory: integration-tests - shell: bash - run: | - cds bind --exec -- node -e " - const https = require('https'); - const vcap = JSON.parse(process.env.VCAP_SERVICES || '{}'); - const key = (vcap.aicore || vcap['ai-core'] || [{}])[0].credentials || JSON.parse(process.env.AICORE_SERVICE_KEY || 'null'); - if (!key) { console.log('No AI Core credentials found, skipping cleanup'); process.exit(0); } - const tokenUrl = key.url + '/oauth/token'; - const apiUrl = key.serviceurls.AI_API_URL; - function fetch(url, opts) { - return new Promise((resolve, reject) => { - const u = new URL(url); - const req = https.request({hostname:u.hostname, path:u.pathname+u.search, method:opts.method||'GET', headers:opts.headers||{}}, res => { - let data=''; res.on('data',c=>data+=c); res.on('end',()=>resolve({status:res.statusCode,body:data})); - }); - req.on('error',reject); - if(opts.body) req.write(opts.body); - req.end(); - }); - } - (async () => { - const tokenParams = new URLSearchParams({grant_type:'client_credentials'}); - const authHeader = 'Basic ' + Buffer.from(key.clientid+':'+key.clientsecret).toString('base64'); - const tokenRes = await fetch(tokenUrl+'?'+tokenParams.toString(), {headers:{'Authorization':authHeader}}); - const token = JSON.parse(tokenRes.body).access_token; - const headers = {'Authorization':'Bearer '+token, 'AI-Resource-Group':'default'}; - // Match ALL attempts for this run (not just current attempt) plus cds-itest- RGs - const runId = '${{ github.run_id }}'; - const prefixes = ['itest-' + runId + '-', 'sonar-' + runId + '-', 'cds-itest-']; - const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers}); - const groups = JSON.parse(rgRes.body).resources || []; - const toDelete = groups.filter(rg => rg.resourceGroupId && prefixes.some(p => rg.resourceGroupId.startsWith(p))); - for (const rg of toDelete) { - const res = await fetch(apiUrl+'/v2/admin/resourceGroups/'+rg.resourceGroupId, {method:'DELETE', headers}); - console.log('Delete', rg.resourceGroupId, '->', res.status); - } - console.log('Cleaned up', toDelete.length, 'resource groups for run ' + runId + ' (all attempts)'); - })().catch(e => { console.error(e.message); process.exit(1); }); - " - local-mtx-tests: name: Local MTX Tests (Java ${{ matrix.java-version }}) runs-on: ubuntu-latest From d539f21e5820e56a3e540b0905f50b86b26cec96 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 11 Jun 2026 11:12:39 +0200 Subject: [PATCH 11/24] fix(ci): scope resource group cleanup to own job only Each parallel CI job (Java 17, Java 21, SonarQube) was using broad prefixes in its cleanup step, deleting resource groups belonging to sibling jobs still in progress. This caused intermittent 403 Forbidden errors when the affected jobs tried to use their now-deleted resource groups. Narrow the cleanup prefixes so each job only deletes its own: - integration-tests: itest-{run_id}-{attempt}-j{version}* - scan-with-sonar: sonar-{run_id}-{attempt}* Both still clean up itest-rg-* (ResourceGroupTest leftovers). --- .github/actions/integration-tests/action.yml | 5 ++++- .github/actions/scan-with-sonar/action.yml | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml index c023e11..b4e8415 100644 --- a/.github/actions/integration-tests/action.yml +++ b/.github/actions/integration-tests/action.yml @@ -65,7 +65,10 @@ runs: const token = JSON.parse(tokenRes.body).access_token; const headers = {'Authorization':'Bearer '+token, 'AI-Resource-Group':'default'}; const runId = '${{ github.run_id }}'; - const prefixes = ['itest-' + runId + '-', 'sonar-' + runId + '-', 'cds-itest-', 'itest-rg-']; + const attempt = '${{ github.run_attempt }}'; + const javaVersion = '${{ inputs.java-version }}'; + const ownPrefix = 'itest-' + runId + '-' + attempt + '-j' + javaVersion; + const prefixes = [ownPrefix, 'itest-rg-']; const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers}); const groups = JSON.parse(rgRes.body).resources || []; const toDelete = groups.filter(rg => rg.resourceGroupId && prefixes.some(p => rg.resourceGroupId.startsWith(p))); diff --git a/.github/actions/scan-with-sonar/action.yml b/.github/actions/scan-with-sonar/action.yml index 86c3ded..af84a2c 100644 --- a/.github/actions/scan-with-sonar/action.yml +++ b/.github/actions/scan-with-sonar/action.yml @@ -78,7 +78,9 @@ runs: const token = JSON.parse(tokenRes.body).access_token; const headers = {'Authorization':'Bearer '+token, 'AI-Resource-Group':'default'}; const runId = '${{ github.run_id }}'; - const prefixes = ['itest-' + runId + '-', 'sonar-' + runId + '-', 'cds-itest-', 'itest-rg-']; + const attempt = '${{ github.run_attempt }}'; + const ownPrefix = 'sonar-' + runId + '-' + attempt; + const prefixes = [ownPrefix, 'itest-rg-']; const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers}); const groups = JSON.parse(rgRes.body).resources || []; const toDelete = groups.filter(rg => rg.resourceGroupId && prefixes.some(p => rg.resourceGroupId.startsWith(p))); From cdb396708a5fb7273a87bea06276d88efbe9b7b3 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 11 Jun 2026 11:56:06 +0200 Subject: [PATCH 12/24] test(ai-core): add unit tests for uncovered code paths - DeploymentHandler: test onCreate (with/without TTL) and onUpdate happy path (targetStatus and configurationId branches) - ResourceGroupHandler: test onUpdate with/without labels, buildTenantLabelSelector branches (tenantId filter, MT non-provider, MT null tenant, single tenancy), ensureOwnedByCurrentTenant branches (provider, single tenant, wrong tenant, matching tenant) - AICoreServiceConfiguration: test eventHandlers() MockAICoreServiceImpl branch (with and without multi-tenancy), test detectMultiTenancy via services() for sidecarUrl branch and no-MT fallback --- .../core/AICoreServiceConfigurationTest.java | 177 +++++++++ .../core/handler/DeploymentHandlerTest.java | 133 ++++++- .../handler/ResourceGroupHandlerTest.java | 345 +++++++++++++++++- 3 files changed, 647 insertions(+), 8 deletions(-) create mode 100644 cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java 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 new file mode 100644 index 0000000..d2feaaa --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java @@ -0,0 +1,177 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +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 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.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) +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); + } + + /** + * 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(); + 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); + + AICoreServiceConfiguration config = new AICoreServiceConfiguration(); + config.services(configurer); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(MockAICoreServiceImpl.class); + verify(configurer).service(captor.capture()); + assert captor.getValue().isMultiTenancyEnabled(); + } + + /** + * 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); + + AICoreServiceConfiguration config = new AICoreServiceConfiguration(); + config.services(configurer); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(MockAICoreServiceImpl.class); + verify(configurer).service(captor.capture()); + assert !captor.getValue().isMultiTenancyEnabled(); + } +} diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java index fb4211b..cc26a3c 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java @@ -5,21 +5,38 @@ 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; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.model.AiDeploymentCreationRequest; +import com.sap.ai.sdk.core.model.AiDeploymentCreationResponse; +import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; +import com.sap.ai.sdk.core.model.AiExecutionStatus; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; +import com.sap.cds.ql.cqn.AnalysisResult; +import com.sap.cds.ql.cqn.CqnAnalyzer; +import com.sap.cds.ql.cqn.CqnUpdate; +import com.sap.cds.reflect.CdsModel; import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.ServiceException; +import com.sap.cds.services.cds.CdsCreateEventContext; import com.sap.cds.services.cds.CdsUpdateEventContext; +import java.util.HashMap; import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) @@ -27,7 +44,8 @@ class DeploymentHandlerTest { @Mock private AICoreServiceImpl service; @Mock private DeploymentApi deploymentApi; - @Mock private CdsUpdateEventContext context; + @Mock private CdsUpdateEventContext updateContext; + @Mock private CdsCreateEventContext createContext; private DeploymentHandler cut; @@ -41,7 +59,7 @@ void setup() { void onUpdate_emptyEntries_throwsBadRequest() { List entries = List.of(); - assertThatThrownBy(() -> cut.onUpdate(context, entries)) + assertThatThrownBy(() -> cut.onUpdate(updateContext, entries)) .isInstanceOfSatisfying( ServiceException.class, e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) @@ -54,7 +72,7 @@ void onUpdate_emptyEntries_throwsBadRequest() { void onUpdate_payloadWithoutTargetStatusOrConfigurationId_throwsBadRequest() { List entries = List.of(Deployments.of(Map.of("ttl", "1d"))); - assertThatThrownBy(() -> cut.onUpdate(context, entries)) + assertThatThrownBy(() -> cut.onUpdate(updateContext, entries)) .isInstanceOfSatisfying( ServiceException.class, e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) @@ -63,4 +81,113 @@ void onUpdate_payloadWithoutTargetStatusOrConfigurationId_throwsBadRequest() { verifyNoInteractions(deploymentApi); } + + @Test + void onUpdate_withTargetStatus_callsModifyWithTargetStatus() { + Deployments data = Deployments.create(); + data.setTargetStatus("STOPPED"); + List entries = List.of(data); + + CqnUpdate cqnUpdate = mock(CqnUpdate.class); + CdsModel model = mock(CdsModel.class); + CqnAnalyzer analyzer = mock(CqnAnalyzer.class); + AnalysisResult analysisResult = mock(AnalysisResult.class); + + when(updateContext.getCqn()).thenReturn(cqnUpdate); + when(updateContext.getModel()).thenReturn(model); + Map keys = new HashMap<>(); + keys.put(Deployments.ID, "dep-123"); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); + when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-1"); + when(service.isProviderUser()).thenReturn(true); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + cut.onUpdate(updateContext, entries); + } + + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentModificationRequest.class); + verify(deploymentApi).modify(eq("rg-1"), eq("dep-123"), captor.capture()); + assertThat(captor.getValue().getTargetStatus().getValue()).isEqualTo("STOPPED"); + } + + @Test + void onUpdate_withConfigurationId_callsModifyWithConfigurationId() { + Deployments data = Deployments.create(); + data.setConfigurationId("config-456"); + List entries = List.of(data); + + CqnUpdate cqnUpdate = mock(CqnUpdate.class); + CdsModel model = mock(CdsModel.class); + CqnAnalyzer analyzer = mock(CqnAnalyzer.class); + AnalysisResult analysisResult = mock(AnalysisResult.class); + + when(updateContext.getCqn()).thenReturn(cqnUpdate); + when(updateContext.getModel()).thenReturn(model); + Map keys = new HashMap<>(); + keys.put(Deployments.ID, "dep-789"); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); + when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-2"); + when(service.isProviderUser()).thenReturn(true); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + cut.onUpdate(updateContext, entries); + } + + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentModificationRequest.class); + verify(deploymentApi).modify(eq("rg-2"), eq("dep-789"), captor.capture()); + assertThat(captor.getValue().getConfigurationId()).isEqualTo("config-456"); + } + + @Test + void onCreate_createsDeploymentWithConfigurationId() { + Deployments entry = Deployments.create(); + entry.setConfigurationId("cfg-1"); + entry.put(Deployments.RESOURCE_GROUP, Map.of("resourceGroupId", "rg-test")); + List entries = List.of(entry); + + AiDeploymentCreationResponse response = mock(AiDeploymentCreationResponse.class); + when(response.getId()).thenReturn("new-dep-id"); + when(response.getStatus()).thenReturn(AiExecutionStatus.UNKNOWN); + when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-test"); + when(service.isProviderUser()).thenReturn(true); + when(deploymentApi.create(eq("rg-test"), any(AiDeploymentCreationRequest.class))) + .thenReturn(response); + + cut.onCreate(createContext, entries); + + verify(createContext).setResult(any(List.class)); + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentCreationRequest.class); + verify(deploymentApi).create(eq("rg-test"), captor.capture()); + assertThat(captor.getValue().getConfigurationId()).isEqualTo("cfg-1"); + } + + @Test + void onCreate_withTtl_setsTtlOnRequest() { + Deployments entry = Deployments.create(); + entry.setConfigurationId("cfg-2"); + entry.setTtl("PT24H"); + List entries = List.of(entry); + + AiDeploymentCreationResponse response = mock(AiDeploymentCreationResponse.class); + when(response.getId()).thenReturn("dep-ttl"); + when(response.getStatus()).thenReturn(AiExecutionStatus.UNKNOWN); + when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-default"); + when(service.isProviderUser()).thenReturn(true); + when(deploymentApi.create(eq("rg-default"), any(AiDeploymentCreationRequest.class))) + .thenReturn(response); + + cut.onCreate(createContext, entries); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentCreationRequest.class); + verify(deploymentApi).create(eq("rg-default"), captor.capture()); + assertThat(captor.getValue().getTtl()).isEqualTo("PT24H"); + } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java index 2debb0d..14a828b 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java @@ -4,23 +4,44 @@ package com.sap.cds.feature.aicore.core.handler; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; +import com.sap.ai.sdk.core.model.BckndResourceGroupList; +import com.sap.ai.sdk.core.model.BckndResourceGroupPatchRequest; import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; +import com.sap.cds.ql.cqn.AnalysisResult; +import com.sap.cds.ql.cqn.CqnAnalyzer; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.ql.cqn.CqnUpdate; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.services.ServiceException; import com.sap.cds.services.cds.CdsCreateEventContext; +import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.cds.CdsUpdateEventContext; +import java.util.HashMap; import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) @@ -28,7 +49,7 @@ class ResourceGroupHandlerTest { @Mock private AICoreServiceImpl service; @Mock private ResourceGroupApi resourceGroupApi; - @Mock private CdsCreateEventContext context; + @Mock private CdsCreateEventContext createContext; private ResourceGroupHandler handler; @@ -43,7 +64,7 @@ void onCreate_withTenantIdOnly_setsOnlyTenantLabel() { Map entry = Map.of("resourceGroupId", "rg-1", "tenantId", "tenant-a"); List entries = List.of(ResourceGroups.of(entry)); - handler.onCreate(context, entries); + handler.onCreate(createContext, entries); BckndResourceGroupsPostRequest request = captureCreateRequest(); assertThat(request.getResourceGroupId()).isEqualTo("rg-1"); @@ -62,7 +83,7 @@ void onCreate_withLabelsOnly_setsOnlyUserLabels() { List.of(Map.of("key", "env", "value", "prod"), Map.of("key", "team", "value", "ai"))); List entries = List.of(ResourceGroups.of(entry)); - handler.onCreate(context, entries); + handler.onCreate(createContext, entries); BckndResourceGroupsPostRequest request = captureCreateRequest(); assertThat(request.getResourceGroupId()).isEqualTo("rg-2"); @@ -83,7 +104,7 @@ void onCreate_withTenantIdAndLabels_keepsTenantLabelAndUserLabels() { List.of(Map.of("key", "env", "value", "prod"))); List entries = List.of(ResourceGroups.of(entry)); - handler.onCreate(context, entries); + handler.onCreate(createContext, entries); BckndResourceGroupsPostRequest request = captureCreateRequest(); // Tenant label first, then user-supplied labels — and tenant label is NOT lost. @@ -105,7 +126,7 @@ void onCreate_userSuppliedTenantLabelTakesPrecedence() { List.of(Map.of("key", AICoreServiceImpl.TENANT_LABEL_KEY, "value", "tenant-user"))); List entries = List.of(ResourceGroups.of(entry)); - handler.onCreate(context, entries); + handler.onCreate(createContext, entries); BckndResourceGroupsPostRequest request = captureCreateRequest(); assertThat(request.getLabels()) @@ -113,6 +134,320 @@ void onCreate_userSuppliedTenantLabelTakesPrecedence() { .containsExactly(tuple(AICoreServiceImpl.TENANT_LABEL_KEY, "tenant-user")); } + @Nested + @ExtendWith(MockitoExtension.class) + class OnUpdateTests { + + @Mock private CdsUpdateEventContext updateContext; + @Mock private CqnUpdate cqnUpdate; + @Mock private CdsModel model; + @Mock private CqnAnalyzer analyzer; + @Mock private AnalysisResult analysisResult; + + @Test + void onUpdate_withLabels_callsPatchWithLabels() { + Map keys = new HashMap<>(); + keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-upd"); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); + when(updateContext.getCqn()).thenReturn(cqnUpdate); + when(updateContext.getModel()).thenReturn(model); + + Map data = new HashMap<>(); + data.put(ResourceGroups.LABELS, List.of(Map.of("key", "env", "value", "staging"))); + when(cqnUpdate.entries()).thenReturn(List.of(data)); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); + when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); + when(label.getValue()).thenReturn("my-tenant"); + when(rg.getLabels()).thenReturn(List.of(label)); + when(resourceGroupApi.get("rg-upd")).thenReturn(rg); + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("my-tenant"); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + handler.onUpdate(updateContext); + } + + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupPatchRequest.class); + verify(resourceGroupApi).patch(eq("rg-upd"), captor.capture()); + assertThat(captor.getValue().getLabels()) + .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) + .containsExactly(tuple("env", "staging")); + } + + @Test + void onUpdate_withoutLabels_callsPatchWithoutLabels() { + Map keys = new HashMap<>(); + keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-nolabel"); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); + when(updateContext.getCqn()).thenReturn(cqnUpdate); + when(updateContext.getModel()).thenReturn(model); + + Map data = new HashMap<>(); + // no labels in update payload + when(cqnUpdate.entries()).thenReturn(List.of(data)); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(resourceGroupApi.get("rg-nolabel")).thenReturn(rg); + when(service.isProviderUser()).thenReturn(true); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + handler.onUpdate(updateContext); + } + + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupPatchRequest.class); + verify(resourceGroupApi).patch(eq("rg-nolabel"), captor.capture()); + assertThat(captor.getValue().getLabels()).isNullOrEmpty(); + } + } + + @Nested + @ExtendWith(MockitoExtension.class) + class BuildTenantLabelSelectorTests { + + @Mock private CdsReadEventContext readContext; + @Mock private CqnSelect cqnSelect; + @Mock private CdsModel model; + @Mock private CqnAnalyzer analyzer; + @Mock private AnalysisResult analysisResult; + + @Test + void readAll_withTenantIdFilter_usesLabelSelector() { + Map keys = new HashMap<>(); + Map values = new HashMap<>(); + values.put(ResourceGroups.TENANT_ID, "tenant-x"); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + + BckndResourceGroupList result = mock(BckndResourceGroupList.class); + when(result.getResources()).thenReturn(List.of()); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(result); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + handler.onRead(readContext); + } + + @SuppressWarnings("unchecked") + ArgumentCaptor> selectorCaptor = ArgumentCaptor.forClass(List.class); + verify(resourceGroupApi) + .getAll(any(), any(), any(), any(), any(), any(), selectorCaptor.capture()); + assertThat(selectorCaptor.getValue()) + .containsExactly(AICoreServiceImpl.TENANT_LABEL_KEY + "=tenant-x"); + } + + @Test + void readAll_multiTenancy_nonProviderUser_restrictsByCurrentTenant() { + Map keys = new HashMap<>(); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.isProviderUser()).thenReturn(false); + when(service.currentTenantId()).thenReturn("current-tenant"); + + BckndResourceGroupList result = mock(BckndResourceGroupList.class); + when(result.getResources()).thenReturn(List.of()); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(result); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + handler.onRead(readContext); + } + + @SuppressWarnings("unchecked") + ArgumentCaptor> selectorCaptor = ArgumentCaptor.forClass(List.class); + verify(resourceGroupApi) + .getAll(any(), any(), any(), any(), any(), any(), selectorCaptor.capture()); + assertThat(selectorCaptor.getValue()) + .containsExactly(AICoreServiceImpl.TENANT_LABEL_KEY + "=current-tenant"); + } + + @Test + void readAll_multiTenancy_nullTenant_noLabelSelector() { + Map keys = new HashMap<>(); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.isProviderUser()).thenReturn(false); + when(service.currentTenantId()).thenReturn(null); + + BckndResourceGroupList result = mock(BckndResourceGroupList.class); + when(result.getResources()).thenReturn(List.of()); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(result); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + handler.onRead(readContext); + } + + verify(resourceGroupApi).getAll(any(), any(), any(), any(), any(), any(), eq(null)); + } + + @Test + void readAll_singleTenancy_noLabelSelector() { + Map keys = new HashMap<>(); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isMultiTenancyEnabled()).thenReturn(false); + + BckndResourceGroupList result = mock(BckndResourceGroupList.class); + when(result.getResources()).thenReturn(List.of()); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(result); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + handler.onRead(readContext); + } + + verify(resourceGroupApi).getAll(any(), any(), any(), any(), any(), any(), eq(null)); + } + } + + @Nested + @ExtendWith(MockitoExtension.class) + class EnsureOwnedByCurrentTenantTests { + + @Mock private CdsReadEventContext readContext; + @Mock private CqnSelect cqnSelect; + @Mock private CdsModel model; + @Mock private CqnAnalyzer analyzer; + @Mock private AnalysisResult analysisResult; + + @Test + void readById_providerUser_allowsAccessToAnyRg() { + Map keys = new HashMap<>(); + keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-any"); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isProviderUser()).thenReturn(true); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getResourceGroupId()).thenReturn("rg-any"); + when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); + when(rg.getLabels()).thenReturn(null); + when(resourceGroupApi.get("rg-any")).thenReturn(rg); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + assertThatCode(() -> handler.onRead(readContext)).doesNotThrowAnyException(); + } + } + + @Test + void readById_singleTenancy_allowsAccess() { + Map keys = new HashMap<>(); + keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-single"); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(false); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getResourceGroupId()).thenReturn("rg-single"); + when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); + when(rg.getLabels()).thenReturn(null); + when(resourceGroupApi.get("rg-single")).thenReturn(rg); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + assertThatCode(() -> handler.onRead(readContext)).doesNotThrowAnyException(); + } + } + + @Test + void readById_multiTenancy_wrongTenant_throws404() { + Map keys = new HashMap<>(); + keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-other"); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("tenant-a"); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); + when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); + when(label.getValue()).thenReturn("tenant-b"); + when(rg.getLabels()).thenReturn(List.of(label)); + when(resourceGroupApi.get("rg-other")).thenReturn(rg); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + assertThatThrownBy(() -> handler.onRead(readContext)) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("not found"); + } + } + + @Test + void readById_multiTenancy_matchingTenant_allowsAccess() { + Map keys = new HashMap<>(); + keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-mine"); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("tenant-a"); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); + when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); + when(label.getValue()).thenReturn("tenant-a"); + when(rg.getLabels()).thenReturn(List.of(label)); + when(rg.getResourceGroupId()).thenReturn("rg-mine"); + when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); + when(resourceGroupApi.get("rg-mine")).thenReturn(rg); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + assertThatCode(() -> handler.onRead(readContext)).doesNotThrowAnyException(); + } + } + } + private BckndResourceGroupsPostRequest captureCreateRequest() { ArgumentCaptor captor = ArgumentCaptor.forClass(BckndResourceGroupsPostRequest.class); From 6657df851691ff8fc5334ee17b9cb1b6fa0cbb52 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 11 Jun 2026 12:13:43 +0200 Subject: [PATCH 13/24] fix(ci): include cds-itest- prefix in resource group cleanup The MultiTenancyTest creates per-tenant resource groups with names like cds-itest-mt-a-{timestamp} (from resourceGroupPrefix 'cds-' + tenant name 'itest-*'). These are unique per test run (timestamped) and safe to clean up from any job without cross-job interference. --- .github/actions/integration-tests/action.yml | 2 +- .github/actions/scan-with-sonar/action.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml index b4e8415..0bc0bf7 100644 --- a/.github/actions/integration-tests/action.yml +++ b/.github/actions/integration-tests/action.yml @@ -68,7 +68,7 @@ runs: const attempt = '${{ github.run_attempt }}'; const javaVersion = '${{ inputs.java-version }}'; const ownPrefix = 'itest-' + runId + '-' + attempt + '-j' + javaVersion; - const prefixes = [ownPrefix, 'itest-rg-']; + const prefixes = [ownPrefix, 'itest-rg-', 'cds-itest-']; const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers}); const groups = JSON.parse(rgRes.body).resources || []; const toDelete = groups.filter(rg => rg.resourceGroupId && prefixes.some(p => rg.resourceGroupId.startsWith(p))); diff --git a/.github/actions/scan-with-sonar/action.yml b/.github/actions/scan-with-sonar/action.yml index af84a2c..fcc37bd 100644 --- a/.github/actions/scan-with-sonar/action.yml +++ b/.github/actions/scan-with-sonar/action.yml @@ -80,7 +80,7 @@ runs: const runId = '${{ github.run_id }}'; const attempt = '${{ github.run_attempt }}'; const ownPrefix = 'sonar-' + runId + '-' + attempt; - const prefixes = [ownPrefix, 'itest-rg-']; + const prefixes = [ownPrefix, 'itest-rg-', 'cds-itest-']; const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers}); const groups = JSON.parse(rgRes.body).resources || []; const toDelete = groups.filter(rg => rg.resourceGroupId && prefixes.some(p => rg.resourceGroupId.startsWith(p))); From 6e28a9d4d4be92c6599f2ef14cb9597e1f400f63 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 11 Jun 2026 15:41:33 +0200 Subject: [PATCH 14/24] refactor(ai-core): migrate AICoreService from CqnService to RemoteService --- .../com/sap/cds/feature/aicore/api/AICoreService.java | 6 +++--- .../cds/feature/aicore/core/AbstractAICoreService.java | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) 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/AbstractAICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java index 0e7ec79..14c921f 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,6 +4,7 @@ package com.sap.cds.feature.aicore.core; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.reflect.CdsService; import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.request.UserInfo; import com.sap.cds.services.runtime.CdsRuntime; @@ -22,6 +23,14 @@ protected AbstractAICoreService(String name, CdsRuntime runtime) { super(name, runtime); } + /** The qualified CDS service definition name used for model lookups. */ + private static final String CDS_DEFINITION_NAME = "AICore"; + + @Override + public CdsService getDefinition() { + return RequestContext.getCurrent(runtime).getModel().getService(CDS_DEFINITION_NAME); + } + /** Returns the {@link CdsRuntime} that this service was created with. */ public CdsRuntime getRuntime() { return runtime; From 42bcdf5b7de2134b0a5d34879876cfa61f35e276 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 11 Jun 2026 15:43:01 +0200 Subject: [PATCH 15/24] refactor(ai-core): delete AICoreApplicationServiceHandler --- .../core/AICoreServiceConfiguration.java | 3 - .../AICoreApplicationServiceHandler.java | 103 ------------------ .../core/AICoreServiceConfigurationTest.java | 3 - .../ApplicationServiceDelegationTest.java | 41 ------- integration-tests/spring/test-service.cds | 6 +- 5 files changed, 1 insertion(+), 155 deletions(-) delete mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApplicationServiceHandler.java delete mode 100644 integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ApplicationServiceDelegationTest.java 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..b7ec08a 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; @@ -106,7 +105,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 +113,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/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..def38af 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 @@ -11,7 +11,6 @@ import static org.mockito.Mockito.when; 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; @@ -63,7 +62,6 @@ void eventHandlers_mockService_noMultiTenancy_registersBasicHandlers() { 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); } @@ -97,7 +95,6 @@ void eventHandlers_mockService_withMultiTenancy_registersSetupHandler() { 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); } 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..520066d 100644 --- a/integration-tests/spring/test-service.cds +++ b/integration-tests/spring/test-service.cds @@ -1,11 +1,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') { From fa28cda69da7de87c489849919241ec4ed5574a7 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 11 Jun 2026 15:48:50 +0200 Subject: [PATCH 16/24] fix(ai-core): guard service registration on AICore model presence --- .../feature/aicore/core/AICoreServiceConfiguration.java | 9 +++++++++ .../cds/feature/aicore/core/AbstractAICoreService.java | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) 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 b7ec08a..dd2d5df 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 @@ -36,6 +36,10 @@ 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 @@ -69,6 +73,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); 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 14c921f..9471c52 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 @@ -28,7 +28,7 @@ protected AbstractAICoreService(String name, CdsRuntime runtime) { @Override public CdsService getDefinition() { - return RequestContext.getCurrent(runtime).getModel().getService(CDS_DEFINITION_NAME); + return runtime.getCdsModel().findService(CDS_DEFINITION_NAME).orElse(null); } /** Returns the {@link CdsRuntime} that this service was created with. */ From ae852881076e2d7f122cddea7622e48b3cf2c6db Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 11 Jun 2026 16:00:45 +0200 Subject: [PATCH 17/24] fix(test): mock CdsModel in unit tests and restore AICore model import --- .../aicore/core/AICoreServiceConfigurationTest.java | 10 ++++++++++ integration-tests/spring/test-service.cds | 1 + 2 files changed, 11 insertions(+) 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 def38af..cf3fb75 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 @@ -7,17 +7,21 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.handler.MockEntityHandler; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.reflect.CdsService; 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.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import java.util.Optional; import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -110,6 +114,9 @@ void services_noBinding_sidecarUrlSet_createsMultiTenantMockService() { envKey == null || envKey.isBlank(), "Skipped: AICORE_SERVICE_KEY is set"); when(configurer.getCdsRuntime()).thenReturn(runtime); when(runtime.getEnvironment()).thenReturn(environment); + CdsModel cdsModel = mock(CdsModel.class); + when(runtime.getCdsModel()).thenReturn(cdsModel); + when(cdsModel.findService("AICore")).thenReturn(Optional.of(mock(CdsService.class))); when(environment.getServiceBindings()).thenReturn(Stream.empty()); lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) .thenAnswer(invocation -> invocation.getArgument(2)); @@ -146,6 +153,9 @@ void services_noBinding_noSidecarUrl_noDeploymentService_singleTenant() { when(configurer.getCdsRuntime()).thenReturn(runtime); when(runtime.getEnvironment()).thenReturn(environment); when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); + CdsModel cdsModel = mock(CdsModel.class); + when(runtime.getCdsModel()).thenReturn(cdsModel); + when(cdsModel.findService("AICore")).thenReturn(Optional.of(mock(CdsService.class))); when(environment.getServiceBindings()).thenReturn(Stream.empty()); lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) .thenAnswer(invocation -> invocation.getArgument(2)); diff --git a/integration-tests/spring/test-service.cds b/integration-tests/spring/test-service.cds index 520066d..232f698 100644 --- a/integration-tests/spring/test-service.cds +++ b/integration-tests/spring/test-service.cds @@ -1,4 +1,5 @@ using {itest} from '../db/schema'; +using { AICore } from 'com.sap.cds/ai'; service TestService { entity Products as projection on itest.Products; From 62ba1d7bddc88ed8453e3afccec3e3a765baeae0 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 11 Jun 2026 16:51:11 +0200 Subject: [PATCH 18/24] fix(ai-core): extend AbstractCdsDefinedService for proper RemoteService support --- cds-feature-ai-core/pom.xml | 1 - .../aicore/core/AbstractAICoreService.java | 17 ++++++----------- .../core/AICoreServiceConfigurationTest.java | 8 ++++++++ .../core/AICoreServiceImplDeploymentIdTest.java | 8 ++++++++ .../aicore/core/MockAICoreServiceImplTest.java | 8 ++++++++ 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/cds-feature-ai-core/pom.xml b/cds-feature-ai-core/pom.xml index c49b701..26247a1 100644 --- a/cds-feature-ai-core/pom.xml +++ b/cds-feature-ai-core/pom.xml @@ -44,7 +44,6 @@ com.sap.cds cds-services-impl - test 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 9471c52..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,11 +4,10 @@ package com.sap.cds.feature.aicore.core; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.reflect.CdsService; +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; @@ -17,18 +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 { - protected AbstractAICoreService(String name, CdsRuntime runtime) { - super(name, runtime); - } - - /** The qualified CDS service definition name used for model lookups. */ + /** The qualified CDS service definition name. */ private static final String CDS_DEFINITION_NAME = "AICore"; - @Override - public CdsService getDefinition() { - return runtime.getCdsModel().findService(CDS_DEFINITION_NAME).orElse(null); + protected AbstractAICoreService(String name, CdsRuntime 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/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 cf3fb75..074a2f8 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 @@ -46,6 +46,9 @@ void eventHandlers_mockService_noMultiTenancy_registersBasicHandlers() { when(configurer.getCdsRuntime()).thenReturn(runtime); when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); when(runtime.getEnvironment()).thenReturn(environment); + CdsModel cdsModel = mock(CdsModel.class); + when(runtime.getCdsModel()).thenReturn(cdsModel); + when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); 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())) @@ -79,6 +82,9 @@ void eventHandlers_mockService_withMultiTenancy_registersSetupHandler() { when(configurer.getCdsRuntime()).thenReturn(runtime); when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); when(runtime.getEnvironment()).thenReturn(environment); + CdsModel cdsModel = mock(CdsModel.class); + when(runtime.getCdsModel()).thenReturn(cdsModel); + when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); 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())) @@ -117,6 +123,7 @@ void services_noBinding_sidecarUrlSet_createsMultiTenantMockService() { CdsModel cdsModel = mock(CdsModel.class); when(runtime.getCdsModel()).thenReturn(cdsModel); when(cdsModel.findService("AICore")).thenReturn(Optional.of(mock(CdsService.class))); + when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); when(environment.getServiceBindings()).thenReturn(Stream.empty()); lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) .thenAnswer(invocation -> invocation.getArgument(2)); @@ -156,6 +163,7 @@ void services_noBinding_noSidecarUrl_noDeploymentService_singleTenant() { CdsModel cdsModel = mock(CdsModel.class); when(runtime.getCdsModel()).thenReturn(cdsModel); when(cdsModel.findService("AICore")).thenReturn(Optional.of(mock(CdsService.class))); + when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); when(environment.getServiceBindings()).thenReturn(Stream.empty()); lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) .thenAnswer(invocation -> invocation.getArgument(2)); 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..be6e5de 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 @@ -24,6 +24,8 @@ 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.reflect.CdsModel; +import com.sap.cds.reflect.CdsService; import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; @@ -67,6 +69,9 @@ void setUp() { 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)); // Use small retry counts so failures don't slow tests. when(env.getProperty(eq("cds.ai.core.maxRetries"), eq(Integer.class), any())) .thenReturn(1); @@ -198,6 +203,9 @@ void resourceGroupForTenant_nullTenantId_returnsDefault() { CdsRuntime rtMt = mock(CdsRuntime.class); CdsEnvironment envMt = mock(CdsEnvironment.class); when(rtMt.getEnvironment()).thenReturn(envMt); + CdsModel cdsModelMt = mock(CdsModel.class); + when(rtMt.getCdsModel()).thenReturn(cdsModelMt); + when(cdsModelMt.getService("AICore")).thenReturn(mock(CdsService.class)); 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())) 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())) From 96452bd7645d1556d22cd5e562b1e83e552637a8 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 11 Jun 2026 17:44:35 +0200 Subject: [PATCH 19/24] fix(recommendations): promote cds-services-impl to compile scope --- cds-feature-recommendations/pom.xml | 1 - 1 file changed, 1 deletion(-) 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 From 2f60ca8ea1f8d7716ee6bc2c5f3a4093173a2059 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 12 Jun 2026 08:54:08 +0200 Subject: [PATCH 20/24] refactor(test): use real CdsRuntime in AICoreServiceConfigurationTest --- cds-feature-ai-core/pom.xml | 8 + .../core/AICoreServiceConfigurationTest.java | 214 +++++------------- 2 files changed, 63 insertions(+), 159 deletions(-) diff --git a/cds-feature-ai-core/pom.xml b/cds-feature-ai-core/pom.xml index 26247a1..ac235b8 100644 --- a/cds-feature-ai-core/pom.xml +++ b/cds-feature-ai-core/pom.xml @@ -49,6 +49,14 @@ ${project.artifactId} + + + src/test/resources + + + src/gen/srv/src/main/resources + + com.sap.cds 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 074a2f8..7dd772c 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,190 +3,86 @@ */ 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.mock; -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.MockEntityHandler; -import com.sap.cds.reflect.CdsModel; -import com.sap.cds.reflect.CdsService; -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.Optional; -import java.util.stream.Stream; +import org.junit.jupiter.api.Assumptions; 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. + * + *

Tests that exercise service registration are skipped when {@code AICORE_SERVICE_KEY} is set, + * because the configuration would then register the real {@link AICoreServiceImpl} (which requires + * actual AI Core credentials). + */ 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); - CdsModel cdsModel = mock(CdsModel.class); - when(runtime.getCdsModel()).thenReturn(cdsModel); - when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); - 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); - // No setup handler registered when MT is disabled - assert handlers.stream().noneMatch(h -> h instanceof MockAICoreSetupHandler); + private static void assumeNoAICoreBinding() { + String envKey = System.getenv("AICORE_SERVICE_KEY"); + Assumptions.assumeTrue(envKey == null || envKey.isBlank(), "Skipped: AICORE_SERVICE_KEY is set"); } - /** - * 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); - CdsModel cdsModel = mock(CdsModel.class); - when(runtime.getCdsModel()).thenReturn(cdsModel); - when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); - 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 MockAICoreSetupHandler); + void noBinding_noMultiTenancy_registersMockService() { + assumeNoAICoreBinding(); + + 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 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); - CdsModel cdsModel = mock(CdsModel.class); - when(runtime.getCdsModel()).thenReturn(cdsModel); - when(cdsModel.findService("AICore")).thenReturn(Optional.of(mock(CdsService.class))); - when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); - 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() { + assumeNoAICoreBinding(); + + 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); - AICoreServiceConfiguration config = new AICoreServiceConfiguration(); - config.services(configurer); + CdsRuntime runtime = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)) + .cdsModel("edmx/csn.json") + .serviceConfigurations() + .eventHandlerConfigurations() + .complete(); - ArgumentCaptor captor = - ArgumentCaptor.forClass(MockAICoreServiceImpl.class); - verify(configurer).service(captor.capture()); - assert captor.getValue().isMultiTenancyEnabled(); + AICoreService service = + runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + + 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); - CdsModel cdsModel = mock(CdsModel.class); - when(runtime.getCdsModel()).thenReturn(cdsModel); - when(cdsModel.findService("AICore")).thenReturn(Optional.of(mock(CdsService.class))); - when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); - 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(); } } From 57d0d72e49e53f5517190e069d3ac9cee492325f Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 12 Jun 2026 09:00:04 +0200 Subject: [PATCH 21/24] refactor(ai-core): remove AICORE_SERVICE_KEY env var check from binding detection --- .../core/AICoreServiceConfiguration.java | 18 ++++++------------ .../core/AICoreServiceConfigurationTest.java | 15 ++------------- 2 files changed, 8 insertions(+), 25 deletions(-) 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 dd2d5df..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 @@ -41,18 +41,12 @@ private static boolean hasAICoreModel(CdsRuntime runtime) { } 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(); } /** 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 7dd772c..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 @@ -10,7 +10,6 @@ import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; -import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test; /** @@ -18,21 +17,13 @@ * CDS model. This verifies the full service registration and handler wiring lifecycle without heavy * Mockito mocks. * - *

Tests that exercise service registration are skipped when {@code AICORE_SERVICE_KEY} is set, - * because the configuration would then register the real {@link AICoreServiceImpl} (which requires - * actual AI Core credentials). + *

Since the test runtime has no service bindings, the configuration always registers a {@link + * MockAICoreServiceImpl} regardless of environment variables. */ class AICoreServiceConfigurationTest { - private static void assumeNoAICoreBinding() { - String envKey = System.getenv("AICORE_SERVICE_KEY"); - Assumptions.assumeTrue(envKey == null || envKey.isBlank(), "Skipped: AICORE_SERVICE_KEY is set"); - } - @Test void noBinding_noMultiTenancy_registersMockService() { - assumeNoAICoreBinding(); - CdsRuntime runtime = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())) .cdsModel("edmx/csn.json") @@ -49,8 +40,6 @@ void noBinding_noMultiTenancy_registersMockService() { @Test void noBinding_withSidecarUrl_registersMultiTenantMockService() { - assumeNoAICoreBinding(); - CdsProperties props = new CdsProperties(); CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar(); From cdd70d5073ff8d28b73a10df86c95fcfe6ace5fd Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 12 Jun 2026 09:04:51 +0200 Subject: [PATCH 22/24] chore: exclude Mock* classes from SonarQube coverage --- pom.xml | 3 +++ 1 file changed, 3 insertions(+) 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 From c50ced33ce3d9776bba97e2f5a1f27b7cd0c8ff8 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 12 Jun 2026 09:08:07 +0200 Subject: [PATCH 23/24] refactor(test): use real CdsRuntime in AICoreServiceImplDeploymentIdTest --- .../AICoreServiceImplDeploymentIdTest.java | 94 ++++++++++--------- 1 file changed, 48 insertions(+), 46 deletions(-) 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 be6e5de..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,12 +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.reflect.CdsModel; -import com.sap.cds.reflect.CdsService; -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; @@ -54,33 +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); - CdsModel cdsModel = mock(CdsModel.class); - when(runtime.getCdsModel()).thenReturn(cdsModel); - when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); - // 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( @@ -90,7 +88,7 @@ void setUp() { deploymentApi, configurationApi, resourceGroupApi, - sdkService); + mock(AiCoreService.class)); } @Test @@ -136,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"); @@ -191,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); @@ -199,24 +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); - CdsModel cdsModelMt = mock(CdsModel.class); - when(rtMt.getCdsModel()).thenReturn(cdsModelMt); - when(cdsModelMt.getService("AICore")).thenReturn(mock(CdsService.class)); - 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, @@ -224,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"); @@ -266,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; + } + } } From 70724c5c40ef2bb6ee9579ec59807d3ef3cd0f3b Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 12 Jun 2026 12:48:09 +0200 Subject: [PATCH 24/24] fix: remove duplicate detectMultiTenancy method from merge --- .../aicore/core/AICoreServiceConfiguration.java | 14 -------------- 1 file changed, 14 deletions(-) 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 4c1d7b2..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 @@ -63,20 +63,6 @@ private static boolean detectMultiTenancy(CdsRuntime runtime) { return runtime.getServiceCatalog().getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) != null; } - /** - * Detects multi-tenancy by checking the standard CAP Java {@code cds.multiTenancy.sidecar.url} - * property or the presence of a {@link DeploymentService} in the service catalog. This aligns - * with the standard CAP Java convention — no custom property flag is needed. - */ - private static boolean detectMultiTenancy(CdsRuntime runtime) { - CdsProperties props = runtime.getEnvironment().getCdsProperties(); - String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl(); - if (sidecarUrl != null && !sidecarUrl.isBlank()) { - return true; - } - return runtime.getServiceCatalog().getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) != null; - } - @Override public void services(CdsRuntimeConfigurer configurer) { CdsRuntime runtime = configurer.getCdsRuntime();