diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java index e99586ea3..b77ddcb86 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java @@ -5,6 +5,7 @@ import com.sap.cds.feature.attachments.handler.applicationservice.CreateAttachmentsHandler; import com.sap.cds.feature.attachments.handler.applicationservice.DeleteAttachmentsHandler; +import com.sap.cds.feature.attachments.handler.applicationservice.ItemsCountValidationHandler; import com.sap.cds.feature.attachments.handler.applicationservice.ReadAttachmentsHandler; import com.sap.cds.feature.attachments.handler.applicationservice.UpdateAttachmentsHandler; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper; @@ -133,6 +134,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { configurer.eventHandler( new ReadAttachmentsHandler( attachmentService, new AttachmentStatusValidator(), scanRunner)); + configurer.eventHandler(new ItemsCountValidationHandler(attachmentsReader, storage)); } else { logger.debug( "No application service is available. Application service event handlers will not be registered."); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ItemsCountValidationHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ItemsCountValidationHandler.java new file mode 100644 index 000000000..5760c6d94 --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ItemsCountValidationHandler.java @@ -0,0 +1,163 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice; + +import static java.util.Objects.requireNonNull; + +import com.sap.cds.CdsData; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.ItemsCountValidator; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.ItemsCountViolation; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader; +import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; +import com.sap.cds.feature.attachments.handler.common.AttachmentsReader; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.reflect.CdsAssociationType; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.cds.ApplicationService; +import com.sap.cds.services.cds.CdsCreateEventContext; +import com.sap.cds.services.cds.CdsUpdateEventContext; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.Before; +import com.sap.cds.services.handler.annotations.HandlerOrder; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.messages.Messages; +import com.sap.cds.services.utils.OrderConstants; +import com.sap.cds.services.utils.model.CqnUtils; +import java.util.ArrayList; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Event handler that validates the number of items in composition associations against + * {@code @Validation.MaxItems} and {@code @Validation.MinItems} annotations. + * + *

During draft mode (draft activate), violations are reported as warnings, allowing the draft to + * remain in an invalid state. On direct active entity operations (CREATE/UPDATE), violations are + * reported as errors, rejecting the request. + * + *

Error messages can be overridden by applications using the Fiori elements i18n convention. The + * base message keys are: + * + *

+ * + *

To override for a specific entity/property, define in your application's {@code + * messages.properties}: + * + *

+ */ +@ServiceName(value = "*", type = ApplicationService.class) +public class ItemsCountValidationHandler implements EventHandler { + + private static final Logger logger = LoggerFactory.getLogger(ItemsCountValidationHandler.class); + + private final AttachmentsReader attachmentsReader; + private final ThreadDataStorageReader storageReader; + + public ItemsCountValidationHandler( + AttachmentsReader attachmentsReader, ThreadDataStorageReader storageReader) { + this.attachmentsReader = + requireNonNull(attachmentsReader, "attachmentsReader must not be null"); + this.storageReader = requireNonNull(storageReader, "storageReader must not be null"); + } + + @Before + @HandlerOrder(OrderConstants.Before.CHECK_CAPABILITIES) + void validateOnCreate(CdsCreateEventContext context, List data) { + CdsEntity target = context.getTarget(); + if (!hasCompositionsWithItemCountAnnotations(target)) { + return; + } + + logger.debug("Validating items count for CREATE event on entity {}", target.getQualifiedName()); + + List violations = + ItemsCountValidator.validate(target, data, new ArrayList<>()); + + if (!violations.isEmpty()) { + boolean isDraft = Boolean.TRUE.equals(storageReader.get()); + reportViolations(violations, context.getMessages(), isDraft); + } + } + + @Before + @HandlerOrder(OrderConstants.Before.CHECK_CAPABILITIES) + void validateOnUpdate(CdsUpdateEventContext context, List data) { + CdsEntity target = context.getTarget(); + if (!hasCompositionsWithItemCountAnnotations(target)) { + return; + } + + logger.debug("Validating items count for UPDATE event on entity {}", target.getQualifiedName()); + + CqnSelect select = CqnUtils.toSelect(context.getCqn(), target); + List existingData = + attachmentsReader.readAttachments(context.getModel(), target, select); + + List violations = ItemsCountValidator.validate(target, data, existingData); + + if (!violations.isEmpty()) { + boolean isDraft = Boolean.TRUE.equals(storageReader.get()); + reportViolations(violations, context.getMessages(), isDraft); + } + } + + static boolean hasCompositionsWithItemCountAnnotations(CdsEntity entity) { + return entity + .elements() + .filter( + e -> + e.getType().isAssociation() + && e.getType().as(CdsAssociationType.class).isComposition()) + .anyMatch( + e -> + ApplicationHandlerHelper.isMediaEntity( + e.getType().as(CdsAssociationType.class).getTarget()) + && (e.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS).isPresent() + || e.findAnnotation(ItemsCountValidator.ANNOTATION_MIN_ITEMS).isPresent())); + } + + /** + * Reports validation violations as messages on the event context. + * + *

For active entity operations, violations are reported as errors via {@link + * ServiceException}, causing the request to fail. For draft operations, violations are reported + * as warnings via the {@link Messages} API, allowing the draft to be saved in an invalid state. + * + *

The message key follows the Fiori elements i18n override convention: {@code + * __}. Applications can override messages by defining the + * specific key in their own {@code messages.properties}. The base keys are always provided as + * fallback. + * + *

Message parameters: + * + *

    + *
  • {0} = the configured limit + *
  • {1} = the actual number of items + *
+ */ + static void reportViolations( + List violations, Messages messages, boolean isDraft) { + for (ItemsCountViolation violation : violations) { + String messageKey = violation.getBaseMessageKey(); + String target = violation.compositionName(); + + if (isDraft) { + messages.warn(messageKey, violation.limit(), violation.actualCount()).target(target); + } else { + throw new ServiceException( + ErrorStatuses.CONFLICT, messageKey, violation.limit(), violation.actualCount()); + } + } + } +} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemsCountValidator.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemsCountValidator.java new file mode 100644 index 000000000..aa2605653 --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemsCountValidator.java @@ -0,0 +1,256 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper; + +import com.sap.cds.CdsData; +import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; +import com.sap.cds.reflect.CdsAnnotation; +import com.sap.cds.reflect.CdsAssociationType; +import com.sap.cds.reflect.CdsElementDefinition; +import com.sap.cds.reflect.CdsEntity; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Validates the number of items in composition associations against {@code @Validation.MaxItems} + * and {@code @Validation.MinItems} annotations. + * + *

