From 9680ab6eb501f85ba751b0709f182ae8d7a9ed1c Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Fri, 13 Mar 2026 13:39:09 +0100 Subject: [PATCH] feat: add @Validation.MinItems and @Validation.MaxItems support for attachment compositions Introduces item-count validation for attachment compositions annotated with @Validation.MinItems and @Validation.MaxItems. In draft mode violations produce warnings (draft allows an invalid state); on active entities and during draft activation (DRAFT_SAVE) they produce errors. - ItemCountValidationHelper: shared utility that reads annotations, counts items in the payload, and issues warn/error messages targeting the composition table so Fiori elements can highlight it. Supports integer values, and the i18n override pattern (attachment_minItems_{Entity}_{property}) for per-property message customisation. - ItemsCountValidationHandler: @Before LATE on ApplicationService CREATE and UPDATE; detects draft state via IsActiveEntity=false in payload. - DraftSaveItemsCountValidationHandler: @Before LATE on DraftService DRAFT_SAVE; always errors since activation must enforce constraints. - Registration: registers both new handlers alongside the existing ones. - i18n: adds attachment_minItems and attachment_maxItems message keys. --- .../configuration/Registration.java | 4 + .../ItemsCountValidationHandler.java | 45 ++++ .../common/ItemCountValidationHelper.java | 102 ++++++++ .../DraftSaveItemsCountValidationHandler.java | 31 +++ .../_i18n/i18n.properties | 4 + .../configuration/RegistrationTest.java | 8 +- .../ItemsCountValidationHandlerTest.java | 226 ++++++++++++++++++ ...ftSaveItemsCountValidationHandlerTest.java | 154 ++++++++++++ .../src/test/resources/cds/db-model.cds | 7 + 9 files changed, 579 insertions(+), 2 deletions(-) create mode 100644 cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ItemsCountValidationHandler.java create mode 100644 cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ItemCountValidationHelper.java create mode 100644 cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftSaveItemsCountValidationHandler.java create mode 100644 cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ItemsCountValidationHandlerTest.java create mode 100644 cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftSaveItemsCountValidationHandlerTest.java 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..f46db8153 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; @@ -22,6 +23,7 @@ import com.sap.cds.feature.attachments.handler.draftservice.DraftActiveAttachmentsHandler; import com.sap.cds.feature.attachments.handler.draftservice.DraftCancelAttachmentsHandler; import com.sap.cds.feature.attachments.handler.draftservice.DraftPatchAttachmentsHandler; +import com.sap.cds.feature.attachments.handler.draftservice.DraftSaveItemsCountValidationHandler; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.AttachmentsServiceImpl; import com.sap.cds.feature.attachments.service.handler.DefaultAttachmentsServiceHandler; @@ -133,6 +135,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { configurer.eventHandler( new ReadAttachmentsHandler( attachmentService, new AttachmentStatusValidator(), scanRunner)); + configurer.eventHandler(new ItemsCountValidationHandler()); } else { logger.debug( "No application service is available. Application service event handlers will not be registered."); @@ -146,6 +149,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { new DraftPatchAttachmentsHandler(persistenceService, eventFactory, defaultMaxSize)); configurer.eventHandler(new DraftCancelAttachmentsHandler(attachmentsReader, deleteEvent)); configurer.eventHandler(new DraftActiveAttachmentsHandler(storage)); + configurer.eventHandler(new DraftSaveItemsCountValidationHandler()); } else { logger.debug("No draft service is available. Draft 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..decab2647 --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ItemsCountValidationHandler.java @@ -0,0 +1,45 @@ +/* + * © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice; + +import com.sap.cds.CdsData; +import com.sap.cds.feature.attachments.handler.common.ItemCountValidationHelper; +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 java.util.List; + +/** + * The class {@link ItemsCountValidationHandler} validates {@code @Validation.MinItems} and + * {@code @Validation.MaxItems} annotations on attachment compositions during CREATE and UPDATE + * events. In draft mode (entity not yet activated) a warning is issued; on active entities an error + * is raised. + */ +@ServiceName(value = "*", type = ApplicationService.class) +public class ItemsCountValidationHandler implements EventHandler { + + @Before + @HandlerOrder(HandlerOrder.LATE) + void processCreateBefore(CdsCreateEventContext context, List data) { + boolean isDraft = isDraftData(data); + ItemCountValidationHelper.validateItemCounts( + context.getTarget(), data, isDraft, context.getMessages()); + } + + @Before + @HandlerOrder(HandlerOrder.LATE) + void processUpdateBefore(CdsUpdateEventContext context, List data) { + boolean isDraft = isDraftData(data); + ItemCountValidationHelper.validateItemCounts( + context.getTarget(), data, isDraft, context.getMessages()); + } + + private boolean isDraftData(List data) { + return data.stream().anyMatch(d -> Boolean.FALSE.equals(d.get("IsActiveEntity"))); + } +} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ItemCountValidationHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ItemCountValidationHelper.java new file mode 100644 index 000000000..e6f91f934 --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ItemCountValidationHelper.java @@ -0,0 +1,102 @@ +/* + * © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.common; + +import com.sap.cds.CdsData; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.services.messages.Messages; +import java.util.List; + +public final class ItemCountValidationHelper { + + private static final String MIN_ITEMS_KEY = "attachment_minItems"; + private static final String MAX_ITEMS_KEY = "attachment_maxItems"; + + public static void validateItemCounts( + CdsEntity entity, List data, boolean isDraft, Messages messages) { + entity + .compositions() + .forEach( + composition -> { + String compositionName = composition.getName(); + + // only validate if the composition was included in the payload + boolean presentInPayload = + data.stream().anyMatch(d -> d.containsKey(compositionName)); + if (!presentInPayload) { + return; + } + + long count = + data.stream() + .filter(d -> d.containsKey(compositionName)) + .mapToLong( + d -> { + Object val = d.get(compositionName); + return val instanceof List list ? list.size() : 0L; + }) + .sum(); + + composition + .findAnnotation("Validation.MinItems") + .ifPresent( + annotation -> { + Object value = annotation.getValue(); + if (value instanceof Boolean) + return; // bare annotation without value → skip + int limit = ((Number) value).intValue(); + if (count < limit) { + String msgKey = + MIN_ITEMS_KEY + "_" + entity.getName() + "_" + compositionName; + issueMessage( + isDraft, + messages, + msgKey, + limit, + entity.getQualifiedName(), + compositionName); + } + }); + + composition + .findAnnotation("Validation.MaxItems") + .ifPresent( + annotation -> { + Object value = annotation.getValue(); + if (value instanceof Boolean) + return; // bare annotation without value → skip + int limit = ((Number) value).intValue(); + if (count > limit) { + String msgKey = + MAX_ITEMS_KEY + "_" + entity.getName() + "_" + compositionName; + issueMessage( + isDraft, + messages, + msgKey, + limit, + entity.getQualifiedName(), + compositionName); + } + }); + }); + } + + private static void issueMessage( + boolean isDraft, + Messages messages, + String msgKey, + int limit, + String entityQualifiedName, + String compositionName) { + if (isDraft) { + messages.warn(msgKey, limit).target(entityQualifiedName, e -> e.to(compositionName)); + } else { + messages.error(msgKey, limit).target(entityQualifiedName, e -> e.to(compositionName)); + } + } + + private ItemCountValidationHelper() { + // avoid instantiation + } +} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftSaveItemsCountValidationHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftSaveItemsCountValidationHandler.java new file mode 100644 index 000000000..4150342a5 --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftSaveItemsCountValidationHandler.java @@ -0,0 +1,31 @@ +/* + * © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.draftservice; + +import com.sap.cds.CdsData; +import com.sap.cds.feature.attachments.handler.common.ItemCountValidationHelper; +import com.sap.cds.services.draft.DraftSaveEventContext; +import com.sap.cds.services.draft.DraftService; +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 java.util.List; + +/** + * The class {@link DraftSaveItemsCountValidationHandler} validates {@code @Validation.MinItems} and + * {@code @Validation.MaxItems} annotations on attachment compositions when a draft is saved + * (activated). As draft activation transitions to an active entity, violations always raise errors. + */ +@ServiceName(value = "*", type = DraftService.class) +public class DraftSaveItemsCountValidationHandler implements EventHandler { + + @Before + @HandlerOrder(HandlerOrder.LATE) + void processBeforeDraftSave(DraftSaveEventContext context, List data) { + // isDraft=false: saving a draft to active must enforce the constraint as an error + ItemCountValidationHelper.validateItemCounts( + context.getTarget(), data, false, context.getMessages()); + } +} diff --git a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/_i18n/i18n.properties b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/_i18n/i18n.properties index 28f8695a4..0f40734c3 100644 --- a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/_i18n/i18n.properties +++ b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/_i18n/i18n.properties @@ -36,3 +36,7 @@ attachment_note=Note attachment=Attachment #XTIT: Header label for Attachments attachments=Attachments +#XMSG: Error message when too few attachments are provided (min items validation) +attachment_minItems=A minimum of {0} attachments is required. +#XMSG: Error message when too many attachments are provided (max items validation) +attachment_maxItems=A maximum of {0} attachments is allowed. 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..abe2a0b13 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,11 +13,13 @@ 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; import com.sap.cds.feature.attachments.handler.draftservice.DraftCancelAttachmentsHandler; import com.sap.cds.feature.attachments.handler.draftservice.DraftPatchAttachmentsHandler; +import com.sap.cds.feature.attachments.handler.draftservice.DraftSaveItemsCountValidationHandler; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.handler.DefaultAttachmentsServiceHandler; import com.sap.cds.feature.attachments.service.malware.DefaultAttachmentMalwareScanner; @@ -108,7 +110,7 @@ void handlersAreRegistered() { cut.eventHandlers(configurer); - var handlerSize = 8; + var handlerSize = 10; verify(configurer, times(handlerSize)).eventHandler(handlerArgumentCaptor.capture()); checkHandlers(handlerArgumentCaptor.getAllValues(), handlerSize); } @@ -128,7 +130,7 @@ void handlersAreRegisteredWithoutOutboxService() { cut.eventHandlers(configurer); - var handlerSize = 8; + var handlerSize = 10; verify(configurer, times(handlerSize)).eventHandler(handlerArgumentCaptor.capture()); checkHandlers(handlerArgumentCaptor.getAllValues(), handlerSize); } @@ -143,6 +145,8 @@ private void checkHandlers(List handlers, int handlerSize) { isHandlerForClassIncluded(handlers, DraftPatchAttachmentsHandler.class); isHandlerForClassIncluded(handlers, DraftCancelAttachmentsHandler.class); isHandlerForClassIncluded(handlers, DraftActiveAttachmentsHandler.class); + isHandlerForClassIncluded(handlers, ItemsCountValidationHandler.class); + isHandlerForClassIncluded(handlers, DraftSaveItemsCountValidationHandler.class); } @Test 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..76e25a95b --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ItemsCountValidationHandlerTest.java @@ -0,0 +1,226 @@ +/* + * © 2024-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.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +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.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.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.CdsEntity; +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.Message; +import com.sap.cds.services.messages.Messages; +import com.sap.cds.services.runtime.CdsRuntime; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ItemsCountValidationHandlerTest { + + private static CdsRuntime runtime; + + private ItemsCountValidationHandler cut; + private Messages messages; + private Message messageBuilder; + + @BeforeAll + static void classSetup() { + runtime = RuntimeHelper.runtime; + } + + @BeforeEach + void setup() { + cut = new ItemsCountValidationHandler(); + messages = mock(Messages.class); + messageBuilder = mock(Message.class); + when(messages.warn(anyString(), anyInt())).thenReturn(messageBuilder); + when(messages.error(anyString(), anyInt())).thenReturn(messageBuilder); + when(messageBuilder.target(anyString(), any())).thenReturn(messageBuilder); + } + + // --- CREATE handler tests --- + + @Test + void createMaxItemsViolated_activeEntity_error() { + var context = mockCreateContext(RootTable_.CDS_NAME); + var root = rootWithAttachments(11); // MaxItems is 10 + + cut.processCreateBefore(context, List.of(root)); + + verify(messages).error(anyString(), anyInt()); + verify(messages, never()).warn(anyString(), anyInt()); + } + + @Test + void createMaxItemsNotViolated_noMessages() { + var context = mockCreateContext(RootTable_.CDS_NAME); + var root = rootWithAttachments(5); // MaxItems is 10, ok + + cut.processCreateBefore(context, List.of(root)); + + verify(messages, never()).error(anyString(), anyInt()); + verify(messages, never()).warn(anyString(), anyInt()); + } + + @Test + void createMinItemsViolated_activeEntity_error() { + var context = mockCreateContext(RootTable_.CDS_NAME); + var root = RootTable.create(); + root.setAttachments(List.of()); // MinItems is 1, empty violates + + cut.processCreateBefore(context, List.of(root)); + + verify(messages).error(anyString(), anyInt()); + verify(messages, never()).warn(anyString(), anyInt()); + } + + @Test + void createMinItemsNotViolated_noMessages() { + var context = mockCreateContext(RootTable_.CDS_NAME); + var root = rootWithAttachments(2); // MinItems is 1, ok + + cut.processCreateBefore(context, List.of(root)); + + verify(messages, never()).error(anyString(), anyInt()); + verify(messages, never()).warn(anyString(), anyInt()); + } + + @Test + void createMinItemsViolated_draftData_warning() { + var context = mockCreateContext(RootTable_.CDS_NAME); + var root = RootTable.create(); + root.setAttachments(List.of()); // MinItems is 1 + root.put("IsActiveEntity", false); // marks as draft + + cut.processCreateBefore(context, List.of(root)); + + verify(messages).warn(anyString(), anyInt()); + verify(messages, never()).error(anyString(), anyInt()); + } + + @Test + void createMaxItemsViolated_draftData_warning() { + var context = mockCreateContext(RootTable_.CDS_NAME); + var root = rootWithAttachments(11); // MaxItems is 10 + root.put("IsActiveEntity", false); + + cut.processCreateBefore(context, List.of(root)); + + verify(messages).warn(anyString(), anyInt()); + verify(messages, never()).error(anyString(), anyInt()); + } + + @Test + void createCompositionAbsentFromPayload_noMessages() { + var context = mockCreateContext(RootTable_.CDS_NAME); + var root = RootTable.create(); // attachments key not set at all + + cut.processCreateBefore(context, List.of(root)); + + verify(messages, never()).error(anyString(), anyInt()); + verify(messages, never()).warn(anyString(), anyInt()); + } + + // --- UPDATE handler tests --- + + @Test + void updateMaxItemsViolated_activeEntity_error() { + var context = mockUpdateContext(RootTable_.CDS_NAME); + var root = rootWithAttachments(11); + + cut.processUpdateBefore(context, List.of(root)); + + verify(messages).error(anyString(), anyInt()); + verify(messages, never()).warn(anyString(), anyInt()); + } + + @Test + void updateMinItemsViolated_draftData_warning() { + var context = mockUpdateContext(RootTable_.CDS_NAME); + var root = RootTable.create(); + root.setAttachments(List.of()); + root.put("IsActiveEntity", false); + + cut.processUpdateBefore(context, List.of(root)); + + verify(messages).warn(anyString(), anyInt()); + verify(messages, never()).error(anyString(), anyInt()); + } + + // --- Annotation verification tests --- + + @Test + void classHasCorrectAnnotation() { + var annotation = cut.getClass().getAnnotation(ServiceName.class); + + assertThat(annotation.type()).containsOnly(ApplicationService.class); + assertThat(annotation.value()).containsOnly("*"); + } + + @Test + void processCreateBeforeHasCorrectAnnotations() throws NoSuchMethodException { + var method = + cut.getClass() + .getDeclaredMethod("processCreateBefore", CdsCreateEventContext.class, List.class); + + assertThat(method.getAnnotation(Before.class)).isNotNull(); + assertThat(method.getAnnotation(HandlerOrder.class).value()).isEqualTo(HandlerOrder.LATE); + } + + @Test + void processUpdateBeforeHasCorrectAnnotations() throws NoSuchMethodException { + var method = + cut.getClass() + .getDeclaredMethod("processUpdateBefore", CdsUpdateEventContext.class, List.class); + + assertThat(method.getAnnotation(Before.class)).isNotNull(); + assertThat(method.getAnnotation(HandlerOrder.class).value()).isEqualTo(HandlerOrder.LATE); + } + + // --- Helpers --- + + private CdsCreateEventContext mockCreateContext(String cdsName) { + CdsEntity entity = runtime.getCdsModel().findEntity(cdsName).orElseThrow(); + CdsCreateEventContext context = mock(CdsCreateEventContext.class); + when(context.getTarget()).thenReturn(entity); + when(context.getMessages()).thenReturn(messages); + return context; + } + + private CdsUpdateEventContext mockUpdateContext(String cdsName) { + CdsEntity entity = runtime.getCdsModel().findEntity(cdsName).orElseThrow(); + CdsUpdateEventContext context = mock(CdsUpdateEventContext.class); + when(context.getTarget()).thenReturn(entity); + when(context.getMessages()).thenReturn(messages); + return context; + } + + private RootTable rootWithAttachments(int count) { + var root = RootTable.create(); + List attachments = + IntStream.range(0, count) + .mapToObj(i -> (CdsData) Attachments.create()) + .collect(Collectors.toList()); + root.setAttachments(attachments); + return root; + } +} diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftSaveItemsCountValidationHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftSaveItemsCountValidationHandlerTest.java new file mode 100644 index 000000000..be51ac33d --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftSaveItemsCountValidationHandlerTest.java @@ -0,0 +1,154 @@ +/* + * © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.draftservice; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +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.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.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.CdsEntity; +import com.sap.cds.services.draft.DraftSaveEventContext; +import com.sap.cds.services.draft.DraftService; +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.Message; +import com.sap.cds.services.messages.Messages; +import com.sap.cds.services.runtime.CdsRuntime; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class DraftSaveItemsCountValidationHandlerTest { + + private static CdsRuntime runtime; + + private DraftSaveItemsCountValidationHandler cut; + private Messages messages; + private Message messageBuilder; + + @BeforeAll + static void classSetup() { + runtime = RuntimeHelper.runtime; + } + + @BeforeEach + void setup() { + cut = new DraftSaveItemsCountValidationHandler(); + messages = mock(Messages.class); + messageBuilder = mock(Message.class); + when(messages.warn(anyString(), anyInt())).thenReturn(messageBuilder); + when(messages.error(anyString(), anyInt())).thenReturn(messageBuilder); + when(messageBuilder.target(anyString(), any())).thenReturn(messageBuilder); + } + + @Test + void maxItemsViolated_alwaysError() { + var context = mockContext(RootTable_.CDS_NAME); + var root = rootWithAttachments(11); // MaxItems is 10 + + cut.processBeforeDraftSave(context, List.of(root)); + + verify(messages).error(anyString(), anyInt()); + verify(messages, never()).warn(anyString(), anyInt()); + } + + @Test + void maxItemsViolated_evenIfDraftFlagged_stillError() { + var context = mockContext(RootTable_.CDS_NAME); + var root = rootWithAttachments(11); + root.put("IsActiveEntity", false); // draft flag in data — ignored by this handler + + cut.processBeforeDraftSave(context, List.of(root)); + + // DraftSave always errors regardless of IsActiveEntity flag + verify(messages).error(anyString(), anyInt()); + verify(messages, never()).warn(anyString(), anyInt()); + } + + @Test + void minItemsViolated_alwaysError() { + var context = mockContext(RootTable_.CDS_NAME); + var root = RootTable.create(); + root.setAttachments(List.of()); // MinItems is 1 + + cut.processBeforeDraftSave(context, List.of(root)); + + verify(messages).error(anyString(), anyInt()); + verify(messages, never()).warn(anyString(), anyInt()); + } + + @Test + void noViolation_noMessages() { + var context = mockContext(RootTable_.CDS_NAME); + var root = rootWithAttachments(5); // MinItems:1, MaxItems:10 → ok + + cut.processBeforeDraftSave(context, List.of(root)); + + verify(messages, never()).error(anyString(), anyInt()); + verify(messages, never()).warn(anyString(), anyInt()); + } + + @Test + void compositionAbsentFromPayload_noMessages() { + var context = mockContext(RootTable_.CDS_NAME); + var root = RootTable.create(); // attachments not set in payload + + cut.processBeforeDraftSave(context, List.of(root)); + + verify(messages, never()).error(anyString(), anyInt()); + verify(messages, never()).warn(anyString(), anyInt()); + } + + @Test + void classHasCorrectAnnotation() { + var annotation = cut.getClass().getAnnotation(ServiceName.class); + + assertThat(annotation.type()).containsOnly(DraftService.class); + assertThat(annotation.value()).containsOnly("*"); + } + + @Test + void processBeforeDraftSaveHasCorrectAnnotations() throws NoSuchMethodException { + var method = + cut.getClass() + .getDeclaredMethod("processBeforeDraftSave", DraftSaveEventContext.class, List.class); + + assertThat(method.getAnnotation(Before.class)).isNotNull(); + assertThat(method.getAnnotation(HandlerOrder.class).value()).isEqualTo(HandlerOrder.LATE); + } + + // --- Helpers --- + + private DraftSaveEventContext mockContext(String cdsName) { + CdsEntity entity = runtime.getCdsModel().findEntity(cdsName).orElseThrow(); + DraftSaveEventContext context = mock(DraftSaveEventContext.class); + when(context.getTarget()).thenReturn(entity); + when(context.getMessages()).thenReturn(messages); + return context; + } + + private RootTable rootWithAttachments(int count) { + var root = RootTable.create(); + List attachments = + IntStream.range(0, count) + .mapToObj(i -> (CdsData) Attachments.create()) + .collect(Collectors.toList()); + root.setAttachments(attachments); + return root; + } +} 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..b3e48c8e9 100644 --- a/cds-feature-attachments/src/test/resources/cds/db-model.cds +++ b/cds-feature-attachments/src/test/resources/cds/db-model.cds @@ -50,3 +50,10 @@ annotate EventItems.defaultSizeLimitedAttachments with { content @Validation.Maximum; }; +annotate EventItems with { + sizeLimitedAttachments @Validation.MinItems: 2 @Validation.MaxItems: 20; +}; + +annotate Roots with { + attachments @Validation.MinItems: 1 @Validation.MaxItems: 10; +};