Annotation values can be: + * + *

    + *
  • An integer literal, e.g. {@code @Validation.MaxItems: 20} + *
  • A property name from the parent entity, e.g. {@code @Validation.MaxItems: 'maxCount'} + *
+ */ +public final class ItemsCountValidator { + + private static final Logger logger = LoggerFactory.getLogger(ItemsCountValidator.class); + + public static final String ANNOTATION_MAX_ITEMS = "Validation.MaxItems"; + public static final String ANNOTATION_MIN_ITEMS = "Validation.MinItems"; + + /** + * Validates all composition associations of the given entity that have + * {@code @Validation.MaxItems} or {@code @Validation.MinItems} annotations against the items + * count in the provided data. + * + * @param entity the entity definition + * @param data the request payload data + * @param existingData the existing data from the database (for UPDATE); may be empty for CREATE + * @return a list of validation violations found + */ + public static List validate( + CdsEntity entity, List data, List existingData) { + List violations = new ArrayList<>(); + + entity + .elements() + .filter( + element -> + element.getType().isAssociation() + && element.getType().as(CdsAssociationType.class).isComposition()) + .filter( + element -> + isMediaEntity(element) + && (hasAnnotation(element, ANNOTATION_MAX_ITEMS) + || hasAnnotation(element, ANNOTATION_MIN_ITEMS))) + .forEach( + element -> { + String compositionName = element.getName(); + validateComposition(entity, element, compositionName, data, existingData, violations); + }); + + return violations; + } + + private static boolean isMediaEntity(CdsElementDefinition element) { + CdsEntity target = element.getType().as(CdsAssociationType.class).getTarget(); + return ApplicationHandlerHelper.isMediaEntity(target); + } + + private static boolean hasAnnotation(CdsElementDefinition element, String annotationName) { + return element.findAnnotation(annotationName).isPresent(); + } + + private static void validateComposition( + CdsEntity entity, + CdsElementDefinition element, + String compositionName, + List dataList, + List existingDataList, + List violations) { + + for (int i = 0; i < dataList.size(); i++) { + CdsData data = dataList.get(i); + CdsData existingData = + (existingDataList != null && i < existingDataList.size()) + ? existingDataList.get(i) + : null; + + int itemCount = countItems(data, compositionName); + if (itemCount < 0) { + // composition not present in payload, skip validation + continue; + } + + Map parentData = mergeData(existingData, data); + + validateMaxItems(entity, element, compositionName, itemCount, parentData, violations); + validateMinItems(entity, element, compositionName, itemCount, parentData, violations); + } + } + + /** + * Counts items for a composition. If the composition is present in the request data, use that + * count. Otherwise, return -1 to indicate the composition is not being modified. + */ + private static int countItems(CdsData data, String compositionName) { + Object compositionData = data.get(compositionName); + if (compositionData == null) { + // composition not in payload, not being modified + return -1; + } + if (compositionData instanceof Collection collection) { + return collection.size(); + } + return -1; + } + + private static Map mergeData(CdsData existingData, CdsData requestData) { + if (existingData == null) { + return requestData; + } + CdsData merged = CdsData.create(); + merged.putAll(existingData); + merged.putAll(requestData); + return merged; + } + + private static void validateMaxItems( + CdsEntity entity, + CdsElementDefinition element, + String compositionName, + int itemCount, + Map parentData, + List violations) { + Optional maxItems = resolveAnnotationValue(element, ANNOTATION_MAX_ITEMS, parentData); + if (maxItems.isPresent() && itemCount > maxItems.get()) { + String entityName = getSimpleEntityName(entity); + violations.add( + new ItemsCountViolation( + ItemsCountViolation.Type.MAX_ITEMS, + compositionName, + entityName, + itemCount, + maxItems.get())); + } + } + + private static void validateMinItems( + CdsEntity entity, + CdsElementDefinition element, + String compositionName, + int itemCount, + Map parentData, + List violations) { + Optional minItems = resolveAnnotationValue(element, ANNOTATION_MIN_ITEMS, parentData); + if (minItems.isPresent() && itemCount < minItems.get()) { + String entityName = getSimpleEntityName(entity); + violations.add( + new ItemsCountViolation( + ItemsCountViolation.Type.MIN_ITEMS, + compositionName, + entityName, + itemCount, + minItems.get())); + } + } + + /** + * Resolves the annotation value to an integer. Supports: + * + *
    + *
  • Integer literal: {@code @Validation.MaxItems: 20} → 20 + *
  • Property reference as string: {@code @Validation.MaxItems: 'maxCount'} → reads value of + * 'maxCount' from parent entity data + *
  • Bare annotation (value = true): ignored, returns empty + *
+ */ + static Optional resolveAnnotationValue( + CdsElementDefinition element, String annotationName, Map parentData) { + return element + .findAnnotation(annotationName) + .map(CdsAnnotation::getValue) + .flatMap(value -> resolveValue(value, parentData, annotationName)); + } + + private static Optional resolveValue( + Object value, Map parentData, String annotationName) { + if ("true".equals(value.toString())) { + // bare annotation without value, ignore + return Optional.empty(); + } + + // try direct integer + if (value instanceof Number number) { + return Optional.of(number.intValue()); + } + + String strValue = value.toString().trim(); + + // try parsing as integer literal + try { + return Optional.of(Integer.parseInt(strValue)); + } catch (NumberFormatException e) { + // not a plain integer, try as property reference + logger.debug( + "Annotation value '{}' is not an integer, trying as property reference", strValue); + } + + // try as property reference from parent entity data + if (parentData != null) { + Object propertyValue = parentData.get(strValue); + if (propertyValue instanceof Number number) { + return Optional.of(number.intValue()); + } + if (propertyValue != null) { + try { + return Optional.of(Integer.parseInt(propertyValue.toString())); + } catch (NumberFormatException e) { + logger.warn( + "Cannot resolve {} annotation value '{}' to an integer from property value '{}'", + annotationName, + strValue, + propertyValue); + } + } else { + logger.debug( + "Property '{}' referenced by {} annotation not found in entity data", + strValue, + annotationName); + } + } + + logger.warn("Cannot resolve {} annotation value '{}' to an integer", annotationName, strValue); + return Optional.empty(); + } + + /** + * Returns the simple (unqualified) entity name, e.g. "Incidents" from + * "my.namespace.service.Incidents". + */ + private static String getSimpleEntityName(CdsEntity entity) { + String qualifiedName = entity.getQualifiedName(); + return qualifiedName.substring(qualifiedName.lastIndexOf('.') + 1); + } + + private ItemsCountValidator() { + // avoid instantiation + } +} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemsCountViolation.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemsCountViolation.java new file mode 100644 index 000000000..9a42c2c66 --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemsCountViolation.java @@ -0,0 +1,54 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper; + +/** + * Represents a validation violation for items count constraints on a composition. + * + * @param type the type of violation (MAX_ITEMS or MIN_ITEMS) + * @param compositionName the name of the composition association that was violated + * @param entityName the simple name of the parent entity + * @param actualCount the actual number of items found + * @param limit the configured limit that was violated + */ +public record ItemsCountViolation( + Type type, String compositionName, String entityName, int actualCount, int limit) { + + /** The type of items count violation. */ + public enum Type { + MAX_ITEMS, + MIN_ITEMS + } + + /** + * Returns the i18n message key for this violation. The key follows the Fiori elements convention + * of appending entity name and property name for overriding. + * + *

Base keys: + * + *

    + *
  • {@code AttachmentMaxItemsExceeded} for MAX_ITEMS violations + *
  • {@code AttachmentMinItemsNotReached} for MIN_ITEMS violations + *
+ * + *

Override keys (checked first): + * + *

    + *
  • {@code AttachmentMaxItemsExceeded__} + *
  • {@code AttachmentMinItemsNotReached__} + *
+ */ + public String getBaseMessageKey() { + return type == Type.MAX_ITEMS ? "AttachmentMaxItemsExceeded" : "AttachmentMinItemsNotReached"; + } + + /** + * Returns the entity/property-specific override message key. + * + * @return the override key in the form {@code __} + */ + public String getOverrideMessageKey() { + return getBaseMessageKey() + "_" + entityName + "_" + compositionName; + } +} diff --git a/cds-feature-attachments/src/main/resources/messages.properties b/cds-feature-attachments/src/main/resources/messages.properties index e9af11c93..b113a5394 100644 --- a/cds-feature-attachments/src/main/resources/messages.properties +++ b/cds-feature-attachments/src/main/resources/messages.properties @@ -1 +1,3 @@ -AttachmentSizeExceeded = File size exceeds the limit of {0}. \ No newline at end of file +AttachmentSizeExceeded = File size exceeds the limit of {0}. +AttachmentMaxItemsExceeded = Number of attachments exceeds the maximum of {0}. Found {1} items. +AttachmentMinItemsNotReached = Number of attachments is below the minimum of {0}. Found {1} items. \ No newline at end of file diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/configuration/RegistrationTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/configuration/RegistrationTest.java index b0229cd5b..7eff970f4 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/configuration/RegistrationTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/configuration/RegistrationTest.java @@ -13,6 +13,7 @@ import com.sap.cds.feature.attachments.handler.applicationservice.CreateAttachmentsHandler; import com.sap.cds.feature.attachments.handler.applicationservice.DeleteAttachmentsHandler; +import com.sap.cds.feature.attachments.handler.applicationservice.ItemsCountValidationHandler; import com.sap.cds.feature.attachments.handler.applicationservice.ReadAttachmentsHandler; import com.sap.cds.feature.attachments.handler.applicationservice.UpdateAttachmentsHandler; import com.sap.cds.feature.attachments.handler.draftservice.DraftActiveAttachmentsHandler; @@ -108,7 +109,7 @@ void handlersAreRegistered() { cut.eventHandlers(configurer); - var handlerSize = 8; + var handlerSize = 9; verify(configurer, times(handlerSize)).eventHandler(handlerArgumentCaptor.capture()); checkHandlers(handlerArgumentCaptor.getAllValues(), handlerSize); } @@ -128,7 +129,7 @@ void handlersAreRegisteredWithoutOutboxService() { cut.eventHandlers(configurer); - var handlerSize = 8; + var handlerSize = 9; verify(configurer, times(handlerSize)).eventHandler(handlerArgumentCaptor.capture()); checkHandlers(handlerArgumentCaptor.getAllValues(), handlerSize); } @@ -140,6 +141,7 @@ private void checkHandlers(List handlers, int handlerSize) { isHandlerForClassIncluded(handlers, UpdateAttachmentsHandler.class); isHandlerForClassIncluded(handlers, DeleteAttachmentsHandler.class); isHandlerForClassIncluded(handlers, ReadAttachmentsHandler.class); + isHandlerForClassIncluded(handlers, ItemsCountValidationHandler.class); isHandlerForClassIncluded(handlers, DraftPatchAttachmentsHandler.class); isHandlerForClassIncluded(handlers, DraftCancelAttachmentsHandler.class); isHandlerForClassIncluded(handlers, DraftActiveAttachmentsHandler.class); @@ -166,6 +168,7 @@ void lessHandlersAreRegistered() { isHandlerForClassMissing(handlers, UpdateAttachmentsHandler.class); isHandlerForClassMissing(handlers, DeleteAttachmentsHandler.class); isHandlerForClassMissing(handlers, ReadAttachmentsHandler.class); + isHandlerForClassMissing(handlers, ItemsCountValidationHandler.class); // event handlers for draft services are not registered isHandlerForClassMissing(handlers, DraftPatchAttachmentsHandler.class); isHandlerForClassMissing(handlers, DraftCancelAttachmentsHandler.class); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ItemsCountValidationHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ItemsCountValidationHandlerTest.java new file mode 100644 index 000000000..ab3786d9f --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ItemsCountValidationHandlerTest.java @@ -0,0 +1,357 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Attachment_; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Items_; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.ItemsCountViolation; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader; +import com.sap.cds.feature.attachments.handler.common.AttachmentsReader; +import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; +import com.sap.cds.ql.Update; +import com.sap.cds.ql.cqn.CqnFilterableStatement; +import com.sap.cds.ql.cqn.CqnUpdate; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.cds.ApplicationService; +import com.sap.cds.services.cds.CdsCreateEventContext; +import com.sap.cds.services.cds.CdsUpdateEventContext; +import com.sap.cds.services.handler.annotations.Before; +import com.sap.cds.services.handler.annotations.HandlerOrder; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.messages.Messages; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.utils.OrderConstants; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class ItemsCountValidationHandlerTest { + + private static CdsRuntime runtime; + + private ItemsCountValidationHandler cut; + private AttachmentsReader attachmentsReader; + private ThreadDataStorageReader storageReader; + private CdsCreateEventContext createContext; + private CdsUpdateEventContext updateContext; + private Messages messages; + + @BeforeAll + static void classSetup() { + runtime = RuntimeHelper.runtime; + } + + @BeforeEach + void setup() { + attachmentsReader = mock(AttachmentsReader.class); + storageReader = mock(ThreadDataStorageReader.class); + cut = new ItemsCountValidationHandler(attachmentsReader, storageReader); + + createContext = mock(CdsCreateEventContext.class); + updateContext = mock(CdsUpdateEventContext.class); + messages = mock(Messages.class, Mockito.RETURNS_DEEP_STUBS); + + when(createContext.getMessages()).thenReturn(messages); + when(updateContext.getMessages()).thenReturn(messages); + } + + @Test + void classHasCorrectAnnotation() { + var annotation = cut.getClass().getAnnotation(ServiceName.class); + + assertThat(annotation.type()).containsOnly(ApplicationService.class); + assertThat(annotation.value()).containsOnly("*"); + } + + @Test + void validateOnCreateMethodHasCorrectAnnotations() throws NoSuchMethodException { + var method = + cut.getClass() + .getDeclaredMethod("validateOnCreate", CdsCreateEventContext.class, List.class); + + var beforeAnnotation = method.getAnnotation(Before.class); + var handlerOrderAnnotation = method.getAnnotation(HandlerOrder.class); + + assertThat(beforeAnnotation).isNotNull(); + assertThat(handlerOrderAnnotation.value()).isEqualTo(OrderConstants.Before.CHECK_CAPABILITIES); + } + + @Test + void validateOnUpdateMethodHasCorrectAnnotations() throws NoSuchMethodException { + var method = + cut.getClass() + .getDeclaredMethod("validateOnUpdate", CdsUpdateEventContext.class, List.class); + + var beforeAnnotation = method.getAnnotation(Before.class); + var handlerOrderAnnotation = method.getAnnotation(HandlerOrder.class); + + assertThat(beforeAnnotation).isNotNull(); + assertThat(handlerOrderAnnotation.value()).isEqualTo(OrderConstants.Before.CHECK_CAPABILITIES); + } + + @Test + void createWithNoAnnotatedCompositionsDoesNothing() { + // Attachment entity has no compositions with MaxItems/MinItems + getEntityAndMockCreateContext(Attachment_.CDS_NAME); + var attachment = Attachments.create(); + + cut.validateOnCreate(createContext, List.of(attachment)); + + verifyNoInteractions(storageReader); + verifyNoInteractions(attachmentsReader); + } + + @Test + void createWithinLimitsNoViolation() { + getEntityAndMockCreateContext(RootTable_.CDS_NAME); + var root = RootTable.create(); + root.setAttachments(createAttachments(5)); + when(storageReader.get()).thenReturn(false); + + assertDoesNotThrow(() -> cut.validateOnCreate(createContext, List.of(root))); + } + + @Test + void createExceedsMaxItemsThrowsError() { + getEntityAndMockCreateContext(RootTable_.CDS_NAME); + var root = RootTable.create(); + root.setAttachments(createAttachments(25)); + when(storageReader.get()).thenReturn(false); + + var exception = + assertThrows( + ServiceException.class, () -> cut.validateOnCreate(createContext, List.of(root))); + + assertThat(exception.getErrorStatus()).isEqualTo(ErrorStatuses.CONFLICT); + } + + @Test + void createBelowMinItemsThrowsError() { + getEntityAndMockCreateContext(RootTable_.CDS_NAME); + var root = RootTable.create(); + root.setAttachments(createAttachments(1)); + when(storageReader.get()).thenReturn(false); + + var exception = + assertThrows( + ServiceException.class, () -> cut.validateOnCreate(createContext, List.of(root))); + + assertThat(exception.getErrorStatus()).isEqualTo(ErrorStatuses.CONFLICT); + } + + @Test + void createInDraftModeAddsWarningInsteadOfError() { + getEntityAndMockCreateContext(RootTable_.CDS_NAME); + var root = RootTable.create(); + root.setAttachments(createAttachments(25)); + when(storageReader.get()).thenReturn(true); + + assertDoesNotThrow(() -> cut.validateOnCreate(createContext, List.of(root))); + + verify(messages).warn("AttachmentMaxItemsExceeded", 20, 25); + } + + @Test + void createWithNoCompositionInPayloadSkipsValidation() { + getEntityAndMockCreateContext(RootTable_.CDS_NAME); + var root = RootTable.create(); + root.setTitle("test"); + // attachments not in payload + + assertDoesNotThrow(() -> cut.validateOnCreate(createContext, List.of(root))); + verifyNoInteractions(storageReader); + } + + @Test + void updateWithinLimitsNoViolation() { + var id = getEntityAndMockUpdateContext(RootTable_.CDS_NAME); + var root = RootTable.create(); + root.setId(id); + root.setAttachments(createAttachments(10)); + when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) + .thenReturn(Collections.emptyList()); + when(storageReader.get()).thenReturn(false); + + assertDoesNotThrow(() -> cut.validateOnUpdate(updateContext, List.of(root))); + } + + @Test + void updateExceedsMaxItemsThrowsError() { + var id = getEntityAndMockUpdateContext(RootTable_.CDS_NAME); + var root = RootTable.create(); + root.setId(id); + root.setAttachments(createAttachments(25)); + when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) + .thenReturn(Collections.emptyList()); + when(storageReader.get()).thenReturn(false); + + var exception = + assertThrows( + ServiceException.class, () -> cut.validateOnUpdate(updateContext, List.of(root))); + + assertThat(exception.getErrorStatus()).isEqualTo(ErrorStatuses.CONFLICT); + } + + @Test + void updateInDraftModeAddsWarning() { + var id = getEntityAndMockUpdateContext(RootTable_.CDS_NAME); + var root = RootTable.create(); + root.setId(id); + root.setAttachments(createAttachments(25)); + when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) + .thenReturn(Collections.emptyList()); + when(storageReader.get()).thenReturn(true); + + assertDoesNotThrow(() -> cut.validateOnUpdate(updateContext, List.of(root))); + + verify(messages).warn("AttachmentMaxItemsExceeded", 20, 25); + } + + @Test + void reportViolationsThrowsErrorWhenNotDraft() { + var violations = + List.of( + new ItemsCountViolation( + ItemsCountViolation.Type.MAX_ITEMS, "attachments", "RootTable", 25, 20)); + + var exception = + assertThrows( + ServiceException.class, + () -> ItemsCountValidationHandler.reportViolations(violations, messages, false)); + + assertThat(exception.getErrorStatus()).isEqualTo(ErrorStatuses.CONFLICT); + } + + @Test + void reportViolationsAddsWarningWhenDraft() { + var violations = + List.of( + new ItemsCountViolation( + ItemsCountViolation.Type.MAX_ITEMS, "attachments", "RootTable", 25, 20)); + + assertDoesNotThrow( + () -> ItemsCountValidationHandler.reportViolations(violations, messages, true)); + + verify(messages).warn("AttachmentMaxItemsExceeded", 20, 25); + } + + @Test + void hasCompositionsWithItemCountAnnotationsReturnsTrueForAnnotatedEntity() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + assertThat(ItemsCountValidationHandler.hasCompositionsWithItemCountAnnotations(entity)) + .isTrue(); + } + + @Test + void hasCompositionsWithItemCountAnnotationsReturnsFalseForNonAnnotatedEntity() { + CdsEntity entity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME).orElseThrow(); + assertThat(ItemsCountValidationHandler.hasCompositionsWithItemCountAnnotations(entity)) + .isFalse(); + } + + @Test + void hasCompositionsWithItemCountAnnotationsReturnsTrueForMinItemsOnlyEntity() { + CdsEntity entity = runtime.getCdsModel().findEntity(Items_.CDS_NAME).orElseThrow(); + assertThat(ItemsCountValidationHandler.hasCompositionsWithItemCountAnnotations(entity)) + .isTrue(); + } + + @Test + void updateWithNoAnnotatedCompositionsDoesNothing() { + // Attachment entity has no compositions with MaxItems/MinItems + var serviceEntity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME).orElseThrow(); + when(updateContext.getTarget()).thenReturn(serviceEntity); + var attachment = Attachments.create(); + + cut.validateOnUpdate(updateContext, List.of(attachment)); + + verifyNoInteractions(storageReader); + verifyNoInteractions(attachmentsReader); + } + + @Test + void createWithEmptyViolationsListDoesNothing() { + getEntityAndMockCreateContext(RootTable_.CDS_NAME); + // No composition in payload means no violations + var root = RootTable.create(); + root.setTitle("test"); + + cut.validateOnCreate(createContext, List.of(root)); + + verifyNoInteractions(storageReader); + } + + @Test + void reportViolationsMinItemsAddsWarningWhenDraft() { + var violations = + List.of( + new ItemsCountViolation( + ItemsCountViolation.Type.MIN_ITEMS, "attachments", "RootTable", 1, 2)); + + assertDoesNotThrow( + () -> ItemsCountValidationHandler.reportViolations(violations, messages, true)); + + verify(messages).warn("AttachmentMinItemsNotReached", 2, 1); + } + + @Test + void reportViolationsMinItemsThrowsErrorWhenNotDraft() { + var violations = + List.of( + new ItemsCountViolation( + ItemsCountViolation.Type.MIN_ITEMS, "attachments", "RootTable", 1, 2)); + + var exception = + assertThrows( + ServiceException.class, + () -> ItemsCountValidationHandler.reportViolations(violations, messages, false)); + + assertThat(exception.getErrorStatus()).isEqualTo(ErrorStatuses.CONFLICT); + } + + private void getEntityAndMockCreateContext(String cdsName) { + var serviceEntity = runtime.getCdsModel().findEntity(cdsName).orElseThrow(); + when(createContext.getTarget()).thenReturn(serviceEntity); + } + + private String getEntityAndMockUpdateContext(String cdsName) { + var serviceEntity = runtime.getCdsModel().findEntity(cdsName).orElseThrow(); + var id = UUID.randomUUID().toString(); + CqnUpdate update = + Update.entity(serviceEntity.getQualifiedName()).where(entity -> entity.get("ID").eq(id)); + when(updateContext.getTarget()).thenReturn(serviceEntity); + when(updateContext.getModel()).thenReturn(runtime.getCdsModel()); + when(updateContext.getCqn()).thenReturn(update); + return id; + } + + private List createAttachments(int count) { + List attachments = new ArrayList<>(); + for (int i = 0; i < count; i++) { + var attachment = Attachments.create(); + attachment.setFileName("file" + i + ".txt"); + attachments.add(attachment); + } + return attachments; + } +} diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemsCountValidatorTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemsCountValidatorTest.java new file mode 100644 index 000000000..c693fb052 --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemsCountValidatorTest.java @@ -0,0 +1,441 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.sap.cds.CdsData; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Items; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Items_; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; +import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; +import com.sap.cds.reflect.CdsAnnotation; +import com.sap.cds.reflect.CdsElementDefinition; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.services.runtime.CdsRuntime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class ItemsCountValidatorTest { + + private static CdsRuntime runtime; + + @BeforeAll + static void classSetup() { + runtime = RuntimeHelper.runtime; + } + + @Test + void noViolationsWhenCompositionNotInPayload() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + var root = RootTable.create(); + root.setTitle("test"); + // attachments not in payload + + List violations = + ItemsCountValidator.validate(entity, List.of(root), new ArrayList<>()); + + assertThat(violations).isEmpty(); + } + + @Test + void noViolationsWhenWithinLimits() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + var root = RootTable.create(); + // Create 5 attachments (within 2-20 range) + List attachments = createAttachments(5); + root.setAttachments(attachments); + + List violations = + ItemsCountValidator.validate(entity, List.of(root), new ArrayList<>()); + + assertThat(violations).isEmpty(); + } + + @Test + void maxItemsViolation() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + var root = RootTable.create(); + // Create 25 attachments (exceeds max of 20) + List attachments = createAttachments(25); + root.setAttachments(attachments); + + List violations = + ItemsCountValidator.validate(entity, List.of(root), new ArrayList<>()); + + assertThat(violations).hasSize(1); + assertThat(violations.get(0).type()).isEqualTo(ItemsCountViolation.Type.MAX_ITEMS); + assertThat(violations.get(0).compositionName()).isEqualTo("attachments"); + assertThat(violations.get(0).actualCount()).isEqualTo(25); + assertThat(violations.get(0).limit()).isEqualTo(20); + } + + @Test + void minItemsViolation() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + var root = RootTable.create(); + // Create 1 attachment (below min of 2) + List attachments = createAttachments(1); + root.setAttachments(attachments); + + List violations = + ItemsCountValidator.validate(entity, List.of(root), new ArrayList<>()); + + assertThat(violations).hasSize(1); + assertThat(violations.get(0).type()).isEqualTo(ItemsCountViolation.Type.MIN_ITEMS); + assertThat(violations.get(0).compositionName()).isEqualTo("attachments"); + assertThat(violations.get(0).actualCount()).isEqualTo(1); + assertThat(violations.get(0).limit()).isEqualTo(2); + } + + @Test + void emptyAttachmentsViolatesMinItems() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + var root = RootTable.create(); + root.setAttachments(Collections.emptyList()); + + List violations = + ItemsCountValidator.validate(entity, List.of(root), new ArrayList<>()); + + assertThat(violations).hasSize(1); + assertThat(violations.get(0).type()).isEqualTo(ItemsCountViolation.Type.MIN_ITEMS); + assertThat(violations.get(0).actualCount()).isEqualTo(0); + } + + @Test + void exactMinLimitNoViolation() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + var root = RootTable.create(); + // Create exactly 2 attachments (min is 2) + List attachments = createAttachments(2); + root.setAttachments(attachments); + + List violations = + ItemsCountValidator.validate(entity, List.of(root), new ArrayList<>()); + + assertThat(violations).isEmpty(); + } + + @Test + void exactMaxLimitNoViolation() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + var root = RootTable.create(); + // Create exactly 20 attachments (max is 20) + List attachments = createAttachments(20); + root.setAttachments(attachments); + + List violations = + ItemsCountValidator.validate(entity, List.of(root), new ArrayList<>()); + + assertThat(violations).isEmpty(); + } + + @Test + void violationMessageKeys() { + var maxViolation = + new ItemsCountViolation( + ItemsCountViolation.Type.MAX_ITEMS, "attachments", "RootTable", 25, 20); + + assertThat(maxViolation.getBaseMessageKey()).isEqualTo("AttachmentMaxItemsExceeded"); + assertThat(maxViolation.getOverrideMessageKey()) + .isEqualTo("AttachmentMaxItemsExceeded_RootTable_attachments"); + + var minViolation = + new ItemsCountViolation( + ItemsCountViolation.Type.MIN_ITEMS, "attachments", "RootTable", 1, 2); + + assertThat(minViolation.getBaseMessageKey()).isEqualTo("AttachmentMinItemsNotReached"); + assertThat(minViolation.getOverrideMessageKey()) + .isEqualTo("AttachmentMinItemsNotReached_RootTable_attachments"); + } + + @Test + void resolveAnnotationValueWithPropertyReference() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + // The @Validation.MaxItems: 20 annotation should be resolvable + var element = entity.findElement("attachments").orElseThrow(); + var parentData = CdsData.create(); + parentData.put("someField", 15); + + var result = + ItemsCountValidator.resolveAnnotationValue( + element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, parentData); + + assertThat(result).isPresent().hasValue(20); + } + + @Test + void resolveAnnotationValueReturnsEmptyWhenAnnotationNotPresent() { + CdsElementDefinition element = mock(CdsElementDefinition.class); + when(element.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS)) + .thenReturn(Optional.empty()); + + var result = + ItemsCountValidator.resolveAnnotationValue( + element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, Map.of()); + + assertThat(result).isEmpty(); + } + + @SuppressWarnings("unchecked") + @Test + void resolveAnnotationValueReturnsEmptyForNullAnnotationValue() { + CdsElementDefinition element = mock(CdsElementDefinition.class); + CdsAnnotation annotation = mock(CdsAnnotation.class); + when(annotation.getValue()).thenReturn(null); + when(element.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS)) + .thenReturn(Optional.of(annotation)); + + var result = + ItemsCountValidator.resolveAnnotationValue( + element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, Map.of()); + + assertThat(result).isEmpty(); + } + + @SuppressWarnings("unchecked") + @Test + void resolveAnnotationValueReturnsEmptyForBareAnnotation() { + CdsElementDefinition element = mock(CdsElementDefinition.class); + CdsAnnotation annotation = mock(CdsAnnotation.class); + when(annotation.getValue()).thenReturn(true); + when(element.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS)) + .thenReturn(Optional.of(annotation)); + + var result = + ItemsCountValidator.resolveAnnotationValue( + element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, Map.of()); + + assertThat(result).isEmpty(); + } + + @SuppressWarnings("unchecked") + @Test + void resolveAnnotationValueReturnsValueForNumberType() { + CdsElementDefinition element = mock(CdsElementDefinition.class); + CdsAnnotation annotation = mock(CdsAnnotation.class); + when(annotation.getValue()).thenReturn(42); + when(element.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS)) + .thenReturn(Optional.of(annotation)); + + var result = + ItemsCountValidator.resolveAnnotationValue( + element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, Map.of()); + + assertThat(result).isPresent().hasValue(42); + } + + @SuppressWarnings("unchecked") + @Test + void resolveAnnotationValueParsesStringInteger() { + CdsElementDefinition element = mock(CdsElementDefinition.class); + CdsAnnotation annotation = mock(CdsAnnotation.class); + when(annotation.getValue()).thenReturn("15"); + when(element.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS)) + .thenReturn(Optional.of(annotation)); + + var result = + ItemsCountValidator.resolveAnnotationValue( + element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, Map.of()); + + assertThat(result).isPresent().hasValue(15); + } + + @SuppressWarnings("unchecked") + @Test + void resolveAnnotationValueResolvesPropertyReferenceFromNumber() { + CdsElementDefinition element = mock(CdsElementDefinition.class); + CdsAnnotation annotation = mock(CdsAnnotation.class); + when(annotation.getValue()).thenReturn("maxCount"); + when(element.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS)) + .thenReturn(Optional.of(annotation)); + Map parentData = new HashMap<>(); + parentData.put("maxCount", 30); + + var result = + ItemsCountValidator.resolveAnnotationValue( + element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, parentData); + + assertThat(result).isPresent().hasValue(30); + } + + @SuppressWarnings("unchecked") + @Test + void resolveAnnotationValueResolvesPropertyReferenceFromStringNumber() { + CdsElementDefinition element = mock(CdsElementDefinition.class); + CdsAnnotation annotation = mock(CdsAnnotation.class); + when(annotation.getValue()).thenReturn("maxCount"); + when(element.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS)) + .thenReturn(Optional.of(annotation)); + Map parentData = new HashMap<>(); + parentData.put("maxCount", "25"); + + var result = + ItemsCountValidator.resolveAnnotationValue( + element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, parentData); + + assertThat(result).isPresent().hasValue(25); + } + + @SuppressWarnings("unchecked") + @Test + void resolveAnnotationValueReturnsEmptyForUnresolvablePropertyValue() { + CdsElementDefinition element = mock(CdsElementDefinition.class); + CdsAnnotation annotation = mock(CdsAnnotation.class); + when(annotation.getValue()).thenReturn("maxCount"); + when(element.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS)) + .thenReturn(Optional.of(annotation)); + Map parentData = new HashMap<>(); + parentData.put("maxCount", "not-a-number"); + + var result = + ItemsCountValidator.resolveAnnotationValue( + element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, parentData); + + assertThat(result).isEmpty(); + } + + @SuppressWarnings("unchecked") + @Test + void resolveAnnotationValueReturnsEmptyForMissingPropertyInData() { + CdsElementDefinition element = mock(CdsElementDefinition.class); + CdsAnnotation annotation = mock(CdsAnnotation.class); + when(annotation.getValue()).thenReturn("nonExistentProp"); + when(element.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS)) + .thenReturn(Optional.of(annotation)); + Map parentData = new HashMap<>(); + + var result = + ItemsCountValidator.resolveAnnotationValue( + element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, parentData); + + assertThat(result).isEmpty(); + } + + @SuppressWarnings("unchecked") + @Test + void resolveAnnotationValueReturnsEmptyForNullParentData() { + CdsElementDefinition element = mock(CdsElementDefinition.class); + CdsAnnotation annotation = mock(CdsAnnotation.class); + when(annotation.getValue()).thenReturn("maxCount"); + when(element.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS)) + .thenReturn(Optional.of(annotation)); + + var result = + ItemsCountValidator.resolveAnnotationValue( + element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, null); + + assertThat(result).isEmpty(); + } + + @Test + void validateWithNullExistingDataList() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + var root = RootTable.create(); + root.setAttachments(createAttachments(25)); + + List violations = + ItemsCountValidator.validate(entity, List.of(root), null); + + assertThat(violations).hasSize(1); + assertThat(violations.get(0).type()).isEqualTo(ItemsCountViolation.Type.MAX_ITEMS); + } + + @Test + void validateWithExistingDataShorterThanNewData() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + var root1 = RootTable.create(); + root1.setAttachments(createAttachments(5)); + var root2 = RootTable.create(); + root2.setAttachments(createAttachments(25)); + + // Existing data has only one entry, but new data has two + var existingRoot = RootTable.create(); + existingRoot.setTitle("existing"); + + List violations = + ItemsCountValidator.validate(entity, List.of(root1, root2), List.of(existingRoot)); + + assertThat(violations).hasSize(1); + assertThat(violations.get(0).actualCount()).isEqualTo(25); + } + + @Test + void validateWithNonCollectionCompositionData() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + var root = CdsData.create(); + // Set composition as non-collection (e.g., a string) + root.put("attachments", "not-a-collection"); + + List violations = + ItemsCountValidator.validate(entity, List.of(root), new ArrayList<>()); + + // Should skip validation when composition data is not a collection + assertThat(violations).isEmpty(); + } + + @Test + void validateMergesExistingDataWithRequestData() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + var root = RootTable.create(); + root.setAttachments(createAttachments(5)); + + var existingRoot = RootTable.create(); + existingRoot.setTitle("existing"); + + // Validate with existing data to ensure merge works + List violations = + ItemsCountValidator.validate(entity, List.of(root), List.of(existingRoot)); + + assertThat(violations).isEmpty(); + } + + @Test + void minItemsOnlyAnnotationViolation() { + // Items entity has only @Validation.MinItems on itemAttachments (no MaxItems) + CdsEntity entity = runtime.getCdsModel().findEntity(Items_.CDS_NAME).orElseThrow(); + var item = Items.create(); + item.setItemAttachments(Collections.emptyList()); + + List violations = + ItemsCountValidator.validate(entity, List.of(item), new ArrayList<>()); + + assertThat(violations).hasSize(1); + assertThat(violations.get(0).type()).isEqualTo(ItemsCountViolation.Type.MIN_ITEMS); + assertThat(violations.get(0).limit()).isEqualTo(1); + } + + @Test + void minItemsOnlyAnnotationNoViolation() { + // Items entity has only @Validation.MinItems on itemAttachments (no MaxItems) + CdsEntity entity = runtime.getCdsModel().findEntity(Items_.CDS_NAME).orElseThrow(); + var item = Items.create(); + item.setItemAttachments(createAttachments(5)); + + List violations = + ItemsCountValidator.validate(entity, List.of(item), new ArrayList<>()); + + assertThat(violations).isEmpty(); + } + + private List createAttachments(int count) { + List attachments = new ArrayList<>(); + for (int i = 0; i < count; i++) { + var attachment = Attachments.create(); + attachment.setFileName("file" + i + ".txt"); + attachments.add(attachment); + } + return attachments; + } +} diff --git a/cds-feature-attachments/src/test/resources/cds/db-model.cds b/cds-feature-attachments/src/test/resources/cds/db-model.cds index 25d91921d..04b5d798a 100644 --- a/cds-feature-attachments/src/test/resources/cds/db-model.cds +++ b/cds-feature-attachments/src/test/resources/cds/db-model.cds @@ -11,6 +11,8 @@ entity Roots : cuid { title : String; itemTable : Composition of many Items on itemTable.rootId = $self.ID; + @Validation.MaxItems : 20 + @Validation.MinItems : 2 attachments : Composition of many Attachments; } @@ -20,6 +22,7 @@ entity Items : cuid { events : Composition of many Events on events.id1 = $self.ID; attachments : Composition of many Attachment on attachments.ID = $self.ID; + @Validation.MinItems : 1 itemAttachments : Composition of many Attachments; }