From 2b60fe012d5003d9e6ee81a5fd7fa1d09ca7d442 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Fri, 27 Mar 2026 12:05:43 +0100 Subject: [PATCH 01/45] Support inline single attachments via Attachment type --- .../configuration/Registration.java | 7 +- .../ReadAttachmentsHandler.java | 52 +++- .../UpdateAttachmentsHandler.java | 23 +- .../ModifyApplicationHandlerHelper.java | 49 +++- .../helper/ReadonlyDataContextEnhancer.java | 58 ++++- .../AttachmentValidationHelper.java | 2 +- .../mimeTypeValidation/MediaTypeResolver.java | 3 +- .../modifyevents/CreateAttachmentEvent.java | 44 +++- .../MarkAsDeletedAttachmentEvent.java | 37 ++- .../readhelper/BeforeReadItemsModifier.java | 32 +++ .../common/ApplicationHandlerHelper.java | 161 ++++++++++++- .../handler/common/AssociationCascader.java | 10 +- .../handler/common/AttachmentsReader.java | 21 +- .../DraftCancelAttachmentsHandler.java | 25 +- .../DraftPatchAttachmentsHandler.java | 29 ++- .../DefaultAttachmentsServiceHandler.java | 15 +- .../EndTransactionMalwareScanProvider.java | 6 +- .../EndTransactionMalwareScanRunner.java | 15 +- .../malware/AsyncMalwareScanExecutor.java | 5 +- .../malware/AttachmentMalwareScanner.java | 5 +- .../DefaultAttachmentMalwareScanner.java | 89 ++++--- .../attachments-annotations.cds | 8 + .../cds-feature-attachments/attachments.cds | 3 +- .../ReadAttachmentsHandlerTest.java | 63 ++++- .../UpdateAttachmentsHandlerTest.java | 94 ++++++++ .../ModifyApplicationHandlerHelperTest.java | 57 +++++ .../ReadonlyDataContextEnhancerTest.java | 184 +++++++++++++++ .../AttachmentValidationHelperTest.java | 22 +- .../MediaTypeResolverTest.java | 4 +- .../CreateAttachmentEventTest.java | 108 +++++++++ .../MarkAsDeletedAttachmentEventTest.java | 81 ++++++- .../BeforeReadItemsModifierTest.java | 84 +++++++ .../common/ApplicationHandlerHelperTest.java | 222 ++++++++++++++++++ .../common/AssociationCascaderTest.java | 22 ++ .../handler/common/AttachmentsReaderTest.java | 23 ++ .../DraftCancelAttachmentsHandlerTest.java | 18 ++ .../DraftPatchAttachmentsHandlerTest.java | 62 +++++ .../AttachmentsServiceImplHandlerTest.java | 6 +- .../EndTransactionMalwareScanRunnerTest.java | 21 +- .../DefaultAttachmentMalwareScannerTest.java | 26 +- .../src/test/resources/cds/db-model.cds | 2 + .../app/admin-books/fiori-service.cds | 14 ++ samples/bookshop/pom.xml | 2 +- samples/bookshop/srv/attachments.cds | 18 +- 44 files changed, 1686 insertions(+), 146 deletions(-) create mode 100644 cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.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 9f5d72e93..b33db8fb3 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 @@ -129,9 +129,9 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { new DefaultAttachmentMalwareScanner(persistenceService, attachmentService, scanClient); EndTransactionMalwareScanProvider malwareScanEndTransactionListener = - (attachmentEntity, contentId) -> + (attachmentEntity, contentId, inlinePrefix) -> new EndTransactionMalwareScanRunner( - attachmentEntity, contentId, malwareScanner, runtime); + attachmentEntity, contentId, inlinePrefix, malwareScanner, runtime); // register event handlers for attachment service configurer.eventHandler( @@ -157,7 +157,8 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { eventFactory, attachmentsReader, outboxedAttachmentService, storage, defaultMaxSize)); configurer.eventHandler(new DeleteAttachmentsHandler(attachmentsReader, deleteEvent)); EndTransactionMalwareScanRunner scanRunner = - new EndTransactionMalwareScanRunner(null, null, malwareScanner, runtime); + new EndTransactionMalwareScanRunner( + null, null, Optional.empty(), malwareScanner, runtime); configurer.eventHandler( new ReadAttachmentsHandler( attachmentService, diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java index 7f98bc15b..8fa59c6fb 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java @@ -43,6 +43,7 @@ import java.util.Map; import java.util.Map.Entry; import java.util.Objects; +import java.util.Optional; import java.util.function.Supplier; import java.util.stream.Collectors; import org.slf4j.Logger; @@ -102,8 +103,11 @@ void processBefore(CdsReadEventContext context) { CdsModel cdsModel = context.getModel(); List fieldNames = getAttachmentAssociations(cdsModel, context.getTarget(), "", new ArrayList<>()); - if (!fieldNames.isEmpty()) { - CqnSelect resultCqn = CQL.copy(context.getCqn(), new BeforeReadItemsModifier(fieldNames)); + List inlinePrefixes = + ApplicationHandlerHelper.getInlineAttachmentFieldNames(context.getTarget()); + if (!fieldNames.isEmpty() || !inlinePrefixes.isEmpty()) { + CqnSelect resultCqn = + CQL.copy(context.getCqn(), new BeforeReadItemsModifier(fieldNames, inlinePrefixes)); context.setCqn(resultCqn); } } @@ -117,10 +121,21 @@ void processAfter(CdsReadEventContext context, List data) { Converter converter = (path, element, value) -> { - Attachments attachment = Attachments.of(path.target().values()); + Attachments attachment; + // Check if this is an inline attachment field + Optional inlinePrefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix( + path.target().type(), element.getName()); + if (inlinePrefix.isPresent()) { + attachment = + ApplicationHandlerHelper.extractInlineAttachment( + path.target().values(), inlinePrefix.get()); + } else { + attachment = Attachments.of(path.target().values()); + } InputStream content = attachment.getContent(); if (nonNull(attachment.getContentId())) { - verifyStatus(path, attachment); + verifyStatus(path, attachment, inlinePrefix); Supplier supplier = nonNull(content) ? () -> content @@ -140,7 +155,14 @@ void processAfter(CdsReadEventContext context, List data) { private List getAttachmentAssociations( CdsModel model, CdsEntity entity, String associationName, List processedEntities) { List associationNames = new ArrayList<>(); - if (ApplicationHandlerHelper.isMediaEntity(entity)) { + if (ApplicationHandlerHelper.isDirectMediaEntity(entity)) { + associationNames.add(associationName); + } + + // Also add inline attachment field names (for inline type support) + List inlineFields = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); + if (!inlineFields.isEmpty() && !associationNames.contains(associationName)) { + // Use empty string to signify inline fields on the root entity associationNames.add(associationName); } @@ -170,7 +192,7 @@ private List getAttachmentAssociations( return associationNames; } - private void verifyStatus(Path path, Attachments attachment) { + private void verifyStatus(Path path, Attachments attachment, Optional inlinePrefix) { if (areKeysEmpty(path.target().keys())) { String currentStatus = attachment.getStatus(); logger.debug( @@ -179,13 +201,13 @@ private void verifyStatus(Path path, Attachments attachment) { currentStatus); if (scannerAvailable && needsScan(currentStatus, attachment.getScannedAt())) { if (StatusCode.CLEAN.equals(currentStatus)) { - transitionToScanning(path.target().entity(), attachment); + transitionToScanning(path.target().entity(), attachment, inlinePrefix); } logger.debug( "Scanning content with ID {} for malware, has current status {}", attachment.getContentId(), currentStatus); - scanExecutor.scanAsync(path.target().entity(), attachment.getContentId()); + scanExecutor.scanAsync(path.target().entity(), attachment.getContentId(), inlinePrefix); } statusValidator.verifyStatus(attachment.getStatus()); } @@ -204,26 +226,34 @@ private boolean isScanStale(Instant scannedAt) { return scannedAt == null || Instant.now().isAfter(scannedAt.plus(RESCAN_THRESHOLD)); } - private void transitionToScanning(CdsEntity entity, Attachments attachment) { + private void transitionToScanning( + CdsEntity entity, Attachments attachment, Optional inlinePrefix) { logger.debug( "Attachment {} has stale scan (scannedAt={}), transitioning to SCANNING for rescan.", attachment.getContentId(), attachment.getScannedAt()); + String contentIdCol = resolveColumn(Attachments.CONTENT_ID, inlinePrefix); + String statusCol = resolveColumn(Attachments.STATUS, inlinePrefix); + Attachments updateData = Attachments.create(); - updateData.setStatus(StatusCode.SCANNING); + updateData.put(statusCol, StatusCode.SCANNING); // Filter by contentId because primary keys are unavailable during content-only reads // (areKeysEmpty returns true). This is consistent with DefaultAttachmentMalwareScanner. CqnUpdate update = Update.entity(entity) .data(updateData) - .where(entry -> entry.get(Attachments.CONTENT_ID).eq(attachment.getContentId())); + .where(entry -> entry.get(contentIdCol).eq(attachment.getContentId())); persistenceService.run(update); attachment.setStatus(StatusCode.SCANNING); } + private static String resolveColumn(String fieldName, Optional inlinePrefix) { + return inlinePrefix.map(p -> p + "_" + fieldName).orElse(fieldName); + } + private boolean areKeysEmpty(Map keys) { return keys.values().stream().allMatch(Objects::isNull); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandler.java index 0747b36c2..c5179962d 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandler.java @@ -98,10 +98,25 @@ void processBefore(CdsUpdateEventContext context, List data) { } private boolean associationsAreUnchanged(CdsEntity entity, List data) { - return entity - .compositions() - .noneMatch( - association -> data.stream().anyMatch(d -> d.containsKey(association.getName()))); + // Check composition associations + boolean compositionsUnchanged = + entity + .compositions() + .noneMatch( + association -> data.stream().anyMatch(d -> d.containsKey(association.getName()))); + + // Also check inline attachment fields + List inlinePrefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); + boolean inlineUnchanged = + inlinePrefixes.stream() + .noneMatch( + prefix -> + data.stream() + .anyMatch( + d -> + d.keySet().stream().anyMatch(key -> key.startsWith(prefix + "_")))); + + return compositionsUnchanged && inlineUnchanged; } private void deleteRemovedAttachments( diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java index 2c315bbe9..bbafcee7b 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java @@ -20,6 +20,7 @@ import java.io.InputStream; import java.util.List; import java.util.Map; +import java.util.Optional; public final class ModifyApplicationHandlerHelper { @@ -86,7 +87,24 @@ public static InputStream handleAttachmentForEntity( Map keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys()); ReadonlyDataContextEnhancer.restoreReadonlyFields((CdsData) path.target().values()); Attachments attachment = getExistingAttachment(keys, existingAttachments); - String contentId = (String) path.target().values().get(Attachments.CONTENT_ID); + + // For inline attachment fields, extract contentId using prefix + String contentId; + Optional inlinePrefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix( + path.target().entity(), Attachments.CONTENT_ID); + if (inlinePrefix.isEmpty()) { + // Try to detect prefix from any inline attachment field present in data + contentId = + resolveInlineOrDirectField( + path.target().values(), path.target().entity(), Attachments.CONTENT_ID); + } else { + contentId = + (String) path.target().values().get(inlinePrefix.get() + "_" + Attachments.CONTENT_ID); + } + if (contentId == null) { + contentId = (String) path.target().values().get(Attachments.CONTENT_ID); + } String contentLength = eventContext.getParameterInfo().getHeader("Content-Length"); String maxSizeStr = getValMaxValue(path.target().entity(), defaultMaxSize); eventContext.put( @@ -122,8 +140,20 @@ public static InputStream handleAttachmentForEntity( } private static String getValMaxValue(CdsEntity entity, String defaultMaxSize) { + // Try direct content element first (composition-based) return entity .findElement("content") + .or( + () -> { + // Try inline attachment content elements (e.g. profilePicture_content) + List prefixes = + ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); + for (String prefix : prefixes) { + var found = entity.findElement(prefix + "_content"); + if (found.isPresent()) return found; + } + return Optional.empty(); + }) .flatMap(e -> e.findAnnotation("Validation.Maximum")) .map(CdsAnnotation::getValue) .filter(v -> !"true".equals(v.toString())) @@ -131,6 +161,23 @@ private static String getValMaxValue(CdsEntity entity, String defaultMaxSize) { .orElse(defaultMaxSize); } + /** Resolves a field value from either direct or inline prefixed field name. */ + private static String resolveInlineOrDirectField( + Map values, + com.sap.cds.reflect.CdsStructuredType entityType, + String fieldName) { + // First try direct field + Object direct = values.get(fieldName); + if (direct != null) return (String) direct; + // Then try inline prefixed fields + List prefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entityType); + for (String prefix : prefixes) { + Object prefixed = values.get(prefix + "_" + fieldName); + if (prefixed != null) return (String) prefixed; + } + return null; + } + private static Attachments getExistingAttachment( Map keys, List existingAttachments) { return existingAttachments.stream() diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancer.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancer.java index 2df30e3d1..83db2998a 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancer.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancer.java @@ -11,6 +11,7 @@ import com.sap.cds.reflect.CdsEntity; import java.util.List; import java.util.Objects; +import java.util.Optional; /** * The class {@link ReadonlyDataContextEnhancer} provides methods to backup and restore readonly @@ -35,14 +36,38 @@ public static void preserveReadonlyFields(CdsEntity target, List data, Validator validator = (path, element, value) -> { if (isDraft) { - Attachments values = Attachments.of(path.target().values()); - Attachments attachment = Attachments.create(); - attachment.setContentId(values.getContentId()); - attachment.setStatus(values.getStatus()); - attachment.setScannedAt(values.getScannedAt()); - path.target().values().put(DRAFT_READONLY_CONTEXT, attachment); + // Determine if this is an inline attachment field + Optional inlinePrefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix( + path.target().type(), element.getName()); + if (inlinePrefix.isPresent()) { + // Inline attachment: use prefixed field names + String prefix = inlinePrefix.get() + "_"; + Attachments attachment = Attachments.create(); + attachment.setContentId( + (String) path.target().values().get(prefix + Attachments.CONTENT_ID)); + attachment.setStatus( + (String) path.target().values().get(prefix + Attachments.STATUS)); + attachment.setScannedAt( + (java.time.Instant) path.target().values().get(prefix + Attachments.SCANNED_AT)); + path.target().values().put(prefix + DRAFT_READONLY_CONTEXT, attachment); + } else { + // Composition-based attachment: use direct field names + Attachments values = Attachments.of(path.target().values()); + Attachments attachment = Attachments.create(); + attachment.setContentId(values.getContentId()); + attachment.setStatus(values.getStatus()); + attachment.setScannedAt(values.getScannedAt()); + path.target().values().put(DRAFT_READONLY_CONTEXT, attachment); + } } else { path.target().values().remove(DRAFT_READONLY_CONTEXT); + // Also remove inline prefixed draft readonly contexts + List prefixes = + ApplicationHandlerHelper.getInlineAttachmentFieldNames(path.target().type()); + for (String prefix : prefixes) { + path.target().values().remove(prefix + "_" + DRAFT_READONLY_CONTEXT); + } } }; @@ -53,11 +78,12 @@ public static void preserveReadonlyFields(CdsEntity target, List data, /** * Restores the readonly fields with the backup from the data in the custom field {@value - * #DRAFT_READONLY_CONTEXT}. + * #DRAFT_READONLY_CONTEXT}. Supports both composition-based and inline attachment fields. * * @param data the {@link CdsData data} to restore with readonly fields */ public static void restoreReadonlyFields(CdsData data) { + // Restore composition-based readonly fields CdsData readOnlyData = (CdsData) data.get(DRAFT_READONLY_CONTEXT); if (Objects.nonNull(readOnlyData)) { data.put(Attachments.CONTENT_ID, readOnlyData.get(Attachments.CONTENT_ID)); @@ -65,6 +91,24 @@ public static void restoreReadonlyFields(CdsData data) { data.put(Attachments.SCANNED_AT, readOnlyData.get(Attachments.SCANNED_AT)); data.remove(DRAFT_READONLY_CONTEXT); } + + // Restore inline attachment readonly fields + for (String key : List.copyOf(data.keySet())) { + if (key.endsWith("_" + DRAFT_READONLY_CONTEXT)) { + String prefix = key.substring(0, key.length() - DRAFT_READONLY_CONTEXT.length() - 1); + CdsData inlineReadOnlyData = (CdsData) data.get(key); + if (Objects.nonNull(inlineReadOnlyData)) { + data.put( + prefix + "_" + Attachments.CONTENT_ID, + inlineReadOnlyData.get(Attachments.CONTENT_ID)); + data.put(prefix + "_" + Attachments.STATUS, inlineReadOnlyData.get(Attachments.STATUS)); + data.put( + prefix + "_" + Attachments.SCANNED_AT, + inlineReadOnlyData.get(Attachments.SCANNED_AT)); + data.remove(key); + } + } + } } private ReadonlyDataContextEnhancer() { diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java index 72df1920b..9cca79201 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java @@ -39,7 +39,7 @@ public static void validateMediaAttachments( CdsModel cdsModel = cdsRuntime.getCdsModel(); List mediaEntityNames = - ApplicationHandlerHelper.isMediaEntity(entity) + ApplicationHandlerHelper.isDirectMediaEntity(entity) ? List.of(entity.getQualifiedName()) : cascader.findMediaEntityNames(cdsModel, entity); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolver.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolver.java index b96d2e4c9..58e7a823e 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolver.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolver.java @@ -41,7 +41,8 @@ private static Optional> fetchAcceptableMediaTypes(CdsEntity entity public static Optional> getAcceptableMediaTypesAnnotation( CdsEntity entity) { - return Optional.ofNullable(entity.getElement(CONTENT_ELEMENT)) + return entity + .findElement(CONTENT_ELEMENT) .flatMap(element -> element.findAnnotation(ACCEPTABLE_MEDIA_TYPES_ANNOTATION)); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java index 6d66f2793..d388b36f5 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java @@ -17,6 +17,7 @@ import com.sap.cds.services.EventContext; import com.sap.cds.services.changeset.ChangeSetListener; import java.io.InputStream; +import java.util.List; import java.util.Map; import java.util.Optional; import org.slf4j.Logger; @@ -49,8 +50,13 @@ public InputStream processEvent( path.target().entity().getQualifiedName()); Map values = path.target().values(); Map keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys()); - Optional mimeTypeOptional = getFieldValue(MediaData.MIME_TYPE, values, attachment); - Optional fileNameOptional = getFieldValue(MediaData.FILE_NAME, values, attachment); + + // Detect inline prefix if applicable + Optional inlinePrefix = detectInlinePrefix(path); + Optional mimeTypeOptional = + getFieldValue(MediaData.MIME_TYPE, values, attachment, inlinePrefix); + Optional fileNameOptional = + getFieldValue(MediaData.FILE_NAME, values, attachment, inlinePrefix); CreateAttachmentInput createEventInput = new CreateAttachmentInput( @@ -64,16 +70,42 @@ public InputStream processEvent( listenerProvider.provideListener(result.contentId(), eventContext.getCdsRuntime()); eventContext.getChangeSetContext().register(createListener); - path.target().values().put(Attachments.CONTENT_ID, result.contentId()); - path.target().values().put(Attachments.STATUS, result.status()); + // Set contentId and status using correct field names (prefixed for inline) + String contentIdField = + inlinePrefix.map(p -> p + "_" + Attachments.CONTENT_ID).orElse(Attachments.CONTENT_ID); + String statusField = + inlinePrefix.map(p -> p + "_" + Attachments.STATUS).orElse(Attachments.STATUS); + path.target().values().put(contentIdField, result.contentId()); + path.target().values().put(statusField, result.status()); if (nonNull(result.scannedAt())) { - path.target().values().put(Attachments.SCANNED_AT, result.scannedAt()); + String scannedAtField = + inlinePrefix.map(p -> p + "_" + Attachments.SCANNED_AT).orElse(Attachments.SCANNED_AT); + path.target().values().put(scannedAtField, result.scannedAt()); } return result.isInternalStored() ? content : null; } + private static Optional detectInlinePrefix(Path path) { + List prefixes = + ApplicationHandlerHelper.getInlineAttachmentFieldNames(path.target().entity()); + if (!prefixes.isEmpty() + && !path.target().entity().getAnnotationValue("_is_media_data", false)) { + return Optional.of(prefixes.get(0)); + } + return Optional.empty(); + } + private static Optional getFieldValue( - String fieldName, Map values, Attachments attachment) { + String fieldName, + Map values, + Attachments attachment, + Optional inlinePrefix) { + // Try prefixed field name first (for inline types) + if (inlinePrefix.isPresent()) { + Object prefixedValue = values.get(inlinePrefix.get() + "_" + fieldName); + if (nonNull(prefixedValue)) return Optional.of((String) prefixedValue); + } + // Fall back to direct field name Object annotationValue = values.get(fieldName); Object value = nonNull(annotationValue) ? annotationValue : attachment.get(fieldName); return Optional.ofNullable((String) value); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java index c1316ee9f..ff86c4497 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java @@ -7,12 +7,16 @@ import static java.util.Objects.requireNonNull; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; +import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.model.service.MarkAsDeletedInput; import com.sap.cds.ql.cqn.Path; import com.sap.cds.services.EventContext; import com.sap.cds.services.draft.DraftService; import java.io.InputStream; +import java.util.List; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -51,14 +55,37 @@ public InputStream processEvent( qualifiedName); } if (nonNull(path)) { - String newContentId = (String) path.target().values().get(Attachments.CONTENT_ID); + Optional inlinePrefix = detectInlinePrefix(path); + String contentIdField = resolveField(Attachments.CONTENT_ID, inlinePrefix); + String statusField = resolveField(Attachments.STATUS, inlinePrefix); + String scannedAtField = resolveField(Attachments.SCANNED_AT, inlinePrefix); + String mimeTypeField = resolveField(MediaData.MIME_TYPE, inlinePrefix); + String fileNameField = resolveField(MediaData.FILE_NAME, inlinePrefix); + + String newContentId = (String) path.target().values().get(contentIdField); if (nonNull(newContentId) && newContentId.equals(attachment.getContentId()) - || !path.target().values().containsKey(Attachments.CONTENT_ID)) { - path.target().values().put(Attachments.CONTENT_ID, null); - path.target().values().put(Attachments.STATUS, null); - path.target().values().put(Attachments.SCANNED_AT, null); + || !path.target().values().containsKey(contentIdField)) { + path.target().values().put(contentIdField, null); + path.target().values().put(statusField, null); + path.target().values().put(scannedAtField, null); + path.target().values().put(mimeTypeField, null); + path.target().values().put(fileNameField, null); } } return content; } + + private static Optional detectInlinePrefix(Path path) { + List prefixes = + ApplicationHandlerHelper.getInlineAttachmentFieldNames(path.target().entity()); + if (!prefixes.isEmpty() + && !path.target().entity().getAnnotationValue("_is_media_data", false)) { + return Optional.of(prefixes.get(0)); + } + return Optional.empty(); + } + + private static String resolveField(String fieldName, Optional inlinePrefix) { + return inlinePrefix.map(p -> p + "_" + fieldName).orElse(fieldName); + } } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifier.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifier.java index e5b16ab7b..3e46b17d6 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifier.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifier.java @@ -10,6 +10,7 @@ import com.sap.cds.ql.cqn.CqnSelectListItem; import com.sap.cds.ql.cqn.Modifier; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -25,9 +26,16 @@ public class BeforeReadItemsModifier implements Modifier { private static final String ROOT_ASSOCIATION = ""; private final List mediaAssociations; + private final List inlineAttachmentPrefixes; public BeforeReadItemsModifier(List mediaAssociations) { + this(mediaAssociations, Collections.emptyList()); + } + + public BeforeReadItemsModifier( + List mediaAssociations, List inlineAttachmentPrefixes) { this.mediaAssociations = mediaAssociations; + this.inlineAttachmentPrefixes = inlineAttachmentPrefixes; } @Override @@ -79,6 +87,30 @@ private void enhanceWithNewFieldForMediaAssociation( listToEnhance.add(CQL.get(Attachments.STATUS)); listToEnhance.add(CQL.get(Attachments.SCANNED_AT)); } + // Also add inline attachment prefixed fields, but only when the content field + // is explicitly selected (mirroring the composition-based guard above). + // When the items list is empty or contains only a star (SELECT *), all columns + // are already included, so adding explicit columns would break the query by + // replacing SELECT * with a partial column list. + if (ROOT_ASSOCIATION.equals(association)) { + for (String prefix : inlineAttachmentPrefixes) { + String prefixedContent = prefix + "_" + MediaData.CONTENT; + String prefixedContentId = prefix + "_" + Attachments.CONTENT_ID; + String prefixedStatus = prefix + "_" + Attachments.STATUS; + String prefixedScannedAt = prefix + "_" + Attachments.SCANNED_AT; + if (list.stream().anyMatch(item -> isItemRefFieldWithName(item, prefixedContent)) + && list.stream().noneMatch(item -> isItemRefFieldWithName(item, prefixedContentId))) { + logger.debug( + "Adding inline attachment fields: {}, {} and {}", + prefixedContentId, + prefixedStatus, + prefixedScannedAt); + listToEnhance.add(CQL.get(prefixedContentId)); + listToEnhance.add(CQL.get(prefixedStatus)); + listToEnhance.add(CQL.get(prefixedScannedAt)); + } + } + } } private boolean isMediaAssociationAndNeedNewContentIdField( diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java index c8308f513..eb4fec74e 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java @@ -10,6 +10,7 @@ import com.sap.cds.CdsDataProcessor.Filter; import com.sap.cds.CdsDataProcessor.Validator; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.reflect.CdsElement; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.reflect.CdsStructuredType; import com.sap.cds.services.draft.Drafts; @@ -17,6 +18,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; /** @@ -29,12 +31,19 @@ public final class ApplicationHandlerHelper { /** * A filter for media content fields. The filter checks if the entity is a media entity and if the - * element has the annotation "Core.MediaType". + * element has the annotation "Core.MediaType". Also supports inline attachment type fields where + * the structured type is flattened into the parent entity. */ public static final Filter MEDIA_CONTENT_FILTER = - (path, element, type) -> - isMediaEntity(path.target().type()) - && element.findAnnotation(ANNOTATION_CORE_MEDIA_TYPE).isPresent(); + (path, element, type) -> { + // Case 1: Composition-based attachment entity (existing behavior) + if (path.target().type().getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false) + && element.findAnnotation(ANNOTATION_CORE_MEDIA_TYPE).isPresent()) { + return true; + } + // Case 2: Inline attachment type field (flattened into parent entity) + return isInlineAttachmentContentField(path.target().type(), element); + }; /** * Checks if the data contains a content field. @@ -53,15 +62,111 @@ public static boolean containsContentField(CdsEntity entity, Listtrue if the entity is a media entity, false otherwise */ public static boolean isMediaEntity(CdsStructuredType baseEntity) { + if (baseEntity.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false)) { + return true; + } + return hasInlineAttachmentElements(baseEntity); + } + + /** + * Checks if the entity is directly annotated as a media entity (without considering inline + * elements). Used for composition-based attachment detection. + * + * @param baseEntity The entity to check + * @return true if the entity itself has the annotation + */ + public static boolean isDirectMediaEntity(CdsStructuredType baseEntity) { return baseEntity.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false); } + /** + * Checks if the entity has inline attachment elements. In the flattened CDS model, these appear + * as elements with the annotation "_is_media_data" on the element itself, where the entity is not + * directly annotated as a media entity. The flattened element names follow the pattern + * "prefix_content", "prefix_contentId", etc. + * + * @param entity The entity to check + * @return true if inline attachment elements exist + */ + public static boolean hasInlineAttachmentElements(CdsStructuredType entity) { + if (entity.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false)) { + return false; // Entity itself is a media entity (composition-based), not inline + } + return !getInlineAttachmentFieldNames(entity).isEmpty(); + } + + /** + * Returns the inline attachment element name prefixes for a given entity. In the flattened CDS + * model, inline attachment fields appear as "prefix_content", "prefix_contentId", etc. with + * element-level "_is_media_data" annotation. This method finds all unique prefixes by looking for + * elements ending with "_content" that have the annotation. + * + * @param entity The entity to inspect + * @return list of inline attachment field name prefixes (e.g. ["profilePicture"]) + */ + public static List getInlineAttachmentFieldNames(CdsStructuredType entity) { + List fieldNames = new ArrayList<>(); + var elements = entity.elements(); + if (elements == null) return fieldNames; + String contentSuffix = "_content"; + elements + .filter(e -> e.getName().endsWith(contentSuffix)) + .filter(e -> e.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false)) + .filter(e -> e.findAnnotation(ANNOTATION_CORE_MEDIA_TYPE).isPresent()) + .forEach( + e -> { + String prefix = + e.getName().substring(0, e.getName().length() - contentSuffix.length()); + if (!prefix.isEmpty() && !fieldNames.contains(prefix)) { + fieldNames.add(prefix); + } + }); + return fieldNames; + } + + /** + * Checks if an element is a flattened content field from an inline Attachment type. For example, + * "profilePicture_content" where "profilePicture" is of type Attachment. In the flattened model, + * this is an element that ends with "_content", has the "_is_media_data" annotation, and has the + * "Core.MediaType" annotation. + * + * @param entity The parent entity + * @param element The element to check + * @return true if the element is an inline attachment content field + */ + public static boolean isInlineAttachmentContentField( + CdsStructuredType entity, CdsElement element) { + if (entity.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false)) { + return false; // This is a composition-based attachment entity, not inline + } + String elementName = element.getName(); + return elementName.contains("_") + && element.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false) + && element.findAnnotation(ANNOTATION_CORE_MEDIA_TYPE).isPresent(); + } + + /** + * Finds the inline attachment prefix for a given flattened element name. For example, given + * "profilePicture_content", returns Optional of "profilePicture". Uses the known inline prefixes + * from the entity to match against the element name. + * + * @param entity The parent entity + * @param elementName The flattened element name + * @return Optional containing the prefix, or empty if not an inline attachment field + */ + public static Optional getInlineAttachmentPrefix( + CdsStructuredType entity, String elementName) { + return getInlineAttachmentFieldNames(entity).stream() + .filter(prefix -> elementName.startsWith(prefix + "_")) + .findFirst(); + } + /** * Extracts key fields from CdsData based on the entity definition. * @@ -86,6 +191,7 @@ public static Map extractKeys(CdsData data, CdsEntity entity) { /** * Condenses the attachments from the given data into a list of {@link Attachments attachments}. + * Supports both composition-based and inline attachment type fields. * * @param data the list of {@link CdsData} to process * @param entity the {@link CdsEntity entity} type of the given data @@ -96,12 +202,55 @@ public static List condenseAttachments( List resultList = new ArrayList<>(); Validator validator = - (path, element, value) -> resultList.add(Attachments.of(path.target().values())); + (path, element, value) -> { + // For composition-based: path.target() is the attachment entity + if (path.target().type().getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false)) { + resultList.add(Attachments.of(path.target().values())); + } else { + // For inline type: extract prefixed fields from parent entity + Optional prefix = + getInlineAttachmentPrefix(path.target().type(), element.getName()); + if (prefix.isPresent()) { + Attachments attachment = + extractInlineAttachment(path.target().values(), prefix.get()); + // Avoid duplicates (same prefix already processed) + if (resultList.stream() + .noneMatch( + existing -> + nonNull(existing.getContentId()) + && existing.getContentId().equals(attachment.getContentId()))) { + resultList.add(attachment); + } + } + } + }; CdsDataProcessor.create().addValidator(MEDIA_CONTENT_FILTER, validator).process(data, entity); return resultList; } + /** + * Extracts inline attachment data from a parent entity's values by stripping the prefix. For + * example, from "profilePicture_contentId" extracts "contentId". + * + * @param parentValues the parent entity values map + * @param prefix the inline field prefix (e.g. "profilePicture") + * @return an Attachments object with the extracted values + */ + public static Attachments extractInlineAttachment( + Map parentValues, String prefix) { + Attachments attachment = Attachments.create(); + String prefixWithUnderscore = prefix + "_"; + parentValues.forEach( + (key, value) -> { + if (key.startsWith(prefixWithUnderscore)) { + String logicalName = key.substring(prefixWithUnderscore.length()); + attachment.put(logicalName, value); + } + }); + return attachment; + } + public static boolean areKeysInData(Map keys, CdsData data) { return keys.entrySet().stream() .allMatch( diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java index 95e9fbf50..652dc2b04 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java @@ -70,7 +70,7 @@ private List> getAttachmentAssociationPath( var currentList = new LinkedList(); var localProcessEntities = new ArrayList(); - var isMediaEntity = ApplicationHandlerHelper.isMediaEntity(entity); + var isMediaEntity = ApplicationHandlerHelper.isDirectMediaEntity(entity); if (isMediaEntity) { var identifier = new AssociationIdentifier(associationName, entity.getQualifiedName()); firstList.addLast(identifier); @@ -81,6 +81,14 @@ private List> getAttachmentAssociationPath( return internalResultList; } + // Also check for inline attachment type fields on the entity itself + if (ApplicationHandlerHelper.hasInlineAttachmentElements(entity)) { + var identifier = new AssociationIdentifier(associationName, entity.getQualifiedName()); + var inlinePath = new LinkedList<>(firstList); + inlinePath.addLast(identifier); + internalResultList.add(inlinePath); + } + Map associations = entity .elements() diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java index 9eb80d2fc..1b1653860 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java @@ -12,6 +12,7 @@ import com.sap.cds.ql.Select; import com.sap.cds.ql.StructuredType; import com.sap.cds.ql.cqn.CqnFilterableStatement; +import com.sap.cds.ql.cqn.CqnSelectListItem; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.reflect.CdsModel; import com.sap.cds.services.persistence.PersistenceService; @@ -46,10 +47,22 @@ public List readAttachments( NodeTree nodePath = cascader.findEntityPath(model, entity); List> expandList = buildExpandList(nodePath); - Select select = - !expandList.isEmpty() - ? Select.from(statement.ref()).columns(expandList) - : Select.from(statement.ref()).columns(StructuredType::_all); + // Also include inline attachment fields directly in the select + List inlineFields = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); + List inlineColumns = new ArrayList<>(); + for (String fieldName : inlineFields) { + inlineColumns.add(CQL.get(fieldName + "_" + Attachments.CONTENT_ID)); + inlineColumns.add(CQL.get(fieldName + "_" + Attachments.STATUS)); + } + + Select select; + if (!expandList.isEmpty() || !inlineColumns.isEmpty()) { + List allItems = new ArrayList<>(inlineColumns); + allItems.addAll(expandList); + select = Select.from(statement.ref()).columns(allItems); + } else { + select = Select.from(statement.ref()).columns(StructuredType::_all); + } statement.where().ifPresent(select::where); Result result = persistence.run(select); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java index b71231943..27fd733b0 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java @@ -43,9 +43,21 @@ public class DraftCancelAttachmentsHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(DraftCancelAttachmentsHandler.class); private static final Filter contentIdFilter = - (path, element, type) -> - ApplicationHandlerHelper.isMediaEntity(path.target().type()) - && element.getName().equals(Attachments.CONTENT_ID); + (path, element, type) -> { + // Case 1: Composition-based attachment entity + if (ApplicationHandlerHelper.isDirectMediaEntity(path.target().type()) + && element.getName().equals(Attachments.CONTENT_ID)) { + return true; + } + // Case 2: Inline attachment type — check for prefixed contentId + String elementName = element.getName(); + if (elementName.endsWith("_" + Attachments.CONTENT_ID)) { + return ApplicationHandlerHelper.getInlineAttachmentPrefix( + path.target().type(), elementName) + .isPresent(); + } + return false; + }; private final AttachmentsReader attachmentsReader; private final MarkAsDeletedAttachmentEvent deleteEvent; @@ -118,7 +130,12 @@ private boolean deepSearchForAttachmentsRecursive(CdsEntity entity, HashSet existingAttachments; + Optional inlinePrefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix( + path.target().entity(), element.getName()); + if (inlinePrefix.isPresent()) { + // For inline attachments, the DB result has flattened column names (e.g. + // profileIcon_contentId). + // Extract to unprefixed Attachments and carry over parent entity keys for matching. + Map parentKeys = path.target().keys(); + existingAttachments = + result.listOf(Attachments.class).stream() + .map( + raw -> { + Attachments extracted = + ApplicationHandlerHelper.extractInlineAttachment( + raw, inlinePrefix.get()); + parentKeys.forEach(extracted::putIfAbsent); + return extracted; + }) + .collect(Collectors.toList()); + } else { + existingAttachments = result.listOf(Attachments.class); + } + return ModifyApplicationHandlerHelper.handleAttachmentForEntity( - result.listOf(Attachments.class), + existingAttachments, eventFactory, context, path, diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/DefaultAttachmentsServiceHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/DefaultAttachmentsServiceHandler.java index c14217499..7a32af544 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/DefaultAttachmentsServiceHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/DefaultAttachmentsServiceHandler.java @@ -5,19 +5,23 @@ import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.StatusCode; +import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.handler.transaction.EndTransactionMalwareScanProvider; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentMarkAsDeletedEventContext; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentReadEventContext; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentRestoreEventContext; +import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.changeset.ChangeSetListener; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.After; 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 java.util.List; import java.util.Objects; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -66,12 +70,21 @@ void createAttachment(AttachmentCreateEventContext context) { */ @After void afterCreateAttachment(AttachmentCreateEventContext context) { + Optional inlinePrefix = detectInlinePrefix(context.getAttachmentEntity()); ChangeSetListener listener = malwareScanProvider.getChangeSetListener( - context.getAttachmentEntity(), context.getContentId()); + context.getAttachmentEntity(), context.getContentId(), inlinePrefix); context.getChangeSetContext().register(listener); } + private static Optional detectInlinePrefix(CdsEntity entity) { + List prefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); + if (!prefixes.isEmpty() && !entity.getAnnotationValue("_is_media_data", false)) { + return Optional.of(prefixes.get(0)); + } + return Optional.empty(); + } + @On @HandlerOrder(DEFAULT_ON) void markAttachmentAsDeleted(AttachmentMarkAsDeletedEventContext context) { diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanProvider.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanProvider.java index da667e0d5..28f110183 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanProvider.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanProvider.java @@ -5,6 +5,7 @@ import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.changeset.ChangeSetListener; +import java.util.Optional; /** * This interface provides a {@link ChangeSetListener} for the malware scan after the transaction is @@ -17,7 +18,10 @@ public interface EndTransactionMalwareScanProvider { * * @param attachmentEntity The entity containing the attachment to scan * @param contentId The ID of the attachment content + * @param inlinePrefix For inline attachments, the field name prefix; empty for composition-based + * attachments * @return The {@link ChangeSetListener} for the malware scan after the transaction is completed */ - ChangeSetListener getChangeSetListener(CdsEntity attachmentEntity, String contentId); + ChangeSetListener getChangeSetListener( + CdsEntity attachmentEntity, String contentId, Optional inlinePrefix); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunner.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunner.java index a0f380fc6..b48bf95cc 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunner.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunner.java @@ -10,6 +10,7 @@ import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.RequestContextRunner; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; import org.slf4j.Logger; @@ -22,12 +23,14 @@ * * @param attachmentEntity The attachment entity to be scanned * @param contentId The content ID of the attachment + * @param inlinePrefix For inline attachments, the field name prefix; empty for composition-based * @param attachmentMalwareScanner The attachment malware scanner to be used for scanning * @param runtime The runtime instance to be used for creating the request context */ public record EndTransactionMalwareScanRunner( CdsEntity attachmentEntity, String contentId, + Optional inlinePrefix, AttachmentMalwareScanner attachmentMalwareScanner, CdsRuntime runtime) implements ChangeSetListener, AsyncMalwareScanExecutor { @@ -38,16 +41,18 @@ public record EndTransactionMalwareScanRunner( @Override public void afterClose(boolean completed) { if (completed) { - startScanning(attachmentEntity, contentId); + startScanning(attachmentEntity, contentId, inlinePrefix); } } @Override - public void scanAsync(CdsEntity attachmentEntity, String contentId) { - startScanning(attachmentEntity, contentId); + public void scanAsync( + CdsEntity attachmentEntity, String contentId, Optional inlinePrefix) { + startScanning(attachmentEntity, contentId, inlinePrefix); } - private void startScanning(CdsEntity attachmentEntityToScan, String contentId) { + private void startScanning( + CdsEntity attachmentEntityToScan, String contentId, Optional prefix) { // get current request context RequestContextRunner runner = runtime.requestContext(); @@ -71,7 +76,7 @@ private void startScanning(CdsEntity attachmentEntityToScan, String contentId) { contentId, attachmentEntityToScan.getQualifiedName()); attachmentMalwareScanner.scanAttachment( - attachmentEntityToScan, contentId); + attachmentEntityToScan, contentId, prefix); }); }); return null; diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AsyncMalwareScanExecutor.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AsyncMalwareScanExecutor.java index fc6413643..0af68ffee 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AsyncMalwareScanExecutor.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AsyncMalwareScanExecutor.java @@ -4,6 +4,7 @@ package com.sap.cds.feature.attachments.service.malware; import com.sap.cds.reflect.CdsEntity; +import java.util.Optional; /** Supports asynchronous malware scanning of attachments. */ public interface AsyncMalwareScanExecutor { @@ -13,6 +14,8 @@ public interface AsyncMalwareScanExecutor { * * @param attachmentEntity The entity containing the attachment to scan * @param contentId The content id of the attachment entity + * @param inlinePrefix For inline attachments, the field name prefix; empty for composition-based + * attachments */ - void scanAsync(CdsEntity attachmentEntity, String contentId); + void scanAsync(CdsEntity attachmentEntity, String contentId, Optional inlinePrefix); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AttachmentMalwareScanner.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AttachmentMalwareScanner.java index d64d60cbe..b3c97243a 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AttachmentMalwareScanner.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AttachmentMalwareScanner.java @@ -5,6 +5,7 @@ import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.ServiceException; +import java.util.Optional; /** * The {@link AttachmentMalwareScanner} is the connection to the malware scan service. It reads the @@ -18,7 +19,9 @@ public interface AttachmentMalwareScanner { * * @param attachmentEntity The entity containing the attachment to scan * @param contentId The content id of the attachment entity + * @param inlinePrefix For inline attachments, the field name prefix (e.g. "profileIcon"); empty + * for composition-based attachments * @throws ServiceException Exception to be thrown in case of errors during scanning the content */ - void scanAttachment(CdsEntity attachmentEntity, String contentId); + void scanAttachment(CdsEntity attachmentEntity, String contentId, Optional inlinePrefix); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java index 7b53c6484..b2aa8b331 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java @@ -25,6 +25,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -67,15 +68,18 @@ public DefaultAttachmentMalwareScanner( } @Override - public void scanAttachment(CdsEntity attachmentEntity, String contentId) { + public void scanAttachment( + CdsEntity attachmentEntity, String contentId, Optional inlinePrefix) { logger.debug( "Started scanning attachment {} of entity {}.", contentId, attachmentEntity.getQualifiedName()); - List selectionResults = selectData(attachmentEntity, contentId); + List selectionResults = + selectData(attachmentEntity, contentId, inlinePrefix); - MalwareScanResultStatus status = findAndScanAttachments(selectionResults, contentId); + MalwareScanResultStatus status = + findAndScanAttachments(selectionResults, contentId, inlinePrefix); if (status == null) { logger.debug("Attachment {} not found in any entity, skipping update.", contentId); @@ -85,16 +89,18 @@ public void scanAttachment(CdsEntity attachmentEntity, String contentId) { // Update ALL candidate entities. This ensures the scan result is persisted // even if the attachment moved between draft and active tables during the scan. for (SelectionResult result : selectionResults) { - updateData(result.entity, contentId, status); + updateData(result.entity(), contentId, status, inlinePrefix); } } private MalwareScanResultStatus findAndScanAttachments( - List selectionResults, String contentId) { + List selectionResults, + String contentId, + Optional inlinePrefix) { return selectionResults.stream() .filter(result -> validateAndFilter(result, contentId)) .findFirst() - .map(result -> scanDocument(result.result().single(Attachments.class))) + .map(result -> scanDocument(extractAttachment(result.result(), inlinePrefix))) .orElse(null); } @@ -104,7 +110,7 @@ private boolean validateAndFilter(SelectionResult result, String contentId) { logger.debug( "No attachments {} found in entity {}, nothing to scan.", contentId, - result.entity.getQualifiedName()); + result.entity().getQualifiedName()); return false; } @@ -112,7 +118,7 @@ private boolean validateAndFilter(SelectionResult result, String contentId) { logger.warn( "More than one attachment {} found in entity {}.", contentId, - result.entity.getQualifiedName()); + result.entity().getQualifiedName()); throw new IllegalStateException( "More than one attachment with contentId %s.".formatted(contentId)); } @@ -120,41 +126,60 @@ private boolean validateAndFilter(SelectionResult result, String contentId) { return true; } - private List selectData(CdsEntity attachmentEntity, String contentId) { + private Attachments extractAttachment(Result queryResult, Optional inlinePrefix) { + if (inlinePrefix.isEmpty()) { + return queryResult.single(Attachments.class); + } + String prefix = inlinePrefix.get() + "_"; + var row = queryResult.single(); + Attachments attachment = Attachments.create(); + attachment.setContentId((String) row.get(prefix + Attachments.CONTENT_ID)); + attachment.setContent((InputStream) row.get(prefix + Attachments.CONTENT)); + attachment.setStatus((String) row.get(prefix + Attachments.STATUS)); + return attachment; + } + + @VisibleForTesting + static String resolveColumn(String fieldName, Optional inlinePrefix) { + return inlinePrefix.map(p -> p + "_" + fieldName).orElse(fieldName); + } + + private List selectData( + CdsEntity attachmentEntity, String contentId, Optional inlinePrefix) { List result = new ArrayList<>(); try { CdsEntity entity = (CdsEntity) attachmentEntity.getTargetOf(Drafts.SIBLING_ENTITY); - Result selectionResult = readData(contentId, entity); + Result selectionResult = readData(contentId, entity, inlinePrefix); result.add(new SelectionResult(entity, selectionResult)); } catch (CdsElementNotFoundException ignored) { // no sibling found nothing to select } - Result selectionResult = readData(contentId, attachmentEntity); + Result selectionResult = readData(contentId, attachmentEntity, inlinePrefix); result.add(new SelectionResult(attachmentEntity, selectionResult)); return result; } - private Result readData(String contentId, CdsEntity entity) { + private Result readData(String contentId, CdsEntity entity, Optional inlinePrefix) { + String contentIdCol = resolveColumn(Attachments.CONTENT_ID, inlinePrefix); + String contentCol = resolveColumn(Attachments.CONTENT, inlinePrefix); + String statusCol = resolveColumn(Attachments.STATUS, inlinePrefix); + CqnSelect select = Select.from(entity) - .columns(Attachments.CONTENT_ID, Attachments.CONTENT, Attachments.STATUS) + .columns(contentIdCol, contentCol, statusCol) .where( - e -> - e.get(Attachments.CONTENT_ID) - .eq(contentId) - .and(e.get(Attachments.STATUS).ne(StatusCode.CLEAN))); + e -> e.get(contentIdCol).eq(contentId).and(e.get(statusCol).ne(StatusCode.CLEAN))); Result result = persistenceService.run(select); - result - .streamOf(Attachments.class) + result.stream() .forEach( - attachment -> + row -> logger.debug( "Found attachment {} in entity {} with status {}.", - attachment.getContentId(), + row.get(contentIdCol), entity.getQualifiedName(), - attachment.getStatus())); + row.get(statusCol))); return result; } @@ -176,20 +201,30 @@ private MalwareScanResultStatus scanDocument(Attachments attachment) { } private void updateData( - CdsEntity attachmentEntity, String contentId, MalwareScanResultStatus status) { + CdsEntity attachmentEntity, + String contentId, + MalwareScanResultStatus status, + Optional inlinePrefix) { + String contentIdCol = resolveColumn(Attachments.CONTENT_ID, inlinePrefix); + String statusCol = resolveColumn(Attachments.STATUS, inlinePrefix); + String scannedAtCol = resolveColumn(Attachments.SCANNED_AT, inlinePrefix); + + String mappedStatus = mapStatus(status); + Instant scannedAt = Instant.now(); + Attachments updateData = Attachments.create(); - updateData.setStatus(mapStatus(status)); - updateData.setScannedAt(Instant.now()); + updateData.put(statusCol, mappedStatus); + updateData.put(scannedAtCol, scannedAt); CqnUpdate update = Update.entity(attachmentEntity) .data(updateData) - .where(entry -> entry.get(Attachments.CONTENT_ID).eq(contentId)); + .where(entry -> entry.get(contentIdCol).eq(contentId)); Result result = persistenceService.run(update); logger.debug( "Updated scan status to {} of attachment {} in entity {} -> Row count {}.", - updateData.getStatus(), + mappedStatus, contentId, attachmentEntity.getQualifiedName(), result.rowCount()); diff --git a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds index 37c2a8e22..a6ae39601 100644 --- a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds +++ b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds @@ -1,8 +1,16 @@ using { + sap.attachments.Attachment, sap.attachments.MediaData, sap.attachments.Attachments } from './attachments'; +// Annotate Attachment type with a static Core.MediaType so that LargeBinary content is exposed as Edm.Stream (enabling Fiori upload widget). +// Using 'mimeType' (path reference) instead of a static value would break inline usage: +// CDS flattening rewrites 'content' to 'prefix_content' but does NOT rewrite the path reference 'mimeType' to 'prefix_mimeType', causing a broken reference to a non-existent field. +annotate Attachment with { + content @Core.MediaType: 'application/octet-stream'; +} + annotate MediaData with @UI.MediaResource: {Stream: content} { content @( title : '{i18n>attachment_content}', diff --git a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds index 6a81f760a..907cc419f 100644 --- a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds +++ b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds @@ -14,7 +14,7 @@ type StatusCode : String(32) enum { Failed; } -aspect MediaData @(_is_media_data) { +type Attachment @(_is_media_data) { content : LargeBinary; // stored only for db-based services mimeType : String; fileName : String(5000); @@ -29,6 +29,7 @@ entity ScanStates : CodeList { name : localized String(64); criticality : Integer @UI.Hidden; } +aspect MediaData : Attachment {} aspect Attachments : cuid, managed, MediaData { note : String(5000); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java index 6c24fa20e..46b56c8b3 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java @@ -45,6 +45,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.List; +import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -233,7 +234,7 @@ void scannerCalledForUnscannedAttachments() { cut.processAfter(readEventContext, List.of(attachment)); verify(asyncMalwareScanExecutor) - .scanAsync(readEventContext.getTarget(), attachment.getContentId()); + .scanAsync(readEventContext.getTarget(), attachment.getContentId(), Optional.empty()); } @Test @@ -247,7 +248,7 @@ void scannerCalledForUnscannedAttachmentsIfNoContentProvided() { cut.processAfter(readEventContext, List.of(attachment)); verify(asyncMalwareScanExecutor) - .scanAsync(readEventContext.getTarget(), attachment.getContentId()); + .scanAsync(readEventContext.getTarget(), attachment.getContentId(), Optional.empty()); } @Test @@ -281,7 +282,7 @@ void scannerCalledForStaleCleanAttachment() { verify(persistenceService).run(any(com.sap.cds.ql.cqn.CqnUpdate.class)); verify(asyncMalwareScanExecutor) - .scanAsync(readEventContext.getTarget(), attachment.getContentId()); + .scanAsync(readEventContext.getTarget(), attachment.getContentId(), Optional.empty()); assertThat(attachment.getStatus()).isEqualTo(StatusCode.SCANNING); } @@ -303,7 +304,7 @@ void scannerCalledForCleanAttachmentWithNullScannedAt() { verify(persistenceService).run(any(com.sap.cds.ql.cqn.CqnUpdate.class)); verify(asyncMalwareScanExecutor) - .scanAsync(readEventContext.getTarget(), attachment.getContentId()); + .scanAsync(readEventContext.getTarget(), attachment.getContentId(), Optional.empty()); assertThat(attachment.getStatus()).isEqualTo(StatusCode.SCANNING); } @@ -350,7 +351,7 @@ void persistenceServiceNotCalledForUnscannedAttachments() { cut.processAfter(readEventContext, List.of(attachment)); verify(asyncMalwareScanExecutor) - .scanAsync(readEventContext.getTarget(), attachment.getContentId()); + .scanAsync(readEventContext.getTarget(), attachment.getContentId(), Optional.empty()); verify(attachmentStatusValidator).verifyStatus(StatusCode.UNSCANNED); verifyNoInteractions(persistenceService); } @@ -503,4 +504,56 @@ private void mockEventContext(String entityName, CqnSelect select) { when(readEventContext.getModel()).thenReturn(runtime.getCdsModel()); when(readEventContext.getCqn()).thenReturn(select); } + + // --- Inline Attachment Tests --- + + @Test + void inlineContentWrappedWithLazyProxyOnRead() { + mockEventContext(RootTable_.CDS_NAME, mock(CqnSelect.class)); + + // Create root data with inline attachment fields + var root = CdsData.create(); + root.put("ID", UUID.randomUUID().toString()); + root.put("profilePicture_content", null); + root.put("profilePicture_contentId", "inline-doc-1"); + root.put("profilePicture_status", StatusCode.CLEAN); + + cut.processAfter(readEventContext, List.of(root)); + + assertThat(root.get("profilePicture_content")).isInstanceOf(LazyProxyInputStream.class); + } + + @Test + void inlineContentWithoutContentIdRemainsNull() { + mockEventContext(RootTable_.CDS_NAME, mock(CqnSelect.class)); + + var root = CdsData.create(); + root.put("ID", UUID.randomUUID().toString()); + root.put("profilePicture_content", null); + // No contentId — should not be wrapped + + cut.processAfter(readEventContext, List.of(root)); + + assertThat(root.get("profilePicture_content")).isNull(); + } + + @Test + void inlineContentWithExistingStreamWrappedWithProxy() throws IOException { + mockEventContext(RootTable_.CDS_NAME, mock(CqnSelect.class)); + var testContent = "inline photo bytes"; + var testStream = new ByteArrayInputStream(testContent.getBytes(StandardCharsets.UTF_8)); + + var root = CdsData.create(); + root.put("ID", UUID.randomUUID().toString()); + root.put("profilePicture_content", testStream); + root.put("profilePicture_contentId", "inline-doc-2"); + root.put("profilePicture_status", StatusCode.CLEAN); + + cut.processAfter(readEventContext, List.of(root)); + + assertThat(root.get("profilePicture_content")).isInstanceOf(LazyProxyInputStream.class); + // The proxy uses the existing stream supplier + byte[] bytes = ((InputStream) root.get("profilePicture_content")).readAllBytes(); + assertThat(bytes).isEqualTo(testContent.getBytes(StandardCharsets.UTF_8)); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java index 1865c4237..132542f24 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java @@ -570,4 +570,98 @@ private String getOrCondition(String key1, String key2) { .replace(" ", "") .replace("\n", ""); } + + // --- Inline Attachment Tests --- + + @Test + void inlineContentFieldTriggersProcessing() { + var id = getEntityAndMockContext(RootTable_.CDS_NAME); + var root = CdsData.create(); + root.put("ID", id); + root.put("profilePicture_content", mock(InputStream.class)); + when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) + .thenReturn(List.of()); + + cut.processBefore(updateContext, List.of(root)); + + verify(attachmentsReader).readAttachments(any(), any(), any(CqnFilterableStatement.class)); + } + + @Test + void inlineMetadataOnlyFieldTriggersReaderButNotEventFactory() { + // data contains profilePicture_mimeType but NOT profilePicture_content + // associationsAreUnchanged → false (because prefix_ key is present) + // containsContentField → false (mimeType is not content) + var id = getEntityAndMockContext(RootTable_.CDS_NAME); + var root = CdsData.create(); + root.put("ID", id); + root.put("profilePicture_mimeType", "image/png"); + when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) + .thenReturn(List.of()); + + cut.processBefore(updateContext, List.of(root)); + + // Reader is called because inline fields changed + verify(attachmentsReader).readAttachments(any(), any(), any(CqnFilterableStatement.class)); + // But eventFactory is not called because no actual content change + verifyNoInteractions(eventFactory); + } + + @Test + void noInlineOrCompositionFieldsSkipsProcessing() { + getEntityAndMockContext(RootTable_.CDS_NAME); + var root = CdsData.create(); + root.put("ID", UUID.randomUUID().toString()); + root.put("title", "Just a title update"); + + cut.processBefore(updateContext, List.of(root)); + + verifyNoInteractions(attachmentsReader); + verifyNoInteractions(eventFactory); + verifyNoInteractions(attachmentService); + } + + @Test + void inlineReadonlyFieldsPreservedForDraftActivation() { + getEntityAndMockContext(RootTable_.CDS_NAME); + + var data = CdsData.create(); + data.put("ID", UUID.randomUUID().toString()); + // Content key must be present for CdsDataProcessor validator to fire + data.put("profilePicture_content", null); + data.put("profilePicture_contentId", "doc-42"); + data.put("profilePicture_status", "Clean"); + when(storageReader.get()).thenReturn(true); + + cut.processBeforeForDraft(updateContext, List.of(data)); + + // ReadonlyDataContextEnhancer preserves inline readonly fields + var readonlyContext = (CdsData) data.get("profilePicture_DRAFT_READONLY_CONTEXT"); + assertThat(readonlyContext).isNotNull(); + assertThat(readonlyContext).containsEntry("contentId", "doc-42"); + assertThat(readonlyContext).containsEntry("status", "Clean"); + } + + @Test + void inlineReadonlyFieldsClearedForNonDraftActivation() { + getEntityAndMockContext(RootTable_.CDS_NAME); + + var readonlyData = CdsData.create(); + readonlyData.put(Attachments.CONTENT_ID, "old-doc"); + readonlyData.put(Attachments.STATUS, "Infected"); + + var data = CdsData.create(); + data.put("ID", UUID.randomUUID().toString()); + data.put("profilePicture_content", null); + data.put("profilePicture_contentId", "doc-42"); + data.put("profilePicture_DRAFT_READONLY_CONTEXT", readonlyData); + when(storageReader.get()).thenReturn(false); + + cut.processBeforeForDraft(updateContext, List.of(data)); + + // Non-draft: readonly context key is removed + assertThat(data.get("profilePicture_DRAFT_READONLY_CONTEXT")).isNull(); + // contentId stays (it was explicitly set) + assertThat(data).containsEntry("profilePicture_contentId", "doc-42"); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java index 489017824..8588e4634 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java @@ -7,7 +7,9 @@ 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.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; @@ -214,4 +216,59 @@ void malformedContentLengthHeader() { assertThat(exception.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST); } + + // --- Inline Attachment Tests --- + + @Test + void inlineContentIdResolvedFromPrefixedField() { + // Use real RootTable entity so that inline detection works + CdsEntity realEntity = + runtime.getCdsModel().findEntity("unit.test.TestService.RootTable").orElseThrow(); + when(target.entity()).thenReturn(realEntity); + + var values = com.sap.cds.CdsData.create(); + values.put("ID", UUID.randomUUID().toString()); + values.put("profilePicture_content", mock(InputStream.class)); + values.put("profilePicture_contentId", "existing-doc-77"); + when(target.values()).thenReturn(values); + when(target.keys()).thenReturn(Map.of("ID", values.get("ID"))); + when(parameterInfo.getHeader("Content-Length")).thenReturn(null); + + var existingAttachments = List.of(); + + // contentId should be resolved from profilePicture_contentId + ModifyApplicationHandlerHelper.handleAttachmentForEntity( + existingAttachments, + eventFactory, + eventContext, + path, + (InputStream) values.get("profilePicture_content"), + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER); + + // Verify eventFactory was called with the resolved contentId + verify(eventFactory).getEvent(any(), eq("existing-doc-77"), any()); + } + + @Test + void handleAttachmentForEntitiesProcessesInlineContent() { + CdsEntity realEntity = + runtime.getCdsModel().findEntity("unit.test.TestService.RootTable").orElseThrow(); + + var content = mock(InputStream.class); + var data = com.sap.cds.CdsData.create(); + data.put("ID", UUID.randomUUID().toString()); + data.put("profilePicture_content", content); + when(parameterInfo.getHeader("Content-Length")).thenReturn(null); + + ModifyApplicationHandlerHelper.handleAttachmentForEntities( + realEntity, + List.of(data), + List.of(), + eventFactory, + eventContext, + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER); + + // eventFactory should be called since inline content was found + verify(eventFactory).getEvent(any(), any(), any()); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java new file mode 100644 index 000000000..1733aa34b --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java @@ -0,0 +1,184 @@ +/* + * © 2024-2025 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 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.handler.helper.RuntimeHelper; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.services.runtime.CdsRuntime; +import java.io.ByteArrayInputStream; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class ReadonlyDataContextEnhancerTest { + + private static CdsRuntime runtime; + private static final String DRAFT_READONLY_CONTEXT = "DRAFT_READONLY_CONTEXT"; + + @BeforeAll + static void classSetup() { + runtime = RuntimeHelper.runtime; + } + + private CdsEntity getRootTableEntity() { + return runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + } + + private CdsEntity getAttachmentEntity() { + return runtime + .getCdsModel() + .findEntity("unit.test.TestService.RootTable.attachments") + .orElseThrow(); + } + + // --- Composition-based preserve/restore tests --- + + @Test + void preserveReadonlyFieldsForDraftComposition() { + CdsEntity entity = getAttachmentEntity(); + CdsData data = CdsData.create(); + data.put(Attachments.CONTENT, new ByteArrayInputStream(new byte[0])); + data.put(Attachments.CONTENT_ID, "cid-123"); + data.put(Attachments.STATUS, "Clean"); + Instant now = Instant.now(); + data.put(Attachments.SCANNED_AT, now); + + ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), true); + + CdsData backup = (CdsData) data.get(DRAFT_READONLY_CONTEXT); + assertThat(backup).isNotNull(); + assertThat(backup.get(Attachments.CONTENT_ID)).isEqualTo("cid-123"); + assertThat(backup.get(Attachments.STATUS)).isEqualTo("Clean"); + assertThat(backup.get(Attachments.SCANNED_AT)).isEqualTo(now); + } + + @Test + void preserveReadonlyFieldsNonDraftRemovesContext() { + CdsEntity entity = getAttachmentEntity(); + CdsData data = CdsData.create(); + data.put(Attachments.CONTENT, new ByteArrayInputStream(new byte[0])); + data.put(Attachments.CONTENT_ID, "cid-123"); + data.put(DRAFT_READONLY_CONTEXT, Attachments.create()); + + ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), false); + + assertThat(data.containsKey(DRAFT_READONLY_CONTEXT)).isFalse(); + } + + @Test + void restoreReadonlyFieldsComposition() { + CdsData data = CdsData.create(); + Attachments backup = Attachments.create(); + backup.setContentId("cid-restored"); + backup.setStatus("Scanning"); + Instant scannedAt = Instant.now(); + backup.setScannedAt(scannedAt); + data.put(DRAFT_READONLY_CONTEXT, backup); + + ReadonlyDataContextEnhancer.restoreReadonlyFields(data); + + assertThat(data.get(Attachments.CONTENT_ID)).isEqualTo("cid-restored"); + assertThat(data.get(Attachments.STATUS)).isEqualTo("Scanning"); + assertThat(data.get(Attachments.SCANNED_AT)).isEqualTo(scannedAt); + assertThat(data.containsKey(DRAFT_READONLY_CONTEXT)).isFalse(); + } + + @Test + void restoreReadonlyFieldsNoBackupDoesNothing() { + CdsData data = CdsData.create(); + data.put("ID", "123"); + + ReadonlyDataContextEnhancer.restoreReadonlyFields(data); + + assertThat(data.get("ID")).isEqualTo("123"); + assertThat(data).hasSize(1); + } + + // --- Inline attachment preserve/restore tests --- + + @Test + void preserveReadonlyFieldsForDraftInline() { + CdsEntity entity = getRootTableEntity(); + CdsData data = CdsData.create(); + data.put("profilePicture_content", new ByteArrayInputStream(new byte[0])); + data.put("profilePicture_contentId", "cid-inline-456"); + data.put("profilePicture_status", "Unscanned"); + Instant now = Instant.now(); + data.put("profilePicture_scannedAt", now); + + ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), true); + + CdsData backup = (CdsData) data.get("profilePicture_" + DRAFT_READONLY_CONTEXT); + assertThat(backup).isNotNull(); + assertThat(backup.get(Attachments.CONTENT_ID)).isEqualTo("cid-inline-456"); + assertThat(backup.get(Attachments.STATUS)).isEqualTo("Unscanned"); + assertThat(backup.get(Attachments.SCANNED_AT)).isEqualTo(now); + } + + @Test + void preserveReadonlyFieldsNonDraftRemovesInlineContext() { + CdsEntity entity = getRootTableEntity(); + CdsData data = CdsData.create(); + data.put("profilePicture_content", new ByteArrayInputStream(new byte[0])); + data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, Attachments.create()); + + ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), false); + + assertThat(data.containsKey("profilePicture_" + DRAFT_READONLY_CONTEXT)).isFalse(); + } + + @Test + void restoreReadonlyFieldsInline() { + CdsData data = CdsData.create(); + data.put("ID", "123"); + Attachments backup = Attachments.create(); + backup.setContentId("cid-inline-restored"); + backup.setStatus("Clean"); + Instant scannedAt = Instant.now(); + backup.setScannedAt(scannedAt); + data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, backup); + + ReadonlyDataContextEnhancer.restoreReadonlyFields(data); + + assertThat(data.get("profilePicture_contentId")).isEqualTo("cid-inline-restored"); + assertThat(data.get("profilePicture_status")).isEqualTo("Clean"); + assertThat(data.get("profilePicture_scannedAt")).isEqualTo(scannedAt); + assertThat(data.containsKey("profilePicture_" + DRAFT_READONLY_CONTEXT)).isFalse(); + } + + @Test + void restoreReadonlyFieldsBothCompositionAndInline() { + CdsData data = CdsData.create(); + + // Composition backup + Attachments compositionBackup = Attachments.create(); + compositionBackup.setContentId("cid-comp"); + compositionBackup.setStatus("Clean"); + data.put(DRAFT_READONLY_CONTEXT, compositionBackup); + + // Inline backup + Attachments inlineBackup = Attachments.create(); + inlineBackup.setContentId("cid-inline"); + inlineBackup.setStatus("Scanning"); + data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, inlineBackup); + + ReadonlyDataContextEnhancer.restoreReadonlyFields(data); + + // Composition restored + assertThat(data.get(Attachments.CONTENT_ID)).isEqualTo("cid-comp"); + assertThat(data.get(Attachments.STATUS)).isEqualTo("Clean"); + // Inline restored + assertThat(data.get("profilePicture_contentId")).isEqualTo("cid-inline"); + assertThat(data.get("profilePicture_status")).isEqualTo("Scanning"); + // Backup keys removed + assertThat(data.containsKey(DRAFT_READONLY_CONTEXT)).isFalse(); + assertThat(data.containsKey("profilePicture_" + DRAFT_READONLY_CONTEXT)).isFalse(); + } +} diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelperTest.java index 99e05b6cf..49e40ccd9 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelperTest.java @@ -51,7 +51,7 @@ void doesNothing_whenEntityNotFoundInModel() { try (MockedStatic helper = mockStatic(ApplicationHandlerHelper.class)) { - helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(false); + helper.when(() -> ApplicationHandlerHelper.isDirectMediaEntity(entity)).thenReturn(false); setupMockCascader(entity, model, false); @@ -71,7 +71,7 @@ void doesNothing_whenNoEntityHasAcceptableMediaTypesAnnotation() { MockedStatic extractor = mockStatic(AttachmentDataExtractor.class)) { - helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(true); + helper.when(() -> ApplicationHandlerHelper.isDirectMediaEntity(entity)).thenReturn(true); // MediaTypeResolver returns empty map = no entity has the annotation resolver @@ -101,7 +101,7 @@ void doesNotThrow_whenNoFiles() { MockedStatic extractor = mockStatic(AttachmentDataExtractor.class)) { CdsRuntime runtime = mockRuntime(entity); - helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(true); + helper.when(() -> ApplicationHandlerHelper.isDirectMediaEntity(entity)).thenReturn(true); resolver .when( @@ -122,7 +122,7 @@ void doesNotThrow_whenNoFiles() { @ParameterizedTest @MethodSource("validFileScenarios") - void doesNotThrow_whenFilesAreValid(boolean isMediaEntity) { + void doesNotThrow_whenFilesAreValid(boolean isDirectMediaEntity) { CdsEntity entity = mockEntity("Entity"); CdsRuntime runtime = mockRuntime(entity); @@ -136,8 +136,10 @@ void doesNotThrow_whenFilesAreValid(boolean isMediaEntity) { MockedStatic extractor = mockStatic(AttachmentDataExtractor.class)) { - helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(isMediaEntity); - setupMockCascader(entity, runtime.getCdsModel(), !isMediaEntity); + helper + .when(() -> ApplicationHandlerHelper.isDirectMediaEntity(entity)) + .thenReturn(isDirectMediaEntity); + setupMockCascader(entity, runtime.getCdsModel(), !isDirectMediaEntity); resolver .when( @@ -165,7 +167,7 @@ private static Stream validFileScenarios() { @ParameterizedTest @MethodSource("invalidFileScenarios") - void throwsException_whenFilesAreInvalid(boolean isMediaEntity) { + void throwsException_whenFilesAreInvalid(boolean isDirectMediaEntity) { CdsEntity entity = mockEntity("Entity"); CdsRuntime runtime = mockRuntime(entity); @@ -179,8 +181,10 @@ void throwsException_whenFilesAreInvalid(boolean isMediaEntity) { MockedStatic extractor = mockStatic(AttachmentDataExtractor.class)) { - helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(isMediaEntity); - setupMockCascader(entity, runtime.getCdsModel(), !isMediaEntity); + helper + .when(() -> ApplicationHandlerHelper.isDirectMediaEntity(entity)) + .thenReturn(isDirectMediaEntity); + setupMockCascader(entity, runtime.getCdsModel(), !isDirectMediaEntity); resolver .when( diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolverTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolverTest.java index f0056d06c..eb7bd1f99 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolverTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolverTest.java @@ -36,7 +36,7 @@ void shouldReturnMediaTypesFromAnnotation() { when(model.getEntity("MediaEntity")).thenReturn(media); - when(media.getElement("content")).thenReturn(element); + when(media.findElement("content")).thenReturn(Optional.of(element)); when(element.findAnnotation("Core.AcceptableMediaTypes")).thenReturn(Optional.of(annotation)); when(annotation.getValue()).thenReturn(List.of("image/png", "image/jpeg")); @@ -52,7 +52,7 @@ void shouldExcludeEntityWithoutAnnotation() { CdsEntity media = mock(CdsEntity.class); when(model.getEntity("MediaEntity")).thenReturn(media); - when(media.getElement(any())).thenReturn(null); + when(media.findElement(any())).thenReturn(Optional.empty()); Map> result = MediaTypeResolver.getAcceptableMediaTypesFromEntity(model, List.of("MediaEntity")); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java index df10cf9e8..5e34b98c2 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java @@ -11,7 +11,9 @@ import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; import com.sap.cds.feature.attachments.handler.applicationservice.transaction.ListenerProvider; +import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.model.service.AttachmentModificationResult; import com.sap.cds.feature.attachments.service.model.service.CreateAttachmentInput; @@ -26,6 +28,7 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.HashMap; import java.util.Map; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; @@ -179,4 +182,109 @@ private Attachments prepareAndExecuteEventWithData() { cut.processEvent(path, attachment.getContent(), Attachments.create(), eventContext); return attachment; } + + // --- Inline Attachment Tests --- + + @Test + void inlineContentIdAndStatusWrittenWithPrefix() { + // Use real entity from CDS model so that getInlineAttachmentFieldNames returns + // ["profilePicture"] + CdsEntity realEntity = + RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + when(target.entity()).thenReturn(realEntity); + + Map values = new HashMap<>(); + values.put("ID", UUID.randomUUID().toString()); + values.put("profilePicture_mimeType", "image/png"); + values.put("profilePicture_fileName", "photo.png"); + when(target.values()).thenReturn(values); + when(target.keys()).thenReturn(Map.of("ID", values.get("ID"))); + + var content = mock(InputStream.class); + when(attachmentService.createAttachment(any())) + .thenReturn(new AttachmentModificationResult(false, "doc-123", "Clean", null)); + + cut.processEvent(path, content, Attachments.create(), eventContext); + + assertThat(values).containsEntry("profilePicture_contentId", "doc-123"); + assertThat(values).containsEntry("profilePicture_status", "Clean"); + } + + @Test + void inlinePrefixedFieldValuesPassedToService() { + CdsEntity realEntity = + RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + when(target.entity()).thenReturn(realEntity); + + Map values = new HashMap<>(); + values.put("ID", UUID.randomUUID().toString()); + values.put("profilePicture_mimeType", "image/jpeg"); + values.put("profilePicture_fileName", "avatar.jpg"); + when(target.values()).thenReturn(values); + when(target.keys()).thenReturn(Map.of("ID", values.get("ID"))); + + var content = mock(InputStream.class); + when(attachmentService.createAttachment(any())) + .thenReturn(new AttachmentModificationResult(false, "id", "ok", null)); + + cut.processEvent(path, content, Attachments.create(), eventContext); + + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + var input = contextArgumentCaptor.getValue(); + assertThat(input.mimeType()).isEqualTo("image/jpeg"); + assertThat(input.fileName()).isEqualTo("avatar.jpg"); + assertThat(input.content()).isEqualTo(content); + } + + @Test + void inlineFallsBackToAttachmentObjectWhenPrefixedFieldMissing() { + CdsEntity realEntity = + RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + when(target.entity()).thenReturn(realEntity); + + Map values = new HashMap<>(); + values.put("ID", UUID.randomUUID().toString()); + // No prefixed mimeType/fileName in values + when(target.values()).thenReturn(values); + when(target.keys()).thenReturn(Map.of("ID", values.get("ID"))); + + var content = mock(InputStream.class); + when(attachmentService.createAttachment(any())) + .thenReturn(new AttachmentModificationResult(false, "id", "ok", null)); + + var existingData = Attachments.create(); + existingData.setFileName("fallback.txt"); + existingData.setMimeType("text/plain"); + + cut.processEvent(path, content, existingData, eventContext); + + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + var input = contextArgumentCaptor.getValue(); + assertThat(input.mimeType()).isEqualTo("text/plain"); + assertThat(input.fileName()).isEqualTo("fallback.txt"); + } + + @Test + void nonInlineEntityDoesNotUsePrefixedFields() { + // Mock entity that is NOT inline + // Plain mock has no elements, so getInlineAttachmentFieldNames returns empty + when(entity.getQualifiedName()).thenReturn(TEST_FULL_NAME); + + Map values = new HashMap<>(); + values.put("ID", UUID.randomUUID().toString()); + values.put(MediaData.MIME_TYPE, "application/pdf"); + values.put(MediaData.FILE_NAME, "doc.pdf"); + when(target.values()).thenReturn(values); + when(target.keys()).thenReturn(Map.of("ID", values.get("ID"))); + + var content = mock(InputStream.class); + when(attachmentService.createAttachment(any())) + .thenReturn(new AttachmentModificationResult(false, "doc-999", "ok", null)); + + cut.processEvent(path, content, Attachments.create(), eventContext); + + // Fields written with unprefixed names + assertThat(values).containsEntry(Attachments.CONTENT_ID, "doc-999"); + assertThat(values).containsEntry(Attachments.STATUS, "ok"); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java index 18e95854c..48685da7e 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java @@ -10,6 +10,9 @@ import static org.mockito.Mockito.when; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; +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.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.model.service.MarkAsDeletedInput; import com.sap.cds.ql.cqn.Path; @@ -31,6 +34,7 @@ class MarkAsDeletedAttachmentEventTest { private MarkAsDeletedAttachmentEvent cut; private AttachmentService attachmentService; private Path path; + private ResolvedSegment target; private Map currentData; private EventContext context; private UserInfo userInfo; @@ -42,9 +46,13 @@ void setup() { context = mock(EventContext.class); path = mock(Path.class); - var target = mock(ResolvedSegment.class); + target = mock(ResolvedSegment.class); currentData = new HashMap<>(); when(path.target()).thenReturn(target); + // Default: non-inline entity (mock with no elements → getInlineAttachmentFieldNames returns + // empty) + var entity = mock(CdsEntity.class); + when(target.entity()).thenReturn(entity); var eventTarget = mock(CdsEntity.class); when(context.getTarget()).thenReturn(eventTarget); when(eventTarget.getQualifiedName()).thenReturn("some.qualified.name"); @@ -71,7 +79,9 @@ void documentIsExternallyDeleted() { assertThat(currentData) .containsEntry(Attachments.CONTENT_ID, null) .containsEntry(Attachments.STATUS, null) - .containsEntry(Attachments.SCANNED_AT, null); + .containsEntry(Attachments.SCANNED_AT, null) + .containsEntry(MediaData.MIME_TYPE, null) + .containsEntry(MediaData.FILE_NAME, null); } @Test @@ -84,7 +94,10 @@ void documentIsNotExternallyDeletedBecauseDoesNotExistBefore() { assertThat(expectedValue).isEqualTo(value); assertThat(data.getContentId()).isNull(); verifyNoInteractions(attachmentService); - assertThat(currentData).containsEntry(Attachments.CONTENT_ID, null); + assertThat(currentData) + .containsEntry(Attachments.CONTENT_ID, null) + .containsEntry(MediaData.MIME_TYPE, null) + .containsEntry(MediaData.FILE_NAME, null); } @Test @@ -100,7 +113,10 @@ void documentIsNotExternallyDeletedBecauseItIsDraftChangeEvent() { assertThat(expectedValue).isEqualTo(value); assertThat(data.getContentId()).isEqualTo(contentId); verifyNoInteractions(attachmentService); - assertThat(currentData).containsEntry(Attachments.CONTENT_ID, null); + assertThat(currentData) + .containsEntry(Attachments.CONTENT_ID, null) + .containsEntry(MediaData.MIME_TYPE, null) + .containsEntry(MediaData.FILE_NAME, null); } @Test @@ -141,4 +157,61 @@ void processEvent_withDifferentNewContentId_doesNotClearContentId() { // currentData should NOT be cleared since newContentId differs from attachment.getContentId() assertThat(currentData).containsEntry(Attachments.CONTENT_ID, newContentId); } + + // --- Inline Attachment Tests --- + + @Test + void inlineDelete_clearsPrefixedFields() { + // Use real entity from CDS model so that getInlineAttachmentFieldNames returns + // ["profilePicture"] + CdsEntity realEntity = + RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + when(target.entity()).thenReturn(realEntity); + + Map values = new HashMap<>(); + values.put("ID", "some-id"); + values.put("profilePicture_contentId", "old-content-id"); + values.put("profilePicture_status", "Clean"); + values.put("profilePicture_mimeType", "image/png"); + values.put("profilePicture_fileName", "photo.png"); + when(target.values()).thenReturn(values); + + var data = Attachments.create(); + data.setContentId("old-content-id"); + when(context.getEvent()).thenReturn(DraftService.EVENT_DRAFT_PATCH); + + cut.processEvent(path, null, data, context); + + // All prefixed fields should be cleared + assertThat(values) + .containsEntry("profilePicture_contentId", null) + .containsEntry("profilePicture_status", null) + .containsEntry("profilePicture_scannedAt", null) + .containsEntry("profilePicture_mimeType", null) + .containsEntry("profilePicture_fileName", null); + // Unprefixed fields should NOT be set + assertThat(values).doesNotContainKey(Attachments.CONTENT_ID); + assertThat(values).doesNotContainKey(Attachments.STATUS); + } + + @Test + void inlineDelete_withDifferentNewContentId_doesNotClearPrefixedFields() { + CdsEntity realEntity = + RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + when(target.entity()).thenReturn(realEntity); + + Map values = new HashMap<>(); + values.put("ID", "some-id"); + values.put("profilePicture_contentId", "different-new-content-id"); + when(target.values()).thenReturn(values); + + var data = Attachments.create(); + data.setContentId("old-content-id"); + + cut.processEvent(path, null, data, context); + + // contentId differs from attachment's contentId, so fields should NOT be cleared + assertThat(values).containsEntry("profilePicture_contentId", "different-new-content-id"); + assertThat(values).doesNotContainKey("profilePicture_status"); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifierTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifierTest.java index 3c2444cc2..45998fb98 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifierTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifierTest.java @@ -198,4 +198,88 @@ private void runTestForDirectSelectScannedAt(CqnSelect select, int expectedField .count(); assertThat(count).isEqualTo(expectedFieldCount); } + + // --- Inline attachment modifier tests --- + + @Test + void inlineAttachmentFieldsAreAdded() { + CqnSelect select = + Select.from(RootTable_.class) + .columns(RootTable_::ID, RootTable_::title, b -> b.get("profilePicture_content")); + + cut = new BeforeReadItemsModifier(List.of(), List.of("profilePicture")); + List resultItems = cut.items(select.items()); + + var contentIdCount = + resultItems.stream() + .filter( + item -> + item.isRef() && item.asRef().displayName().equals("profilePicture_contentId")) + .count(); + var statusCount = + resultItems.stream() + .filter( + item -> item.isRef() && item.asRef().displayName().equals("profilePicture_status")) + .count(); + var scannedAtCount = + resultItems.stream() + .filter( + item -> + item.isRef() && item.asRef().displayName().equals("profilePicture_scannedAt")) + .count(); + assertThat(contentIdCount).isEqualTo(1); + assertThat(statusCount).isEqualTo(1); + assertThat(scannedAtCount).isEqualTo(1); + } + + @Test + void inlineAttachmentFieldsNotDuplicatedIfAlreadyPresent() { + CqnSelect select = + Select.from(RootTable_.class) + .columns(RootTable_::ID, b -> b.get("profilePicture_contentId")); + + cut = new BeforeReadItemsModifier(List.of(), List.of("profilePicture")); + List resultItems = cut.items(select.items()); + + var contentIdCount = + resultItems.stream() + .filter( + item -> + item.isRef() && item.asRef().displayName().equals("profilePicture_contentId")) + .count(); + assertThat(contentIdCount).isEqualTo(1); + } + + @Test + void emptyInlinePrefixesDoNotAddFields() { + CqnSelect select = Select.from(RootTable_.class).columns(RootTable_::ID, RootTable_::title); + + cut = new BeforeReadItemsModifier(List.of(), List.of()); + List resultItems = cut.items(select.items()); + + var inlineFieldCount = + resultItems.stream() + .filter( + item -> item.isRef() && item.asRef().displayName().startsWith("profilePicture_")) + .count(); + assertThat(inlineFieldCount).isEqualTo(0); + } + + @Test + void inlineAttachmentFieldsNotAddedWithoutContentInSelect() { + // When profilePicture_content is NOT in the select (e.g. SELECT ID, title), + // the modifier must NOT add profilePicture_contentId/status. Otherwise it + // would convert a SELECT * into a partial column list, breaking draftPrepare. + CqnSelect select = Select.from(RootTable_.class).columns(RootTable_::ID, RootTable_::title); + + cut = new BeforeReadItemsModifier(List.of(), List.of("profilePicture")); + List resultItems = cut.items(select.items()); + + var inlineFieldCount = + resultItems.stream() + .filter( + item -> item.isRef() && item.asRef().displayName().startsWith("profilePicture_")) + .count(); + assertThat(inlineFieldCount).isEqualTo(0); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java index e7d8cfa33..f110e5cb2 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java @@ -8,11 +8,28 @@ import static org.junit.jupiter.api.Assertions.assertTrue; 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.handler.helper.RuntimeHelper; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.services.runtime.CdsRuntime; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +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 ApplicationHandlerHelperTest { + private static CdsRuntime runtime; + + @BeforeAll + static void classSetup() { + runtime = RuntimeHelper.runtime; + } + @Test void keysAreInData() { Map keys = Map.of("key1", "value1", "key2", "value2"); @@ -57,4 +74,209 @@ void removeDraftKey() { assertFalse(result.containsKey("IsActiveEntity")); assertTrue(result.containsKey("key1")); } + + // --- Inline attachment tests --- + + private CdsEntity getRootTableEntity() { + return runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + } + + private CdsEntity getAttachmentEntity() { + return runtime + .getCdsModel() + .findEntity("unit.test.TestService.RootTable.attachments") + .orElseThrow(); + } + + @Test + void hasInlineAttachmentElementsReturnsTrueForEntityWithInlineField() { + var entity = getRootTableEntity(); + assertThat(ApplicationHandlerHelper.hasInlineAttachmentElements(entity)).isTrue(); + } + + @Test + void hasInlineAttachmentElementsReturnsFalseForAttachmentEntity() { + var entity = getAttachmentEntity(); + assertThat(ApplicationHandlerHelper.hasInlineAttachmentElements(entity)).isFalse(); + } + + @Test + void getInlineAttachmentFieldNamesReturnsCorrectPrefixes() { + var entity = getRootTableEntity(); + List prefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); + assertThat(prefixes).containsExactly("profilePicture"); + } + + @Test + void getInlineAttachmentFieldNamesReturnsEmptyForAttachmentEntity() { + var entity = getAttachmentEntity(); + List prefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); + assertThat(prefixes).isEmpty(); + } + + @Test + void isMediaEntityReturnsTrueForEntityWithInlineAttachment() { + var entity = getRootTableEntity(); + assertThat(ApplicationHandlerHelper.isMediaEntity(entity)).isTrue(); + } + + @Test + void isMediaEntityReturnsTrueForDirectMediaEntity() { + var entity = getAttachmentEntity(); + assertThat(ApplicationHandlerHelper.isMediaEntity(entity)).isTrue(); + } + + @Test + void isDirectMediaEntityReturnsFalseForEntityWithOnlyInlineAttachments() { + var entity = getRootTableEntity(); + assertThat(ApplicationHandlerHelper.isDirectMediaEntity(entity)).isFalse(); + } + + @Test + void isDirectMediaEntityReturnsTrueForAttachmentEntity() { + var entity = getAttachmentEntity(); + assertThat(ApplicationHandlerHelper.isDirectMediaEntity(entity)).isTrue(); + } + + @Test + void isInlineAttachmentContentFieldReturnsTrueForPrefixedContent() { + var entity = getRootTableEntity(); + var contentElement = entity.findElement("profilePicture_content").orElseThrow(); + assertThat(ApplicationHandlerHelper.isInlineAttachmentContentField(entity, contentElement)) + .isTrue(); + } + + @Test + void isInlineAttachmentContentFieldReturnsFalseForNonContentField() { + var entity = getRootTableEntity(); + var mimeTypeElement = entity.findElement("profilePicture_mimeType").orElseThrow(); + assertThat(ApplicationHandlerHelper.isInlineAttachmentContentField(entity, mimeTypeElement)) + .isFalse(); + } + + @Test + void isInlineAttachmentContentFieldReturnsFalseForRegularField() { + var entity = getRootTableEntity(); + var titleElement = entity.findElement("title").orElseThrow(); + assertThat(ApplicationHandlerHelper.isInlineAttachmentContentField(entity, titleElement)) + .isFalse(); + } + + @Test + void getInlineAttachmentPrefixReturnsPrefixForFlattenedField() { + var entity = getRootTableEntity(); + Optional prefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix(entity, "profilePicture_content"); + assertThat(prefix).isPresent().contains("profilePicture"); + } + + @Test + void getInlineAttachmentPrefixReturnsPrefixForContentIdField() { + var entity = getRootTableEntity(); + Optional prefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix(entity, "profilePicture_contentId"); + assertThat(prefix).isPresent().contains("profilePicture"); + } + + @Test + void getInlineAttachmentPrefixReturnsEmptyForRegularField() { + var entity = getRootTableEntity(); + Optional prefix = ApplicationHandlerHelper.getInlineAttachmentPrefix(entity, "title"); + assertThat(prefix).isEmpty(); + } + + @Test + void getInlineAttachmentPrefixReturnsEmptyForUnprefixedContentId() { + var entity = getRootTableEntity(); + Optional prefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix(entity, "contentId"); + assertThat(prefix).isEmpty(); + } + + @Test + void extractInlineAttachmentStripsPrefix() { + Map parentValues = + Map.of( + "ID", "123", + "title", "Test", + "profilePicture_contentId", "cid-abc", + "profilePicture_mimeType", "image/png", + "profilePicture_fileName", "photo.png", + "profilePicture_status", "Clean"); + + Attachments result = + ApplicationHandlerHelper.extractInlineAttachment(parentValues, "profilePicture"); + + assertThat(result.getContentId()).isEqualTo("cid-abc"); + assertThat(result.getMimeType()).isEqualTo("image/png"); + assertThat(result.getFileName()).isEqualTo("photo.png"); + assertThat(result.getStatus()).isEqualTo("Clean"); + // Non-prefixed fields should NOT be included + assertThat(result.get("ID")).isNull(); + assertThat(result.get("title")).isNull(); + } + + @Test + void condenseAttachmentsIncludesInlineAttachments() { + var entity = getRootTableEntity(); + var data = CdsData.create(); + data.put("ID", "123"); + data.put("profilePicture_content", new ByteArrayInputStream(new byte[0])); + data.put("profilePicture_contentId", "cid-inline"); + data.put("profilePicture_mimeType", "image/png"); + data.put("profilePicture_status", "Clean"); + + List result = ApplicationHandlerHelper.condenseAttachments(List.of(data), entity); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getContentId()).isEqualTo("cid-inline"); + assertThat(result.get(0).getMimeType()).isEqualTo("image/png"); + } + + @Test + void condenseAttachmentsAvoidsDuplicateInlineEntries() { + var entity = getRootTableEntity(); + var data = CdsData.create(); + data.put("ID", "123"); + data.put("profilePicture_content", new ByteArrayInputStream(new byte[0])); + data.put("profilePicture_contentId", "cid-inline"); + data.put("profilePicture_status", "Clean"); + + // Same data twice — condenseAttachments should deduplicate by contentId + List result = + ApplicationHandlerHelper.condenseAttachments(List.of(data, data), entity); + + long distinctContentIds = result.stream().map(Attachments::getContentId).distinct().count(); + assertThat(distinctContentIds).isEqualTo(1); + } + + @Test + void containsContentFieldReturnsTrueForInlineContent() { + var entity = getRootTableEntity(); + var data = CdsData.create(); + data.put("profilePicture_content", new ByteArrayInputStream(new byte[0])); + + assertThat(ApplicationHandlerHelper.containsContentField(entity, List.of(data))).isTrue(); + } + + @Test + void containsContentFieldReturnsFalseForNoContent() { + var entity = getRootTableEntity(); + var data = CdsData.create(); + data.put("ID", "123"); + data.put("title", "Test"); + + assertThat(ApplicationHandlerHelper.containsContentField(entity, List.of(data))).isFalse(); + } + + @Test + void mediaContentFilterMatchesInlineContentField() { + var entity = getRootTableEntity(); + var data = CdsData.create(); + data.put("profilePicture_content", (InputStream) new ByteArrayInputStream(new byte[0])); + data.put("profilePicture_contentId", "cid-123"); + + // Use containsContentField which internally uses MEDIA_CONTENT_FILTER + assertThat(ApplicationHandlerHelper.containsContentField(entity, List.of(data))).isTrue(); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AssociationCascaderTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AssociationCascaderTest.java index dfac40883..cb7e6baa6 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AssociationCascaderTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AssociationCascaderTest.java @@ -140,4 +140,26 @@ private void verifyItemAttachments( .isEqualTo(itemAttachmentNodeName); assertThat(itemAttachmentNode.getChildren()).isNotNull().isEmpty(); } + + @Test + void rootEntityWithInlineAttachmentDoesNotAddExtraTreeChild() { + // Inline attachment fields on the root entity are NOT represented as NodeTree children. + // They're handled directly by AttachmentsReader via CQL select columns. + var serviceEntity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + + assertThat(ApplicationHandlerHelper.hasInlineAttachmentElements(serviceEntity)) + .as("RootTable should have inline attachment elements (profilePicture)") + .isTrue(); + + var rootNode = cut.findEntityPath(runtime.getCdsModel(), serviceEntity); + var rootChildren = rootNode.getChildren(); + + // Inline fields on root do NOT create extra NodeTree children + // only composition associations (attachments, itemTable) appear + assertThat(rootChildren).hasSize(2); + assertThat(rootChildren.get(0).getIdentifier().associationName()) + .isEqualTo(RootTable.ATTACHMENTS); + assertThat(rootChildren.get(1).getIdentifier().associationName()) + .isEqualTo(RootTable.ITEM_TABLE); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReaderTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReaderTest.java index 4ec146e3e..d86cdda5d 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReaderTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReaderTest.java @@ -15,6 +15,7 @@ import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.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.handler.helper.RuntimeHelper; import com.sap.cds.feature.attachments.helper.LogObserver; import com.sap.cds.ql.CQL; import com.sap.cds.ql.Delete; @@ -297,4 +298,26 @@ private String getExpectedSelectStatement() { private String removeSpaceInString(String input) { return input.replace("\n", "").replace("\t", "").replace(" ", ""); } + + // --- Inline Attachment Tests --- + + @Test + void selectIncludesInlineColumnsForEntityWithInlineAttachments() { + // Use real RootTable entity so getInlineAttachmentFieldNames returns ["profilePicture"] + CdsEntity realEntity = + RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + var nodeTree = new NodeTree(new AssociationIdentifier("", RootTable_.CDS_NAME)); + when(cascader.findEntityPath(any(), any(CdsEntity.class))).thenReturn(nodeTree); + List data = List.of(Attachments.create()); + when(result.listOf(Attachments.class)).thenReturn(data); + + CqnDelete delete = Delete.from(RootTable_.CDS_NAME); + cut.readAttachments(model, realEntity, delete); + + verify(persistenceService).run(selectArgumentCaptor.capture()); + var selectStr = selectArgumentCaptor.getValue().toString(); + // Inline columns: profilePicture_contentId and profilePicture_status + assertThat(selectStr).contains("profilePicture_contentId"); + assertThat(selectStr).contains("profilePicture_status"); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java index 06e4045fa..2edd43854 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java @@ -316,4 +316,22 @@ private void getEntityAndMockContext(String cdsName) { Optional serviceEntity = runtime.getCdsModel().findEntity(cdsName); when(eventContext.getTarget()).thenReturn(serviceEntity.orElseThrow()); } + + // --- Inline Attachment Tests --- + + @Test + void entityWithInlineAttachmentsIsProcessed() { + // RootTable has profilePicture: Attachment (inline) + // deepSearchForAttachments should detect it via hasInlineAttachmentElements and process + getEntityAndMockContext(RootTable_.CDS_NAME); + CqnDelete delete = Delete.from(RootTable_.class); + when(eventContext.getCqn()).thenReturn(delete); + when(eventContext.getModel()).thenReturn(runtime.getCdsModel()); + when(eventContext.getEvent()).thenReturn("DRAFT_CANCEL"); + + cut.processBeforeDraftCancel(eventContext); + + // Inline attachment on root means attachmentsReader is called + verify(attachmentsReader, atLeastOnce()).readAttachments(any(), any(), any()); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java index 035dd766b..db678d6e3 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.sap.cds.CdsData; import com.sap.cds.Result; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Events; @@ -193,4 +194,65 @@ private void getEntityAndMockContext(String cdsName) { private void mockTargetInUpdateContext(CdsEntity serviceEntity) { when(eventContext.getTarget()).thenReturn(serviceEntity); } + + // --- Inline Attachment Tests --- + + @Test + void inlineContentFieldTriggersConverterViaMEDIA_CONTENT_FILTER() { + // RootTable has profilePicture : Attachment (inline). + // MEDIA_CONTENT_FILTER should match profilePicture_content and the converter + // should call persistence + eventFactory. + getEntityAndMockContext(RootTable_.CDS_NAME); + + var data = CdsData.create(); + data.put("ID", UUID.randomUUID().toString()); + data.put("profilePicture_content", mock(InputStream.class)); + + var result = mock(Result.class); + when(persistence.run(any(CqnSelect.class))).thenReturn(result); + + cut.processBeforeDraftPatch(eventContext, List.of(data)); + + // The converter reads from persistence (draft entity) and calls eventFactory + verify(persistence).run(any(CqnSelect.class)); + verify(eventFactory).getEvent(any(), any(), any()); + } + + @Test + void inlineDeleteExtractsExistingContentIdFromFlattenedDbResult() { + // When the user deletes an inline attachment, the PATCH data has + // profilePicture_content: null. The DB result has flattened column names + // (profilePicture_contentId). The handler must extract the existing contentId + // from the flattened DB result so the event factory can return deleteEvent. + getEntityAndMockContext(RootTable_.CDS_NAME); + + String bookId = UUID.randomUUID().toString(); + String existingContentId = UUID.randomUUID().toString(); + + // Incoming data: user deleting the inline attachment (content = null) + var data = CdsData.create(); + data.put("ID", bookId); + data.put("profilePicture_content", null); + + // DB result: existing draft row with flattened inline attachment fields + var dbRow = Attachments.create(); + dbRow.put("ID", bookId); + dbRow.put("profilePicture_contentId", existingContentId); + dbRow.put("profilePicture_status", "Clean"); + dbRow.put("profilePicture_mimeType", "image/png"); + dbRow.put("profilePicture_fileName", "avatar.png"); + + var result = mock(Result.class); + when(persistence.run(any(CqnSelect.class))).thenReturn(result); + when(result.listOf(Attachments.class)).thenReturn(List.of(dbRow)); + + cut.processBeforeDraftPatch(eventContext, List.of(data)); + + // Verify the event factory receives an Attachments with the correctly extracted + // (unprefixed) contentId from the DB data + ArgumentCaptor attachmentCaptor = ArgumentCaptor.forClass(Attachments.class); + verify(eventFactory).getEvent(any(), any(), attachmentCaptor.capture()); + Attachments captured = attachmentCaptor.getValue(); + assertThat(captured.getContentId()).isEqualTo(existingContentId); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java index cbfd67a5d..9acf1de4e 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java @@ -25,6 +25,7 @@ import com.sap.cds.services.impl.changeset.ChangeSetContextImpl; import java.util.Map; import java.util.Objects; +import java.util.Optional; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -150,7 +151,8 @@ void readMethodHasCorrectAnnotation() throws NoSuchMethodException { void malwareScannerRegisteredForEndOfTransaction() { var listener = mock(ChangeSetListener.class); var entity = mock(CdsEntity.class); - when(malwareScanProvider.getChangeSetListener(entity, "contentId")).thenReturn(listener); + when(malwareScanProvider.getChangeSetListener(entity, "contentId", Optional.empty())) + .thenReturn(listener); var createContext = AttachmentCreateEventContext.create(); createContext.setAttachmentIds(Map.of(Attachments.ID, "contentId")); createContext.setData(MediaData.create()); @@ -160,7 +162,7 @@ void malwareScannerRegisteredForEndOfTransaction() { cut.createAttachment(createContext); cut.afterCreateAttachment(createContext); - verify(malwareScanProvider).getChangeSetListener(entity, "contentId"); + verify(malwareScanProvider).getChangeSetListener(entity, "contentId", Optional.empty()); } private void closeChangeSetContext() throws Exception { diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunnerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunnerTest.java index 59bc339e8..1c95bfd37 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunnerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunnerTest.java @@ -20,6 +20,7 @@ import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.ChangeSetContextRunner; import com.sap.cds.services.runtime.RequestContextRunner; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -70,7 +71,7 @@ void setup() { attachmentMalwareScanner = mock(AttachmentMalwareScanner.class); cut = new EndTransactionMalwareScanRunner( - attachmentEntity, contentId, attachmentMalwareScanner, runtime); + attachmentEntity, contentId, Optional.empty(), attachmentMalwareScanner, runtime); observer = LogObserver.create(cut.getClass().getName()); } @@ -88,7 +89,7 @@ void notCompletedTransactionDoNothing() { return null; }) .when(attachmentMalwareScanner) - .scanAttachment(attachmentEntity, contentId); + .scanAttachment(attachmentEntity, contentId, Optional.empty()); cut.afterClose(false); @@ -111,12 +112,12 @@ void completedTransactionScanAttachments() { return null; }) .when(attachmentMalwareScanner) - .scanAttachment(attachmentEntity, contentId); + .scanAttachment(attachmentEntity, contentId, Optional.empty()); cut.afterClose(true); Awaitility.await().until(executionDone::get); - verify(attachmentMalwareScanner).scanAttachment(attachmentEntity, contentId); + verify(attachmentMalwareScanner).scanAttachment(attachmentEntity, contentId, Optional.empty()); assertThat(usedThread.get()).isNotEmpty().isNotEqualTo(Thread.currentThread().getName()); } @@ -127,7 +128,7 @@ void exceptionDuringScanningLogged() { throw new RuntimeException("Some exception"); }) .when(attachmentMalwareScanner) - .scanAttachment(attachmentEntity, contentId); + .scanAttachment(attachmentEntity, contentId, Optional.empty()); observer.start(); cut.afterClose(true); @@ -146,12 +147,12 @@ void directScanCallScanAttachments() { return null; }) .when(attachmentMalwareScanner) - .scanAttachment(attachmentEntity, contentId); + .scanAttachment(attachmentEntity, contentId, Optional.empty()); - cut.scanAsync(attachmentEntity, contentId); + cut.scanAsync(attachmentEntity, contentId, Optional.empty()); Awaitility.await().until(executionDone::get); - verify(attachmentMalwareScanner).scanAttachment(attachmentEntity, contentId); + verify(attachmentMalwareScanner).scanAttachment(attachmentEntity, contentId, Optional.empty()); assertThat(usedThread.get()).isNotEmpty().isNotEqualTo(Thread.currentThread().getName()); } @@ -162,10 +163,10 @@ void exceptionDuringScanningLoggedForDirectScanCall() { throw new RuntimeException("Some exception"); }) .when(attachmentMalwareScanner) - .scanAttachment(attachmentEntity, contentId); + .scanAttachment(attachmentEntity, contentId, Optional.empty()); observer.start(); - cut.scanAsync(attachmentEntity, contentId); + cut.scanAsync(attachmentEntity, contentId, Optional.empty()); verifyLogIsWritten(); } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java index 343cd6b15..530799978 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java @@ -27,6 +27,7 @@ import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntime; import java.io.InputStream; +import java.util.Optional; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -71,7 +72,7 @@ void correctSelectForNonDraftEntity() { var entity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME); when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); verify(persistenceService).run(selectCaptor.capture()); var select = selectCaptor.getValue(); @@ -84,7 +85,7 @@ void correctSelectForDraftEntity() { var entity = runtime.getCdsModel().findEntity(getTestServiceAttachmentName()); mockSelectResult(Attachments.create(), MalwareScanResultStatus.CLEAN); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); verify(persistenceService, times(2)).run(selectCaptor.capture()); var selects = selectCaptor.getAllValues(); @@ -110,7 +111,7 @@ void fallbackToActiveEntityIfDraftHasNoData() { when(result.single(Attachments.class)).thenReturn(cdsData); when(malwareScanClient.scanContent(any())).thenReturn(MalwareScanResultStatus.CLEAN); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); verify(malwareScanClient).scanContent(content); verify(persistenceService, times(2)).run(selectCaptor.capture()); @@ -129,7 +130,8 @@ void exceptionIfTooManyResultsAreSelected() { when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); when(result.rowCount()).thenReturn(2L); - assertThrows(IllegalStateException.class, () -> cut.scanAttachment(entity, "")); + assertThrows( + IllegalStateException.class, () -> cut.scanAttachment(entity, "", Optional.empty())); } @ParameterizedTest @@ -138,7 +140,7 @@ void dataAreUpdatedWithStatus(MalwareScanResultStatus status) { var entity = runtime.getCdsModel().findEntity(getTestServiceAttachmentName()); mockSelectResult(Attachments.create(), status); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); verifyPersistenceServiceCalledCorrectlyForReadAndUpdate(status); } @@ -152,7 +154,7 @@ void dataAreUpdatedWithStatusFromFailingScanClient() { when(malwareScanClient.scanContent(any())) .thenThrow(new ServiceException("Error reading attachment")); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); verifyPersistenceServiceCalledCorrectlyForReadAndUpdate(MalwareScanResultStatus.FAILED); } @@ -166,7 +168,7 @@ void dataAreUpdatedWithStatusFromFailingAttachmentService() { when(attachmentService.readAttachment(any())) .thenThrow(new ServiceException("Error reading attachment")); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); verifyPersistenceServiceCalledCorrectlyForReadAndUpdate(MalwareScanResultStatus.FAILED); } @@ -179,7 +181,7 @@ void contentTakenFromTheDatabaseSelect() { data.put("content", content); mockSelectResult(data, MalwareScanResultStatus.CLEAN); - cut.scanAttachment(entity.orElseThrow(), ""); + cut.scanAttachment(entity.orElseThrow(), "", Optional.empty()); verify(malwareScanClient, times(1)).scanContent(content); verifyNoInteractions(attachmentService); @@ -195,7 +197,7 @@ void contentTakenFromTheAttachmentService() { var content = mock(InputStream.class); when(attachmentService.readAttachment(contentId)).thenReturn(content); - cut.scanAttachment(entity.orElseThrow(), ""); + cut.scanAttachment(entity.orElseThrow(), "", Optional.empty()); verify(attachmentService, times(1)).readAttachment(contentId); verify(malwareScanClient, times(1)).scanContent(content); @@ -211,7 +213,7 @@ void contentTakenFromTheAttachmentServiceForNonDraft() { var content = mock(InputStream.class); when(attachmentService.readAttachment(contentId)).thenReturn(content); - cut.scanAttachment(entity.orElseThrow(), ""); + cut.scanAttachment(entity.orElseThrow(), "", Optional.empty()); verify(attachmentService, times(1)).readAttachment(contentId); verify(malwareScanClient, times(1)).scanContent(content); @@ -230,7 +232,7 @@ void updateAttemptedForAllEntitiesEvenWhenActiveHasNoData() { .thenReturn(Attachments.create()); when(malwareScanClient.scanContent(any())).thenReturn(MalwareScanResultStatus.CLEAN); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); verify(persistenceService, times(2)).run(updateCaptor.capture()); var updateList = updateCaptor.getAllValues(); @@ -249,7 +251,7 @@ void clientNotCalledIfNoInstanceBound() { when(result.rowCount()).thenReturn(1L); when(result.single(Attachments.class)).thenReturn(Attachments.create()); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); verifyNoInteractions(malwareScanClient); verify(persistenceService, times(2)).run(updateCaptor.capture()); 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..54b03ebde 100644 --- a/cds-feature-attachments/src/test/resources/cds/db-model.cds +++ b/cds-feature-attachments/src/test/resources/cds/db-model.cds @@ -2,6 +2,7 @@ namespace unit.test; using {cuid} from '@sap/cds/common'; using {sap.attachments.Attachments} from '../../../main/resources/cds/com.sap.cds/cds-feature-attachments'; +using {sap.attachments.Attachment as AttachmentType} from '../../../main/resources/cds/com.sap.cds/cds-feature-attachments'; using from '@sap/cds/srv/outbox'; entity Attachment : Attachments { @@ -12,6 +13,7 @@ entity Roots : cuid { itemTable : Composition of many Items on itemTable.rootId = $self.ID; attachments : Composition of many Attachments; + profilePicture : AttachmentType; } entity Items : cuid { diff --git a/samples/bookshop/app/admin-books/fiori-service.cds b/samples/bookshop/app/admin-books/fiori-service.cds index 36fa09086..9690b7297 100644 --- a/samples/bookshop/app/admin-books/fiori-service.cds +++ b/samples/bookshop/app/admin-books/fiori-service.cds @@ -31,6 +31,17 @@ annotate AdminService.Books with @(UI: { $Type : 'UI.ReferenceFacet', Label : '{i18n>Admin}', Target: '@UI.FieldGroup#Admin' + }, + { + $Type : 'UI.ReferenceFacet', + Label : 'Profile Icon', + Target: '@UI.FieldGroup#ProfileIcon' + }, + { + $Type : 'UI.ReferenceFacet', + ID : 'AttachmentsFacet', + Label : '{i18n>attachments}', + Target: 'attachments/@UI.LineItem' } ], FieldGroup #General: {Data: [ @@ -48,6 +59,9 @@ annotate AdminService.Books with @(UI: { {Value: createdAt}, {Value: modifiedBy}, {Value: modifiedAt} + ]}, + FieldGroup #ProfileIcon: {Data: [ + {Value: profileIcon_content} ]} }); diff --git a/samples/bookshop/pom.xml b/samples/bookshop/pom.xml index a49e3c384..4ea901222 100644 --- a/samples/bookshop/pom.xml +++ b/samples/bookshop/pom.xml @@ -48,7 +48,7 @@ com.sap.cds cds-feature-attachments - 1.5.0 + 1.4.0-SNAPSHOT diff --git a/samples/bookshop/srv/attachments.cds b/samples/bookshop/srv/attachments.cds index 8603e44ba..55f29b504 100644 --- a/samples/bookshop/srv/attachments.cds +++ b/samples/bookshop/srv/attachments.cds @@ -1,5 +1,6 @@ using {sap.capire.bookshop as my} from '../db/schema'; using {sap.attachments.Attachments} from 'com.sap.cds/cds-feature-attachments'; +using {sap.attachments.Attachment} from 'com.sap.cds/cds-feature-attachments'; // Extend Books entity to support file attachments (images, PDFs, documents) // Each book can have multiple attachments via composition relationship @@ -23,6 +24,11 @@ annotate my.Books.mediaValidatedAttachments with { ]; } +// Extend Books entity with an inline single-file attachment (profile icon) +extend my.Books with { + profileIcon : Attachment; +} + // Add UI component for attachments table to the Browse Books App using {CatalogService as service} from '../app/services'; @@ -33,15 +39,9 @@ annotate service.Books with @(UI.Facets: [{ Target: 'attachments/@UI.LineItem' }]); -// Adding the UI Component (a table) to the Administrator App -using {AdminService as adminService} from '../app/services'; - -annotate adminService.Books with @(UI.Facets: [{ - $Type : 'UI.ReferenceFacet', - ID : 'AttachmentsFacet', - Label : '{i18n>attachments}', - Target: 'attachments/@UI.LineItem' -}]); +// AdminService Facets (including attachments and profileIcon) are defined in +// app/admin-books/fiori-service.cds. Don't re-annotate UI.Facets here, +// as it would override the complete facet list defined there. service nonDraft { From 13ce9b4e3b6ed8f26034d3868ec61e7b8589cb52 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Mon, 30 Mar 2026 10:19:19 +0200 Subject: [PATCH 02/45] fix: resolve correct inline prefix for multi-field inline attachments --- .../DeleteAttachmentsHandler.java | 15 ++++- .../ModifyApplicationHandlerHelper.java | 60 +++++++------------ .../modifyevents/CreateAttachmentEvent.java | 22 +++---- .../DoNothingAttachmentEvent.java | 7 ++- .../MarkAsDeletedAttachmentEvent.java | 19 ++---- .../modifyevents/ModifyAttachmentEvent.java | 9 ++- .../modifyevents/UpdateAttachmentEvent.java | 11 +++- .../DraftCancelAttachmentsHandler.java | 7 ++- .../DraftPatchAttachmentsHandler.java | 3 +- .../service/AttachmentsServiceImpl.java | 1 + .../DefaultAttachmentsServiceHandler.java | 14 +---- .../model/service/CreateAttachmentInput.java | 5 +- .../CreateAttachmentsHandlerTest.java | 2 +- .../DeleteAttachmentsHandlerTest.java | 7 ++- .../UpdateAttachmentsHandlerTest.java | 8 ++- .../ModifyApplicationHandlerHelperTest.java | 18 ++++-- .../CreateAttachmentEventTest.java | 24 +++++--- .../DoNothingAttachmentEventTest.java | 4 +- .../MarkAsDeletedAttachmentEventTest.java | 15 ++--- .../UpdateAttachmentEventTest.java | 9 ++- .../DraftCancelAttachmentsHandlerTest.java | 4 +- .../DraftPatchAttachmentsHandlerTest.java | 2 +- .../service/AttachmentsServiceImplTest.java | 58 +++++++++++++++++- .../AttachmentsServiceImplHandlerTest.java | 20 +++++++ .../app/admin-books/fiori-service.cds | 8 +++ samples/bookshop/srv/attachments.cds | 3 +- 26 files changed, 225 insertions(+), 130 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandler.java index a11f3e6da..93af00333 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandler.java @@ -19,6 +19,7 @@ import com.sap.cds.services.handler.annotations.ServiceName; import java.io.InputStream; import java.util.List; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,9 +53,17 @@ void processBefore(CdsDeleteEventContext context) { context.getModel(), context.getTarget(), context.getCqn()); Converter converter = - (path, element, value) -> - deleteEvent.processEvent( - path, (InputStream) value, Attachments.of(path.target().values()), context); + (path, element, value) -> { + Optional inlinePrefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix( + path.target().entity(), element.getName()); + return deleteEvent.processEvent( + path, + (InputStream) value, + Attachments.of(path.target().values()), + context, + inlinePrefix); + }; CdsDataProcessor.create() .addConverter(ApplicationHandlerHelper.MEDIA_CONTENT_FILTER, converter) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java index bbafcee7b..24d095022 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java @@ -52,14 +52,19 @@ public static void handleAttachmentForEntities( ApplicationHandlerHelper.condenseAttachments(existingAttachments, entity); Converter converter = - (path, element, value) -> - handleAttachmentForEntity( - condensedExistingAttachments, - eventFactory, - eventContext, - path, - (InputStream) value, - defaultMaxSize); + (path, element, value) -> { + Optional inlinePrefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix( + path.target().entity(), element.getName()); + return handleAttachmentForEntity( + condensedExistingAttachments, + eventFactory, + eventContext, + path, + (InputStream) value, + defaultMaxSize, + inlinePrefix); + }; CdsDataProcessor.create() .addConverter(ApplicationHandlerHelper.MEDIA_CONTENT_FILTER, converter) @@ -75,6 +80,7 @@ public static void handleAttachmentForEntities( * @param path the {@link Path} of the attachment * @param content the content of the attachment * @param defaultMaxSize the default max size to use when no annotation is present + * @param inlinePrefix the inline attachment field prefix, or empty for composition-based * @return the processed content as an {@link InputStream} */ public static InputStream handleAttachmentForEntity( @@ -83,26 +89,18 @@ public static InputStream handleAttachmentForEntity( EventContext eventContext, Path path, InputStream content, - String defaultMaxSize) { + String defaultMaxSize, + Optional inlinePrefix) { Map keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys()); ReadonlyDataContextEnhancer.restoreReadonlyFields((CdsData) path.target().values()); Attachments attachment = getExistingAttachment(keys, existingAttachments); - // For inline attachment fields, extract contentId using prefix + // For inline attachment fields, extract contentId using the known prefix String contentId; - Optional inlinePrefix = - ApplicationHandlerHelper.getInlineAttachmentPrefix( - path.target().entity(), Attachments.CONTENT_ID); - if (inlinePrefix.isEmpty()) { - // Try to detect prefix from any inline attachment field present in data - contentId = - resolveInlineOrDirectField( - path.target().values(), path.target().entity(), Attachments.CONTENT_ID); - } else { + if (inlinePrefix.isPresent()) { contentId = (String) path.target().values().get(inlinePrefix.get() + "_" + Attachments.CONTENT_ID); - } - if (contentId == null) { + } else { contentId = (String) path.target().values().get(Attachments.CONTENT_ID); } String contentLength = eventContext.getParameterInfo().getHeader("Content-Length"); @@ -130,7 +128,8 @@ public static InputStream handleAttachmentForEntity( ModifyAttachmentEvent eventToProcess = eventFactory.getEvent(wrappedContent, contentId, attachment); try { - return eventToProcess.processEvent(path, wrappedContent, attachment, eventContext); + return eventToProcess.processEvent( + path, wrappedContent, attachment, eventContext, inlinePrefix); } catch (Exception e) { if (wrappedContent != null && wrappedContent.isLimitExceeded()) { throw tooLargeException; @@ -161,23 +160,6 @@ private static String getValMaxValue(CdsEntity entity, String defaultMaxSize) { .orElse(defaultMaxSize); } - /** Resolves a field value from either direct or inline prefixed field name. */ - private static String resolveInlineOrDirectField( - Map values, - com.sap.cds.reflect.CdsStructuredType entityType, - String fieldName) { - // First try direct field - Object direct = values.get(fieldName); - if (direct != null) return (String) direct; - // Then try inline prefixed fields - List prefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entityType); - for (String prefix : prefixes) { - Object prefixed = values.get(prefix + "_" + fieldName); - if (prefixed != null) return (String) prefixed; - } - return null; - } - private static Attachments getExistingAttachment( Map keys, List existingAttachments) { return existingAttachments.stream() diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java index d388b36f5..91ae28f3c 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java @@ -17,7 +17,6 @@ import com.sap.cds.services.EventContext; import com.sap.cds.services.changeset.ChangeSetListener; import java.io.InputStream; -import java.util.List; import java.util.Map; import java.util.Optional; import org.slf4j.Logger; @@ -44,15 +43,17 @@ public CreateAttachmentEvent( @Override public InputStream processEvent( - Path path, InputStream content, Attachments attachment, EventContext eventContext) { + Path path, + InputStream content, + Attachments attachment, + EventContext eventContext, + Optional inlinePrefix) { logger.debug( "Calling attachment service with create event for entity {}", path.target().entity().getQualifiedName()); Map values = path.target().values(); Map keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys()); - // Detect inline prefix if applicable - Optional inlinePrefix = detectInlinePrefix(path); Optional mimeTypeOptional = getFieldValue(MediaData.MIME_TYPE, values, attachment, inlinePrefix); Optional fileNameOptional = @@ -64,7 +65,8 @@ public InputStream processEvent( path.target().entity(), fileNameOptional.orElse(null), mimeTypeOptional.orElse(null), - content); + content, + inlinePrefix); AttachmentModificationResult result = attachmentService.createAttachment(createEventInput); ChangeSetListener createListener = listenerProvider.provideListener(result.contentId(), eventContext.getCdsRuntime()); @@ -85,16 +87,6 @@ public InputStream processEvent( return result.isInternalStored() ? content : null; } - private static Optional detectInlinePrefix(Path path) { - List prefixes = - ApplicationHandlerHelper.getInlineAttachmentFieldNames(path.target().entity()); - if (!prefixes.isEmpty() - && !path.target().entity().getAnnotationValue("_is_media_data", false)) { - return Optional.of(prefixes.get(0)); - } - return Optional.empty(); - } - private static Optional getFieldValue( String fieldName, Map values, diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEvent.java index b409d274a..4d56753b0 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEvent.java @@ -7,6 +7,7 @@ import com.sap.cds.ql.cqn.Path; import com.sap.cds.services.EventContext; import java.io.InputStream; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,7 +21,11 @@ public class DoNothingAttachmentEvent implements ModifyAttachmentEvent { @Override public InputStream processEvent( - Path path, InputStream content, Attachments attachment, EventContext eventContext) { + Path path, + InputStream content, + Attachments attachment, + EventContext eventContext, + Optional inlinePrefix) { logger.debug("Do nothing event for entity {}", path.target().entity().getQualifiedName()); return content; diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java index ff86c4497..9085b8e13 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java @@ -8,14 +8,12 @@ import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; -import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.model.service.MarkAsDeletedInput; import com.sap.cds.ql.cqn.Path; import com.sap.cds.services.EventContext; import com.sap.cds.services.draft.DraftService; import java.io.InputStream; -import java.util.List; import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,7 +35,11 @@ public MarkAsDeletedAttachmentEvent(AttachmentService attachmentService) { @Override public InputStream processEvent( - Path path, InputStream content, Attachments attachment, EventContext eventContext) { + Path path, + InputStream content, + Attachments attachment, + EventContext eventContext, + Optional inlinePrefix) { String qualifiedName = eventContext.getTarget().getQualifiedName(); logger.debug( "Processing the event for calling attachment service with mark as delete event for entity {}", @@ -55,7 +57,6 @@ public InputStream processEvent( qualifiedName); } if (nonNull(path)) { - Optional inlinePrefix = detectInlinePrefix(path); String contentIdField = resolveField(Attachments.CONTENT_ID, inlinePrefix); String statusField = resolveField(Attachments.STATUS, inlinePrefix); String scannedAtField = resolveField(Attachments.SCANNED_AT, inlinePrefix); @@ -75,16 +76,6 @@ public InputStream processEvent( return content; } - private static Optional detectInlinePrefix(Path path) { - List prefixes = - ApplicationHandlerHelper.getInlineAttachmentFieldNames(path.target().entity()); - if (!prefixes.isEmpty() - && !path.target().entity().getAnnotationValue("_is_media_data", false)) { - return Optional.of(prefixes.get(0)); - } - return Optional.empty(); - } - private static String resolveField(String fieldName, Optional inlinePrefix) { return inlinePrefix.map(p -> p + "_" + fieldName).orElse(fieldName); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/ModifyAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/ModifyAttachmentEvent.java index 1a830b5c2..9497ad91d 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/ModifyAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/ModifyAttachmentEvent.java @@ -8,6 +8,7 @@ import com.sap.cds.ql.cqn.Path; import com.sap.cds.services.EventContext; import java.io.InputStream; +import java.util.Optional; /** * The interface {@link ModifyAttachmentEvent} provides a method to process an event on the {@link @@ -22,8 +23,14 @@ public interface ModifyAttachmentEvent { * @param content the content of the attachment * @param attachment existing attachment data * @param eventContext the current event context + * @param inlinePrefix the inline attachment field prefix (e.g. "coverImage"), or empty for + * composition-based attachments * @return the processed content */ InputStream processEvent( - Path path, InputStream content, Attachments attachment, EventContext eventContext); + Path path, + InputStream content, + Attachments attachment, + EventContext eventContext, + Optional inlinePrefix); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEvent.java index a178be89d..49acdb96b 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEvent.java @@ -10,6 +10,7 @@ import com.sap.cds.ql.cqn.Path; import com.sap.cds.services.EventContext; import java.io.InputStream; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,12 +35,16 @@ public UpdateAttachmentEvent( @Override public InputStream processEvent( - Path path, InputStream content, Attachments attachment, EventContext eventContext) { + Path path, + InputStream content, + Attachments attachment, + EventContext eventContext, + Optional inlinePrefix) { logger.debug( "Processing UPDATE event by calling attachment service with create and delete event for entity {}", path.target().entity().getQualifiedName()); - deleteEvent.processEvent(path, content, attachment, eventContext); - return createEvent.processEvent(path, content, attachment, eventContext); + deleteEvent.processEvent(path, content, attachment, eventContext, inlinePrefix); + return createEvent.processEvent(path, content, attachment, eventContext, inlinePrefix); } } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java index 27fd733b0..378fa1b05 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java @@ -100,9 +100,12 @@ void processBeforeDraftCancel(DraftCancelEventContext context) { private Validator buildDeleteContentValidator( DraftCancelEventContext context, List activeCondensedAttachments) { return (path, element, value) -> { + Optional inlinePrefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix( + path.target().entity(), element.getName()); Attachments attachment = Attachments.of(path.target().values()); if (Boolean.FALSE.equals(attachment.get(Drafts.HAS_ACTIVE_ENTITY))) { - deleteEvent.processEvent(path, null, attachment, context); + deleteEvent.processEvent(path, null, attachment, context, inlinePrefix); return; } Map keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys()); @@ -113,7 +116,7 @@ private Validator buildDeleteContentValidator( existingEntry.ifPresent( entry -> { if (!entry.get(Attachments.CONTENT_ID).equals(value)) { - deleteEvent.processEvent(null, null, attachment, context); + deleteEvent.processEvent(null, null, attachment, context, inlinePrefix); } }); }; diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java index 3b0720892..a40cdc6c6 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java @@ -96,7 +96,8 @@ void processBeforeDraftPatch(DraftPatchEventContext context, List createContext.put("attachment.inlinePrefix", prefix)); emit(createContext); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/DefaultAttachmentsServiceHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/DefaultAttachmentsServiceHandler.java index 7a32af544..be93e6d24 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/DefaultAttachmentsServiceHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/DefaultAttachmentsServiceHandler.java @@ -5,21 +5,18 @@ import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.StatusCode; -import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.handler.transaction.EndTransactionMalwareScanProvider; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentMarkAsDeletedEventContext; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentReadEventContext; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentRestoreEventContext; -import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.changeset.ChangeSetListener; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.After; 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 java.util.List; import java.util.Objects; import java.util.Optional; import org.slf4j.Logger; @@ -70,21 +67,14 @@ void createAttachment(AttachmentCreateEventContext context) { */ @After void afterCreateAttachment(AttachmentCreateEventContext context) { - Optional inlinePrefix = detectInlinePrefix(context.getAttachmentEntity()); + String prefix = (String) context.get("attachment.inlinePrefix"); + Optional inlinePrefix = Optional.ofNullable(prefix); ChangeSetListener listener = malwareScanProvider.getChangeSetListener( context.getAttachmentEntity(), context.getContentId(), inlinePrefix); context.getChangeSetContext().register(listener); } - private static Optional detectInlinePrefix(CdsEntity entity) { - List prefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); - if (!prefixes.isEmpty() && !entity.getAnnotationValue("_is_media_data", false)) { - return Optional.of(prefixes.get(0)); - } - return Optional.empty(); - } - @On @HandlerOrder(DEFAULT_ON) void markAttachmentAsDeleted(AttachmentMarkAsDeletedEventContext context) { diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/model/service/CreateAttachmentInput.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/model/service/CreateAttachmentInput.java index df4636b3d..baba15aac 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/model/service/CreateAttachmentInput.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/model/service/CreateAttachmentInput.java @@ -6,6 +6,7 @@ import com.sap.cds.reflect.CdsEntity; import java.io.InputStream; import java.util.Map; +import java.util.Optional; /** * The class {@link CreateAttachmentInput} is used to store the input for creating an attachment. @@ -15,10 +16,12 @@ * @param fileName The file name of the content * @param mimeType The mime type of the content * @param content The input stream of the content + * @param inlinePrefix For inline attachments, the field name prefix; empty for composition-based */ public record CreateAttachmentInput( Map attachmentIds, CdsEntity attachmentEntity, String fileName, String mimeType, - InputStream content) {} + InputStream content, + Optional inlinePrefix) {} diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java index c4c3a690d..9b9f955b9 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java @@ -226,7 +226,7 @@ void attachmentAccessExceptionCorrectHandledForCreate() { attachment.setFileName("test.txt"); attachment.setContent(null); when(eventFactory.getEvent(any(), any(), any())).thenReturn(event); - when(event.processEvent(any(), any(), any(), any())).thenThrow(new ServiceException("")); + when(event.processEvent(any(), any(), any(), any(), any())).thenThrow(new ServiceException("")); List input = List.of(attachment); assertThrows(ServiceException.class, () -> cut.processBefore(createContext, input)); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java index 29404a393..254821d97 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java @@ -84,7 +84,8 @@ void attachmentDataExistsServiceIsCalled() { cut.processBefore(context); - verify(modifyAttachmentEvent).processEvent(any(), eq(inputStream), eq(data), eq(context)); + verify(modifyAttachmentEvent) + .processEvent(any(), eq(inputStream), eq(data), eq(context), any()); assertThat(data.getContent()).isNull(); } @@ -108,10 +109,10 @@ void attachmentDataExistsAsExpandServiceIsCalled() { verify(modifyAttachmentEvent) .processEvent( - any(Path.class), eq(inputStream), eq(Attachments.of(attachment1)), eq(context)); + any(Path.class), eq(inputStream), eq(Attachments.of(attachment1)), eq(context), any()); verify(modifyAttachmentEvent) .processEvent( - any(Path.class), eq(inputStream), eq(Attachments.of(attachment2)), eq(context)); + any(Path.class), eq(inputStream), eq(Attachments.of(attachment2)), eq(context), any()); assertThat(attachment1.getContent()).isNull(); assertThat(attachment2.getContent()).isNull(); } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java index 132542f24..197cbc5ed 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java @@ -250,7 +250,7 @@ void attachmentAccessExceptionCorrectHandledForUpdate() { attachment.setFileName("test.txt"); attachment.setContent(null); attachment.setId(id); - when(event.processEvent(any(), any(), any(), any())).thenThrow(new ServiceException("")); + when(event.processEvent(any(), any(), any(), any(), any())).thenThrow(new ServiceException("")); when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) .thenReturn(List.of(attachment)); @@ -287,7 +287,11 @@ void existingDataFoundAndUsed() { ArgumentCaptor eventStreamCaptor = ArgumentCaptor.forClass(InputStream.class); verify(event) .processEvent( - any(), eventStreamCaptor.capture(), cdsDataArgumentCaptor.capture(), eq(updateContext)); + any(), + eventStreamCaptor.capture(), + cdsDataArgumentCaptor.capture(), + eq(updateContext), + any()); InputStream eventCaptured = eventStreamCaptor.getValue(); assertThat(eventCaptured).isInstanceOf(CountingInputStream.class); assertThat(((CountingInputStream) eventCaptured).getDelegate()).isSameAs(testStream); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java index 8588e4634..b4d8a471f 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java @@ -28,6 +28,7 @@ import java.io.InputStream; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -94,7 +95,8 @@ void serviceExceptionDueToContentLength() { eventContext, path, attachment.getContent(), - ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER)); + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + Optional.empty())); assertThat(exception.getErrorStatus()).isEqualTo(ExtendedErrorStatuses.CONTENT_TOO_LARGE); } @@ -121,7 +123,7 @@ void serviceExceptionDueToLimitExceeded() { when(parameterInfo.getHeader("Content-Length")).thenReturn(null); // Make event.processEvent() read from the stream, triggering the limit check - when(event.processEvent(any(), any(), any(), any())) + when(event.processEvent(any(), any(), any(), any(), any())) .thenAnswer( invocation -> { InputStream wrappedContent = invocation.getArgument(1); @@ -148,7 +150,8 @@ void serviceExceptionDueToLimitExceeded() { eventContext, path, content, - ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER)); + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + Optional.empty())); assertThat(exception.getErrorStatus()).isEqualTo(ExtendedErrorStatuses.CONTENT_TOO_LARGE); } @@ -180,7 +183,8 @@ void defaultValMaxValueUsed() { eventContext, path, content, - ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER)); + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + Optional.empty())); } @Test @@ -212,7 +216,8 @@ void malformedContentLengthHeader() { eventContext, path, content, - ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER)); + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + Optional.empty())); assertThat(exception.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST); } @@ -243,7 +248,8 @@ void inlineContentIdResolvedFromPrefixedField() { eventContext, path, (InputStream) values.get("profilePicture_content"), - ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER); + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + Optional.of("profilePicture")); // Verify eventFactory was called with the resolved contentId verify(eventFactory).getEvent(any(), eq("existing-doc-77"), any()); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java index 5e34b98c2..d60374f8b 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java @@ -30,6 +30,7 @@ import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; +import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -100,7 +101,7 @@ void storageCalledWithAllFieldsFilledFromExistingData() { existingData.setFileName("some file name"); existingData.setMimeType("some mime type"); - cut.processEvent(path, attachment.getContent(), existingData, eventContext); + cut.processEvent(path, attachment.getContent(), existingData, eventContext, Optional.empty()); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); var createInput = contextArgumentCaptor.getValue(); @@ -123,7 +124,8 @@ void resultFromServiceStoredInPath() { when(attachmentService.createAttachment(any())).thenReturn(attachmentServiceResult); when(target.values()).thenReturn(attachment); - cut.processEvent(path, attachment.getContent(), Attachments.create(), eventContext); + cut.processEvent( + path, attachment.getContent(), Attachments.create(), eventContext, Optional.empty()); assertThat(attachment.getContentId()).isEqualTo(attachmentServiceResult.contentId()); assertThat(attachment.getStatus()).isEqualTo(attachmentServiceResult.status()); @@ -139,7 +141,7 @@ void changesetIstRegistered() { when(attachmentService.createAttachment(any())) .thenReturn(new AttachmentModificationResult(false, contentId, "test", null)); - cut.processEvent(path, null, Attachments.create(), eventContext); + cut.processEvent(path, null, Attachments.create(), eventContext, Optional.empty()); verify(changeSetContext).register(listener); } @@ -160,7 +162,8 @@ void contentIsReturnedIfNotExternalStored(boolean isExternalStored) throws IOExc .thenReturn(new AttachmentModificationResult(isExternalStored, "id", "test", null)); var result = - cut.processEvent(path, attachment.getContent(), Attachments.create(), eventContext); + cut.processEvent( + path, attachment.getContent(), Attachments.create(), eventContext, Optional.empty()); var expectedContent = isExternalStored ? attachment.getContent() : null; assertThat(result).isEqualTo(expectedContent); @@ -179,7 +182,8 @@ private Attachments prepareAndExecuteEventWithData() { when(attachmentService.createAttachment(any())) .thenReturn(new AttachmentModificationResult(false, "id", "test", null)); - cut.processEvent(path, attachment.getContent(), Attachments.create(), eventContext); + cut.processEvent( + path, attachment.getContent(), Attachments.create(), eventContext, Optional.empty()); return attachment; } @@ -204,7 +208,8 @@ void inlineContentIdAndStatusWrittenWithPrefix() { when(attachmentService.createAttachment(any())) .thenReturn(new AttachmentModificationResult(false, "doc-123", "Clean", null)); - cut.processEvent(path, content, Attachments.create(), eventContext); + cut.processEvent( + path, content, Attachments.create(), eventContext, Optional.of("profilePicture")); assertThat(values).containsEntry("profilePicture_contentId", "doc-123"); assertThat(values).containsEntry("profilePicture_status", "Clean"); @@ -227,7 +232,8 @@ void inlinePrefixedFieldValuesPassedToService() { when(attachmentService.createAttachment(any())) .thenReturn(new AttachmentModificationResult(false, "id", "ok", null)); - cut.processEvent(path, content, Attachments.create(), eventContext); + cut.processEvent( + path, content, Attachments.create(), eventContext, Optional.of("profilePicture")); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); var input = contextArgumentCaptor.getValue(); @@ -256,7 +262,7 @@ void inlineFallsBackToAttachmentObjectWhenPrefixedFieldMissing() { existingData.setFileName("fallback.txt"); existingData.setMimeType("text/plain"); - cut.processEvent(path, content, existingData, eventContext); + cut.processEvent(path, content, existingData, eventContext, Optional.of("profilePicture")); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); var input = contextArgumentCaptor.getValue(); @@ -281,7 +287,7 @@ void nonInlineEntityDoesNotUsePrefixedFields() { when(attachmentService.createAttachment(any())) .thenReturn(new AttachmentModificationResult(false, "doc-999", "ok", null)); - cut.processEvent(path, content, Attachments.create(), eventContext); + cut.processEvent(path, content, Attachments.create(), eventContext, Optional.empty()); // Fields written with unprefixed names assertThat(values).containsEntry(Attachments.CONTENT_ID, "doc-999"); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEventTest.java index 2a0cecb82..349bbac05 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEventTest.java @@ -17,6 +17,7 @@ import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; import java.util.Objects; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EmptySource; @@ -49,7 +50,8 @@ void contentReturned(String input) { when(target.entity()).thenReturn(entity); when(entity.getQualifiedName()).thenReturn("some.qualified.name"); - var result = cut.processEvent(path, streamInput, data, mock(EventContext.class)); + var result = + cut.processEvent(path, streamInput, data, mock(EventContext.class), Optional.empty()); assertThat(result).isEqualTo(streamInput); verifyNoInteractions(element, data); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java index 48685da7e..f73340d2a 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java @@ -25,6 +25,7 @@ import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -68,7 +69,7 @@ void documentIsExternallyDeleted() { var data = Attachments.create(); data.setContentId(contentId); - var expectedValue = cut.processEvent(path, value, data, context); + var expectedValue = cut.processEvent(path, value, data, context, Optional.empty()); assertThat(expectedValue).isEqualTo(value); assertThat(data.getContentId()).isEqualTo(contentId); @@ -89,7 +90,7 @@ void documentIsNotExternallyDeletedBecauseDoesNotExistBefore() { var value = new ByteArrayInputStream("test".getBytes(StandardCharsets.UTF_8)); var data = Attachments.create(); - var expectedValue = cut.processEvent(path, value, data, context); + var expectedValue = cut.processEvent(path, value, data, context, Optional.empty()); assertThat(expectedValue).isEqualTo(value); assertThat(data.getContentId()).isNull(); @@ -108,7 +109,7 @@ void documentIsNotExternallyDeletedBecauseItIsDraftChangeEvent() { data.setContentId(contentId); when(context.getEvent()).thenReturn(DraftService.EVENT_DRAFT_PATCH); - var expectedValue = cut.processEvent(path, value, data, context); + var expectedValue = cut.processEvent(path, value, data, context, Optional.empty()); assertThat(expectedValue).isEqualTo(value); assertThat(data.getContentId()).isEqualTo(contentId); @@ -126,7 +127,7 @@ void processEvent_withNullPath_doesNotModifyPathValues() { var data = Attachments.create(); data.setContentId(contentId); - var expectedValue = cut.processEvent(null, value, data, context); + var expectedValue = cut.processEvent(null, value, data, context, Optional.empty()); assertThat(expectedValue).isEqualTo(value); // Attachment service should still be called to mark as deleted @@ -147,7 +148,7 @@ void processEvent_withDifferentNewContentId_doesNotClearContentId() { // Set a different contentId in the path values currentData.put(Attachments.CONTENT_ID, newContentId); - var expectedValue = cut.processEvent(path, value, data, context); + var expectedValue = cut.processEvent(path, value, data, context, Optional.empty()); assertThat(expectedValue).isEqualTo(value); // Attachment service should be called to mark old content as deleted @@ -180,7 +181,7 @@ void inlineDelete_clearsPrefixedFields() { data.setContentId("old-content-id"); when(context.getEvent()).thenReturn(DraftService.EVENT_DRAFT_PATCH); - cut.processEvent(path, null, data, context); + cut.processEvent(path, null, data, context, Optional.of("profilePicture")); // All prefixed fields should be cleared assertThat(values) @@ -208,7 +209,7 @@ void inlineDelete_withDifferentNewContentId_doesNotClearPrefixedFields() { var data = Attachments.create(); data.setContentId("old-content-id"); - cut.processEvent(path, null, data, context); + cut.processEvent(path, null, data, context, Optional.of("profilePicture")); // contentId differs from attachment's contentId, so fields should NOT be cleared assertThat(values).containsEntry("profilePicture_contentId", "different-new-content-id"); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEventTest.java index 640a304a3..f18029b91 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEventTest.java @@ -13,6 +13,7 @@ import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.EventContext; import java.io.InputStream; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -44,9 +45,11 @@ void eventsCorrectCalled() { var existingData = Attachments.create(); var eventContext = mock(EventContext.class); - cut.processEvent(path, testContentStream, existingData, eventContext); + cut.processEvent(path, testContentStream, existingData, eventContext, Optional.empty()); - verify(createEvent).processEvent(path, testContentStream, existingData, eventContext); - verify(deleteEvent).processEvent(path, testContentStream, existingData, eventContext); + verify(createEvent) + .processEvent(path, testContentStream, existingData, eventContext, Optional.empty()); + verify(deleteEvent) + .processEvent(path, testContentStream, existingData, eventContext, Optional.empty()); } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java index 2edd43854..d8be08638 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java @@ -207,7 +207,7 @@ void createdEntityNeedsToBeDeleted() { cut.processBeforeDraftCancel(eventContext); verify(deleteContentAttachmentEvent) - .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext)); + .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext), any()); assertThat(dataArgumentCaptor.getValue()).isEqualTo(attachment); } @@ -226,7 +226,7 @@ void updatedEntityNeedsToBeDeleted() { cut.processBeforeDraftCancel(eventContext); verify(deleteContentAttachmentEvent) - .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext)); + .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext), any()); assertThat(dataArgumentCaptor.getValue()).isEqualTo(draftAttachment); } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java index db678d6e3..b8add72a1 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java @@ -142,7 +142,7 @@ void contentIdUsedForEventFactory() { InputStream captured = streamCaptor.getValue(); assertThat(captured).isInstanceOf(CountingInputStream.class); assertThat(((CountingInputStream) captured).getDelegate()).isSameAs(content); - verify(event).processEvent(any(), eq(captured), eq(attachment), eq(eventContext)); + verify(event).processEvent(any(), eq(captured), eq(attachment), eq(eventContext), any()); } @Test diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImplTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImplTest.java index c1f569f35..b6701216a 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImplTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImplTest.java @@ -26,6 +26,7 @@ import java.io.InputStream; import java.time.Instant; import java.util.Map; +import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -97,7 +98,7 @@ void createAttachmentInsertsData(Boolean isExternalCreated) { var stream = mock(InputStream.class); Map ids = Map.of("ID1", "value1", "id2", "Value2"); var input = - new CreateAttachmentInput(ids, mock(CdsEntity.class), "fileName", "mimeType", stream); + new CreateAttachmentInput(ids, mock(CdsEntity.class), "fileName", "mimeType", stream, Optional.empty()); var result = cut.createAttachment(input); @@ -125,13 +126,66 @@ void createAttachmentExternalCreateNotFilledReturnedFalse() { Map ids = Map.of("ID1", "value1", "id2", "Value2"); var input = new CreateAttachmentInput( - ids, mock(CdsEntity.class), "fileName", "mimeType", mock(InputStream.class)); + ids, mock(CdsEntity.class), "fileName", "mimeType", mock(InputStream.class), Optional.empty()); var result = cut.createAttachment(input); assertThat(result.isInternalStored()).isFalse(); } + @Test + void createAttachmentWithInlinePrefixPutsItInContext() { + var contextReference = new AtomicReference(); + doAnswer( + input -> { + var context = (AttachmentCreateEventContext) input.getArgument(0); + contextReference.set(context); + context.setCompleted(); + return null; + }) + .when(handler) + .process(any()); + serviceSpi.on(AttachmentService.EVENT_CREATE_ATTACHMENT, "", handler); + Map ids = Map.of("ID1", "value1"); + var input = + new CreateAttachmentInput( + ids, + mock(CdsEntity.class), + "fileName", + "mimeType", + mock(InputStream.class), + Optional.of("profileIcon")); + + cut.createAttachment(input); + + var createContext = contextReference.get(); + assertThat(createContext.get("attachment.inlinePrefix")).isEqualTo("profileIcon"); + } + + @Test + void createAttachmentWithoutInlinePrefixDoesNotSetContext() { + var contextReference = new AtomicReference(); + doAnswer( + input -> { + var context = (AttachmentCreateEventContext) input.getArgument(0); + contextReference.set(context); + context.setCompleted(); + return null; + }) + .when(handler) + .process(any()); + serviceSpi.on(AttachmentService.EVENT_CREATE_ATTACHMENT, "", handler); + Map ids = Map.of("ID1", "value1"); + var input = + new CreateAttachmentInput( + ids, mock(CdsEntity.class), "fileName", "mimeType", mock(InputStream.class), Optional.empty()); + + cut.createAttachment(input); + + var createContext = contextReference.get(); + assertThat(createContext.get("attachment.inlinePrefix")).isNull(); + } + @Test void markAsDeleteAttachmentInsertsData() { var contextReference = new AtomicReference(); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java index 9acf1de4e..c971c2832 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java @@ -165,6 +165,26 @@ void malwareScannerRegisteredForEndOfTransaction() { verify(malwareScanProvider).getChangeSetListener(entity, "contentId", Optional.empty()); } + @Test + void malwareScannerRegisteredWithInlinePrefixFromContext() { + var listener = mock(ChangeSetListener.class); + var entity = mock(CdsEntity.class); + when(malwareScanProvider.getChangeSetListener(entity, "contentId", Optional.of("profileIcon"))) + .thenReturn(listener); + var createContext = AttachmentCreateEventContext.create(); + createContext.setAttachmentIds(Map.of(Attachments.ID, "contentId")); + createContext.setData(MediaData.create()); + createContext.setAttachmentEntity(entity); + createContext.put("attachment.inlinePrefix", "profileIcon"); + ChangeSetContextImpl.open(false); + + cut.createAttachment(createContext); + cut.afterCreateAttachment(createContext); + + verify(malwareScanProvider) + .getChangeSetListener(entity, "contentId", Optional.of("profileIcon")); + } + private void closeChangeSetContext() throws Exception { var context = ChangeSetContextImpl.getCurrent(); if (Objects.nonNull(context)) { diff --git a/samples/bookshop/app/admin-books/fiori-service.cds b/samples/bookshop/app/admin-books/fiori-service.cds index 9690b7297..66304d85f 100644 --- a/samples/bookshop/app/admin-books/fiori-service.cds +++ b/samples/bookshop/app/admin-books/fiori-service.cds @@ -37,6 +37,11 @@ annotate AdminService.Books with @(UI: { Label : 'Profile Icon', Target: '@UI.FieldGroup#ProfileIcon' }, + { + $Type : 'UI.ReferenceFacet', + Label : 'Cover Image', + Target: '@UI.FieldGroup#CoverImage' + }, { $Type : 'UI.ReferenceFacet', ID : 'AttachmentsFacet', @@ -62,6 +67,9 @@ annotate AdminService.Books with @(UI: { ]}, FieldGroup #ProfileIcon: {Data: [ {Value: profileIcon_content} + ]}, + FieldGroup #CoverImage: {Data: [ + {Value: coverImage_content} ]} }); diff --git a/samples/bookshop/srv/attachments.cds b/samples/bookshop/srv/attachments.cds index 55f29b504..f6ba9c4c5 100644 --- a/samples/bookshop/srv/attachments.cds +++ b/samples/bookshop/srv/attachments.cds @@ -24,9 +24,10 @@ annotate my.Books.mediaValidatedAttachments with { ]; } -// Extend Books entity with an inline single-file attachment (profile icon) +// Extend Books entity with inline single-file attachments extend my.Books with { profileIcon : Attachment; + coverImage : Attachment; } // Add UI component for attachments table to the Browse Books App From 6dc91e1d6c0a50f6578a2da068fdedc7f68f638d Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Mon, 30 Mar 2026 10:27:28 +0200 Subject: [PATCH 03/45] =?UTF-8?q?Use=20LinkedHashSet=20in=20getInlineAttac?= =?UTF-8?q?hmentFieldNames=20to=20avoid=20O(n=C2=B2)=20lookup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/common/ApplicationHandlerHelper.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java index eb4fec74e..5d51e5a6a 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java @@ -15,6 +15,7 @@ import com.sap.cds.reflect.CdsStructuredType; import com.sap.cds.services.draft.Drafts; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -111,10 +112,10 @@ public static boolean hasInlineAttachmentElements(CdsStructuredType entity) { * @return list of inline attachment field name prefixes (e.g. ["profilePicture"]) */ public static List getInlineAttachmentFieldNames(CdsStructuredType entity) { - List fieldNames = new ArrayList<>(); var elements = entity.elements(); - if (elements == null) return fieldNames; + if (elements == null) return List.of(); String contentSuffix = "_content"; + LinkedHashSet fieldNames = new LinkedHashSet<>(); elements .filter(e -> e.getName().endsWith(contentSuffix)) .filter(e -> e.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false)) @@ -123,11 +124,11 @@ public static List getInlineAttachmentFieldNames(CdsStructuredType entit e -> { String prefix = e.getName().substring(0, e.getName().length() - contentSuffix.length()); - if (!prefix.isEmpty() && !fieldNames.contains(prefix)) { + if (!prefix.isEmpty()) { fieldNames.add(prefix); } }); - return fieldNames; + return new ArrayList<>(fieldNames); } /** From d311213223c8221eb0914a3741cd1d20e7c9251c Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Mon, 30 Mar 2026 11:03:17 +0200 Subject: [PATCH 04/45] Appy spotless formatting --- .../common/ApplicationHandlerHelper.java | 2 +- .../service/AttachmentsServiceImplTest.java | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java index 5d51e5a6a..2b4d70304 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java @@ -15,8 +15,8 @@ import com.sap.cds.reflect.CdsStructuredType; import com.sap.cds.services.draft.Drafts; import java.util.ArrayList; -import java.util.LinkedHashSet; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImplTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImplTest.java index b6701216a..d287bd8dc 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImplTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImplTest.java @@ -98,7 +98,8 @@ void createAttachmentInsertsData(Boolean isExternalCreated) { var stream = mock(InputStream.class); Map ids = Map.of("ID1", "value1", "id2", "Value2"); var input = - new CreateAttachmentInput(ids, mock(CdsEntity.class), "fileName", "mimeType", stream, Optional.empty()); + new CreateAttachmentInput( + ids, mock(CdsEntity.class), "fileName", "mimeType", stream, Optional.empty()); var result = cut.createAttachment(input); @@ -126,7 +127,12 @@ void createAttachmentExternalCreateNotFilledReturnedFalse() { Map ids = Map.of("ID1", "value1", "id2", "Value2"); var input = new CreateAttachmentInput( - ids, mock(CdsEntity.class), "fileName", "mimeType", mock(InputStream.class), Optional.empty()); + ids, + mock(CdsEntity.class), + "fileName", + "mimeType", + mock(InputStream.class), + Optional.empty()); var result = cut.createAttachment(input); @@ -178,7 +184,12 @@ void createAttachmentWithoutInlinePrefixDoesNotSetContext() { Map ids = Map.of("ID1", "value1"); var input = new CreateAttachmentInput( - ids, mock(CdsEntity.class), "fileName", "mimeType", mock(InputStream.class), Optional.empty()); + ids, + mock(CdsEntity.class), + "fileName", + "mimeType", + mock(InputStream.class), + Optional.empty()); cut.createAttachment(input); From e317ab5b9b7494203246cc79e41a9613ad96b1eb Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Mon, 30 Mar 2026 12:25:08 +0200 Subject: [PATCH 05/45] refactor: remove redundant inline-attachment entries from association lookups --- .../applicationservice/ReadAttachmentsHandler.java | 7 ------- .../attachments/handler/common/AssociationCascader.java | 8 -------- 2 files changed, 15 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java index 8fa59c6fb..dcaf781cc 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java @@ -159,13 +159,6 @@ private List getAttachmentAssociations( associationNames.add(associationName); } - // Also add inline attachment field names (for inline type support) - List inlineFields = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); - if (!inlineFields.isEmpty() && !associationNames.contains(associationName)) { - // Use empty string to signify inline fields on the root entity - associationNames.add(associationName); - } - Map annotatedEntities = entity .associations() diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java index 652dc2b04..dde987179 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java @@ -81,14 +81,6 @@ private List> getAttachmentAssociationPath( return internalResultList; } - // Also check for inline attachment type fields on the entity itself - if (ApplicationHandlerHelper.hasInlineAttachmentElements(entity)) { - var identifier = new AssociationIdentifier(associationName, entity.getQualifiedName()); - var inlinePath = new LinkedList<>(firstList); - inlinePath.addLast(identifier); - internalResultList.add(inlinePath); - } - Map associations = entity .elements() From 8ef3dcdbe48001edeb524ea29b16579cd1af4aa4 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Fri, 10 Apr 2026 13:01:34 +0200 Subject: [PATCH 06/45] Add UI annotations to Attachment type and show scan status in bookshop sample --- .../cds-feature-attachments/attachments-annotations.cds | 9 ++++++++- samples/bookshop/app/admin-books/fiori-service.cds | 6 ++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds index a6ae39601..0ef76002d 100644 --- a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds +++ b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds @@ -7,8 +7,15 @@ using { // Annotate Attachment type with a static Core.MediaType so that LargeBinary content is exposed as Edm.Stream (enabling Fiori upload widget). // Using 'mimeType' (path reference) instead of a static value would break inline usage: // CDS flattening rewrites 'content' to 'prefix_content' but does NOT rewrite the path reference 'mimeType' to 'prefix_mimeType', causing a broken reference to a non-existent field. +// Static annotations on the Attachment type propagate through CDS flattening to inline fields (e.g. profileIcon_status gets @readonly automatically). +// Only static values work here. Path references (like Core.MediaType: mimeType, ContentDisposition.Filename: fileName, or SideEffects) do NOT work for inline attachments due to CDS flattening limitations. annotate Attachment with { - content @Core.MediaType: 'application/octet-stream'; + content @Core.MediaType: 'application/octet-stream'; + status @(title: '{i18n>attachment_status}', readonly); + contentId @(UI.Hidden: true, readonly); + scannedAt @(UI.Hidden: true, readonly); + fileName @(title: '{i18n>attachment_fileName}'); + mimeType @(title: '{i18n>attachment_mimeType}'); } annotate MediaData with @UI.MediaResource: {Stream: content} { diff --git a/samples/bookshop/app/admin-books/fiori-service.cds b/samples/bookshop/app/admin-books/fiori-service.cds index 66304d85f..152a8cf9f 100644 --- a/samples/bookshop/app/admin-books/fiori-service.cds +++ b/samples/bookshop/app/admin-books/fiori-service.cds @@ -66,10 +66,12 @@ annotate AdminService.Books with @(UI: { {Value: modifiedAt} ]}, FieldGroup #ProfileIcon: {Data: [ - {Value: profileIcon_content} + {Value: profileIcon_content}, + {Value: profileIcon_status} ]}, FieldGroup #CoverImage: {Data: [ - {Value: coverImage_content} + {Value: coverImage_content}, + {Value: coverImage_status} ]} }); From 13eb10be9e39b47625191ecbec03e811688d785d Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Fri, 10 Apr 2026 14:44:14 +0200 Subject: [PATCH 07/45] Add tests to meet JaCoCo branch and complexity coverage thresholds --- .../ReadAttachmentsHandlerTest.java | 51 +++++++++++++++++++ .../ReadonlyDataContextEnhancerTest.java | 14 +++++ .../CreateAttachmentEventTest.java | 26 ++++++++++ .../readhelper/CountingInputStreamTest.java | 9 ++++ .../DefaultAttachmentMalwareScannerTest.java | 14 +++++ .../src/test/resources/cds/db-model.cds | 6 +++ .../src/test/resources/cds/service.cds | 2 + 7 files changed, 122 insertions(+) diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java index 46b56c8b3..1f4d820ca 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java @@ -7,6 +7,7 @@ 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.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -19,6 +20,7 @@ import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.EventItems; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.EventItems_; 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.InlineOnly_; 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_; @@ -556,4 +558,53 @@ void inlineContentWithExistingStreamWrappedWithProxy() throws IOException { byte[] bytes = ((InputStream) root.get("profilePicture_content")).readAllBytes(); assertThat(bytes).isEqualTo(testContent.getBytes(StandardCharsets.UTF_8)); } + + @Test + void processBeforeWithOnlyInlineAttachmentsModifiesCqn() { + // InlineOnly entity has inline attachment but NO composition associations + // This covers the branch: fieldNames.isEmpty() && !inlinePrefixes.isEmpty() + var select = Select.from(InlineOnly_.class).columns(InlineOnly_::ID); + mockEventContext(InlineOnly_.CDS_NAME, select); + + cut.processBefore(readEventContext); + + // Verify CQN was modified (setCqn called) + verify(readEventContext).setCqn(any(CqnSelect.class)); + } + + @Test + void processAfterWithInlineOnlyEntityWrapsContent() { + mockEventContext(InlineOnly_.CDS_NAME, mock(CqnSelect.class)); + + var root = CdsData.create(); + root.put("ID", UUID.randomUUID().toString()); + root.put("avatar_content", null); + root.put("avatar_contentId", "avatar-doc-1"); + root.put("avatar_status", StatusCode.CLEAN); + + cut.processAfter(readEventContext, List.of(root)); + + assertThat(root.get("avatar_content")).isInstanceOf(LazyProxyInputStream.class); + } + + @Test + void processAfterInlineAttachmentWithStaleScanTriggersRescan() { + mockEventContext(InlineOnly_.CDS_NAME, mock(CqnSelect.class)); + + var root = CdsData.create(); + // Null key so areKeysEmpty returns true → verifyStatus proceeds + root.put("ID", null); + root.put("avatar_content", null); + root.put("avatar_contentId", "avatar-doc-stale"); + root.put("avatar_status", StatusCode.CLEAN); + // No scannedAt → stale → triggers transitionToScanning with inline prefix + + cut.processAfter(readEventContext, List.of(root)); + + // transitionToScanning calls persistenceService.run(update) with prefixed columns + verify(persistenceService).run(any(com.sap.cds.ql.cqn.CqnUpdate.class)); + verify(asyncMalwareScanExecutor) + .scanAsync(any(), eq("avatar-doc-stale"), eq(Optional.of("avatar"))); + assertThat(root.get("avatar_content")).isInstanceOf(LazyProxyInputStream.class); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java index 1733aa34b..de58df47f 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java @@ -181,4 +181,18 @@ void restoreReadonlyFieldsBothCompositionAndInline() { assertThat(data.containsKey(DRAFT_READONLY_CONTEXT)).isFalse(); assertThat(data.containsKey("profilePicture_" + DRAFT_READONLY_CONTEXT)).isFalse(); } + + @Test + void restoreReadonlyFieldsInlineWithNullBackupDoesNothing() { + CdsData data = CdsData.create(); + data.put("ID", "123"); + // Inline backup key exists but value is null + data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, null); + + ReadonlyDataContextEnhancer.restoreReadonlyFields(data); + + // null backup is skipped, key remains + assertThat(data.get("ID")).isEqualTo("123"); + assertThat(data.containsKey("profilePicture_contentId")).isFalse(); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java index d60374f8b..d10b59aa3 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java @@ -293,4 +293,30 @@ void nonInlineEntityDoesNotUsePrefixedFields() { assertThat(values).containsEntry(Attachments.CONTENT_ID, "doc-999"); assertThat(values).containsEntry(Attachments.STATUS, "ok"); } + + @Test + void processEventWritesScannedAtWhenNonNull() { + CdsEntity realEntity = + RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + when(target.entity()).thenReturn(realEntity); + + Map values = new HashMap<>(); + values.put("ID", UUID.randomUUID().toString()); + values.put("profilePicture_mimeType", "image/png"); + values.put("profilePicture_fileName", "photo.png"); + when(target.values()).thenReturn(values); + when(target.keys()).thenReturn(Map.of("ID", values.get("ID"))); + + var scannedAt = java.time.Instant.now(); + var content = mock(InputStream.class); + when(attachmentService.createAttachment(any())) + .thenReturn(new AttachmentModificationResult(false, "doc-scan", "Clean", scannedAt)); + + cut.processEvent( + path, content, Attachments.create(), eventContext, Optional.of("profilePicture")); + + assertThat(values).containsEntry("profilePicture_contentId", "doc-scan"); + assertThat(values).containsEntry("profilePicture_status", "Clean"); + assertThat(values).containsEntry("profilePicture_scannedAt", scannedAt); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStreamTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStreamTest.java index e1245d79b..0a37fecd5 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStreamTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStreamTest.java @@ -309,4 +309,13 @@ void constructor_fractionalValue_throwsServiceException() { assertThat(exception.getMessage()).contains("Error parsing max size annotation value"); assertThat(exception.getCause()).isInstanceOf(ArithmeticException.class); } + + @Test + void close_withNullDelegate_doesNotThrow() { + // CountingInputStream.close() guards against null delegate + assertDoesNotThrow(() -> { + var cut = new CountingInputStream(null, "100"); + cut.close(); + }); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java index 530799978..0218f720f 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java @@ -367,4 +367,18 @@ private void verifyKeyWhereCondition(CqnSelect select) { .contains( "[{\"ref\":[\"contentId\"]},\"=\",{\"val\":\"ID\"},\"and\",{\"ref\":[\"status\"]},\"<>\",{\"val\":\"Clean\"}]"); } + + @Test + void resolveColumnWithPrefixConcatenates() { + String result = + DefaultAttachmentMalwareScanner.resolveColumn("contentId", Optional.of("avatar")); + assertThat(result).isEqualTo("avatar_contentId"); + } + + @Test + void resolveColumnWithoutPrefixReturnsFieldName() { + String result = + DefaultAttachmentMalwareScanner.resolveColumn("contentId", Optional.empty()); + assertThat(result).isEqualTo("contentId"); + } } 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 54b03ebde..8a51e9127 100644 --- a/cds-feature-attachments/src/test/resources/cds/db-model.cds +++ b/cds-feature-attachments/src/test/resources/cds/db-model.cds @@ -44,6 +44,12 @@ entity EventItems { defaultSizeLimitedAttachments : Composition of many Attachments; } +// Entity with only inline attachment (no composition) +entity InlineOnly : cuid { + title : String; + avatar : AttachmentType; +} + annotate EventItems.sizeLimitedAttachments with { content @Validation.Maximum: '10KB'; }; diff --git a/cds-feature-attachments/src/test/resources/cds/service.cds b/cds-feature-attachments/src/test/resources/cds/service.cds index 5541ac775..331c6173f 100644 --- a/cds-feature-attachments/src/test/resources/cds/service.cds +++ b/cds-feature-attachments/src/test/resources/cds/service.cds @@ -5,4 +5,6 @@ using unit.test as db from './db-model'; service TestService { @odata.draft.enabled entity RootTable as projection on db.Roots; + @odata.draft.enabled + entity InlineOnly as projection on db.InlineOnly; } From 3c99d8c5a8808bd41b6df32e5b46612900690718 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Fri, 10 Apr 2026 14:47:48 +0200 Subject: [PATCH 08/45] Apply spotless formatting --- .../readhelper/CountingInputStreamTest.java | 9 +++++---- .../malware/DefaultAttachmentMalwareScannerTest.java | 3 +-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStreamTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStreamTest.java index 0a37fecd5..d031eaac0 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStreamTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStreamTest.java @@ -313,9 +313,10 @@ void constructor_fractionalValue_throwsServiceException() { @Test void close_withNullDelegate_doesNotThrow() { // CountingInputStream.close() guards against null delegate - assertDoesNotThrow(() -> { - var cut = new CountingInputStream(null, "100"); - cut.close(); - }); + assertDoesNotThrow( + () -> { + var cut = new CountingInputStream(null, "100"); + cut.close(); + }); } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java index 0218f720f..7cac3773a 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java @@ -377,8 +377,7 @@ void resolveColumnWithPrefixConcatenates() { @Test void resolveColumnWithoutPrefixReturnsFieldName() { - String result = - DefaultAttachmentMalwareScanner.resolveColumn("contentId", Optional.empty()); + String result = DefaultAttachmentMalwareScanner.resolveColumn("contentId", Optional.empty()); assertThat(result).isEqualTo("contentId"); } } From 08fd4d2e000d6287a6f620343a2b1df7a2211425 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Fri, 10 Apr 2026 15:19:29 +0200 Subject: [PATCH 09/45] Add extra tests to increase JaCoCo coverage margin for CI --- .../BeforeReadItemsModifierTest.java | 31 +++++++++++++++++++ .../common/ApplicationHandlerHelperTest.java | 13 ++++++++ 2 files changed, 44 insertions(+) diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifierTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifierTest.java index 45998fb98..136ef5dd2 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifierTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifierTest.java @@ -282,4 +282,35 @@ void inlineAttachmentFieldsNotAddedWithoutContentInSelect() { .count(); assertThat(inlineFieldCount).isEqualTo(0); } + + @Test + void inlineFieldsNotAddedWhenContentIdAlreadySelected() { + // Both profilePicture_content AND profilePicture_contentId are explicitly selected. + // The modifier should NOT add duplicate contentId/status/scannedAt fields. + CqnSelect select = + Select.from(RootTable_.class) + .columns( + RootTable_::ID, + b -> b.get("profilePicture_content"), + b -> b.get("profilePicture_contentId")); + + cut = new BeforeReadItemsModifier(List.of(), List.of("profilePicture")); + List resultItems = cut.items(select.items()); + + // contentId already in select, so it should appear exactly once (no duplicate added) + var contentIdCount = + resultItems.stream() + .filter( + item -> + item.isRef() && item.asRef().displayName().equals("profilePicture_contentId")) + .count(); + assertThat(contentIdCount).isEqualTo(1); + // status and scannedAt should NOT be added either since the guard prevents it + var statusCount = + resultItems.stream() + .filter( + item -> item.isRef() && item.asRef().displayName().equals("profilePicture_status")) + .count(); + assertThat(statusCount).isEqualTo(0); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java index f110e5cb2..b0c01f1da 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java @@ -6,12 +6,15 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +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.RootTable_; import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.reflect.CdsStructuredType; import com.sap.cds.services.runtime.CdsRuntime; import java.io.ByteArrayInputStream; import java.io.InputStream; @@ -279,4 +282,14 @@ void mediaContentFilterMatchesInlineContentField() { // Use containsContentField which internally uses MEDIA_CONTENT_FILTER assertThat(ApplicationHandlerHelper.containsContentField(entity, List.of(data))).isTrue(); } + + @Test + void getInlineAttachmentFieldNamesWithNullElementsReturnsEmpty() { + CdsStructuredType entity = mock(CdsStructuredType.class); + when(entity.elements()).thenReturn(null); + + var result = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); + + assertThat(result).isEmpty(); + } } From 561e88913610f9cd6a4021e88b7310c7ad33c0af Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Mon, 13 Apr 2026 14:38:19 +0200 Subject: [PATCH 10/45] Clear mimeType/fileName for inline attachments in MarkAsDeletedAttachmentEvent --- .../modifyevents/MarkAsDeletedAttachmentEvent.java | 11 +++++++++-- .../MarkAsDeletedAttachmentEventTest.java | 12 ++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java index 9085b8e13..0f889ddaf 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java @@ -69,8 +69,15 @@ public InputStream processEvent( path.target().values().put(contentIdField, null); path.target().values().put(statusField, null); path.target().values().put(scannedAtField, null); - path.target().values().put(mimeTypeField, null); - path.target().values().put(fileNameField, null); + // For inline attachments, also clear mimeType/fileName on the parent entity. + // For composition-based attachments these live on the attachment entity itself and must NOT + // be cleared here. + // Otherwise UpdateAttachmentEvent's delete step would destroy them before the subsequent + // create step can use them. + if (inlinePrefix.isPresent()) { + path.target().values().put(mimeTypeField, null); + path.target().values().put(fileNameField, null); + } } } return content; diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java index f73340d2a..99d7c5b5d 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java @@ -81,8 +81,8 @@ void documentIsExternallyDeleted() { .containsEntry(Attachments.CONTENT_ID, null) .containsEntry(Attachments.STATUS, null) .containsEntry(Attachments.SCANNED_AT, null) - .containsEntry(MediaData.MIME_TYPE, null) - .containsEntry(MediaData.FILE_NAME, null); + .doesNotContainKey(MediaData.MIME_TYPE) + .doesNotContainKey(MediaData.FILE_NAME); } @Test @@ -97,8 +97,8 @@ void documentIsNotExternallyDeletedBecauseDoesNotExistBefore() { verifyNoInteractions(attachmentService); assertThat(currentData) .containsEntry(Attachments.CONTENT_ID, null) - .containsEntry(MediaData.MIME_TYPE, null) - .containsEntry(MediaData.FILE_NAME, null); + .doesNotContainKey(MediaData.MIME_TYPE) + .doesNotContainKey(MediaData.FILE_NAME); } @Test @@ -116,8 +116,8 @@ void documentIsNotExternallyDeletedBecauseItIsDraftChangeEvent() { verifyNoInteractions(attachmentService); assertThat(currentData) .containsEntry(Attachments.CONTENT_ID, null) - .containsEntry(MediaData.MIME_TYPE, null) - .containsEntry(MediaData.FILE_NAME, null); + .doesNotContainKey(MediaData.MIME_TYPE) + .doesNotContainKey(MediaData.FILE_NAME); } @Test From 4cb1e071f53a53140b974a89ea3e53c73cedc287 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Mon, 13 Apr 2026 16:47:34 +0200 Subject: [PATCH 11/45] Support nested single attachments in AssociationCascader --- .../handler/common/AssociationCascader.java | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java index dde987179..5fb058f68 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java @@ -70,17 +70,27 @@ private List> getAttachmentAssociationPath( var currentList = new LinkedList(); var localProcessEntities = new ArrayList(); - var isMediaEntity = ApplicationHandlerHelper.isDirectMediaEntity(entity); - if (isMediaEntity) { + var isDirectMediaEntity = ApplicationHandlerHelper.isDirectMediaEntity(entity); + var hasInlineAttachments = ApplicationHandlerHelper.hasInlineAttachmentElements(entity); + + // Direct media entities (e.g. Attachments) are always leaf nodes + // No need to traverse compositions + if (isDirectMediaEntity) { var identifier = new AssociationIdentifier(associationName, entity.getQualifiedName()); firstList.addLast(identifier); - } - - if (isMediaEntity) { internalResultList.add(firstList); return internalResultList; } + // Entities with inline attachment fields (e.g. Items with receipt : Attachment) + // are treated as media entities, but may also have compositions that need traversal. + // Record this entity as a path AND continue to discover child compositions. + if (hasInlineAttachments) { + var inlinePath = new LinkedList<>(firstList); + inlinePath.addLast(new AssociationIdentifier(associationName, entity.getQualifiedName())); + internalResultList.add(inlinePath); + } + Map associations = entity .elements() From f041e27241d3facd7b80dc8101007c810b606376 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Mon, 13 Apr 2026 17:19:03 +0200 Subject: [PATCH 12/45] Add Single (Inline) Attachments section to README --- README.md | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/README.md b/README.md index 7d5b38f42..03a368a50 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ It supports the [AWS, Azure, and Google object stores](storage-targets/cds-featu * [Usage](#usage) * [MVN Setup](#mvn-setup) * [Changes in the CDS Models and for the UI](#changes-in-the-cds-models-and-for-the-UI) + * [Single (Inline) Attachments](#single-inline-attachments) * [Try the Bookshop Sample](#try-the-bookshop-sample) * [Storage Targets](#storage-targets) * [Malware Scanner](#malware-scanner) @@ -115,6 +116,86 @@ annotate service.Incidents with @( The UI Facet can also be added directly after other UI Facets in a `cds` file in the `app` folder. +### Single (Inline) Attachments + +In addition to the composition-based `Attachments` aspect (which supports multiple files), CDS provides the `Attachment` type for **single-file** attachment fields directly on an entity. This is useful when an entity needs exactly one file, for example a profile icon or a cover image. + +```cds +using { sap.attachments.Attachment } from 'com.sap.cds/cds-feature-attachments'; + +entity Books { + key ID : UUID; + title : String; + profileIcon : Attachment; + coverImage : Attachment; +} +``` + +CDS flattens inline attachment fields onto the parent entity. For example, `profileIcon : Attachment` generates the following columns on the `Books` table: + +- `profileIcon_content` (LargeBinary) +- `profileIcon_mimeType` (String) +- `profileIcon_fileName` (String) +- `profileIcon_contentId` (String) +- `profileIcon_status` (StatusCode) +- `profileIcon_scannedAt` (Timestamp) + +All plugin features: malware scanning, status tracking, storage targets, maximum file size, and MIME type validation work the same way for inline attachments as for composition-based attachments. + +#### UI Annotations for Inline Attachments + +To display inline attachments in a Fiori Elements UI, use a `FieldGroup` referencing the flattened field names: + +```cds +annotate AdminService.Books with @(UI: { + Facets: [ + // ... other facets ... + { + $Type : 'UI.ReferenceFacet', + Label : 'Profile Icon', + Target: '@UI.FieldGroup#ProfileIcon' + } + ], + FieldGroup #ProfileIcon: {Data: [ + {Value: profileIcon_content}, + {Value: profileIcon_status} + ]} +}); +``` + +> [!Note] +> For inline attachments, the `content` field is annotated with `@Core.MediaType: 'application/octet-stream'` (a static value) instead of a path reference to the `mimeType` field. This is because CDS flattening rewrites `content` to `profileIcon_content` but does **not** rewrite path references like `mimeType` to `profileIcon_mimeType`, which would result in a broken reference. Static annotations propagate correctly through CDS flattening. + +#### Inline Attachments on Composition Children + +Inline attachments also work on entities that are composition children. For example, if `Items` is a composition of `Orders`, you can add an inline attachment field to `Items`: + +```cds +entity Orders { + key ID : UUID; + items : Composition of many Items; +} +entity Items { + key ID : UUID; + title : String; + receipt : Attachment; +} +``` + +The plugin automatically discovers inline attachment fields at any level of the composition tree. + +#### Combining Inline and Composition-Based Attachments + +An entity can use both inline single attachments and composition-based multiple attachments simultaneously: + +```cds +entity Books { + key ID : UUID; + profileIcon : Attachment; // single file + attachments : Composition of many Attachments; // multiple files +} +``` + ### Try the Bookshop Sample The easiest way to get started is with the included [bookshop sample](samples/bookshop/): From d58680de713dd2f6e5bb465f02c26f85d33d619e Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Tue, 14 Apr 2026 10:02:02 +0200 Subject: [PATCH 13/45] Move statusNav to MediaData aspect and update bookshop version --- .../service/malware/DefaultAttachmentMalwareScanner.java | 7 ++----- .../com.sap.cds/cds-feature-attachments/attachments.cds | 5 +++-- .../malware/DefaultAttachmentMalwareScannerTest.java | 4 ++-- samples/bookshop/pom.xml | 2 +- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java index b2aa8b331..6b5d448b9 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java @@ -75,8 +75,7 @@ public void scanAttachment( contentId, attachmentEntity.getQualifiedName()); - List selectionResults = - selectData(attachmentEntity, contentId, inlinePrefix); + List selectionResults = selectData(attachmentEntity, contentId, inlinePrefix); MalwareScanResultStatus status = findAndScanAttachments(selectionResults, contentId, inlinePrefix); @@ -94,9 +93,7 @@ public void scanAttachment( } private MalwareScanResultStatus findAndScanAttachments( - List selectionResults, - String contentId, - Optional inlinePrefix) { + List selectionResults, String contentId, Optional inlinePrefix) { return selectionResults.stream() .filter(result -> validateAndFilter(result, contentId)) .findFirst() diff --git a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds index 907cc419f..b8779abcf 100644 --- a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds +++ b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds @@ -20,7 +20,6 @@ type Attachment @(_is_media_data) { fileName : String(5000); contentId : String @readonly; // id of attachment in external storage, if database storage is used, same as id status : StatusCode default 'Unscanned' @readonly; - statusNav : Association to one ScanStates on statusNav.code = status; scannedAt : Timestamp @readonly; } @@ -29,7 +28,9 @@ entity ScanStates : CodeList { name : localized String(64); criticality : Integer @UI.Hidden; } -aspect MediaData : Attachment {} +aspect MediaData : Attachment { + statusNav : Association to one ScanStates on statusNav.code = status; +} aspect Attachments : cuid, managed, MediaData { note : String(5000); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java index 7cac3773a..92de73886 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java @@ -286,7 +286,7 @@ void scanResultWrittenToAllEntitiesEvenIfDraftDeletedDuringScanning() { .thenReturn(draftUpdateResult) .thenReturn(activeUpdateResult); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); // Scan should happen once from draft content verify(malwareScanClient).scanContent(content); @@ -308,7 +308,7 @@ void noScanOrUpdateWhenAttachmentNotFoundInAnyEntity() { when(emptyResult.rowCount()).thenReturn(0L); when(persistenceService.run(any(CqnSelect.class))).thenReturn(emptyResult); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); verifyNoInteractions(malwareScanClient); verify(persistenceService, times(0)).run(any(CqnUpdate.class)); diff --git a/samples/bookshop/pom.xml b/samples/bookshop/pom.xml index 4ea901222..a49e3c384 100644 --- a/samples/bookshop/pom.xml +++ b/samples/bookshop/pom.xml @@ -48,7 +48,7 @@ com.sap.cds cds-feature-attachments - 1.4.0-SNAPSHOT + 1.5.0 From 067e1150601e9d982d382a564e15f4fd7b3cdf15 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 15 Apr 2026 17:43:29 +0200 Subject: [PATCH 14/45] fundamentals --- .../helper/ReadonlyDataContextEnhancer.java | 13 ++++ .../modifyevents/CreateAttachmentEvent.java | 60 ++++++++++++++++++ .../DraftPatchAttachmentsHandler.java | 52 ++++++++++++++++ .../attachments-annotations.cds | 53 +++++++++------- .../cds-feature-attachments/attachments.cds | 62 +++++++++++-------- .../app/admin-books/fiori-service.cds | 8 ++- samples/bookshop/srv/attachments.cds | 12 +--- 7 files changed, 197 insertions(+), 63 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancer.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancer.java index 83db2998a..d2dbaf542 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancer.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancer.java @@ -7,6 +7,7 @@ import com.sap.cds.CdsDataProcessor; import com.sap.cds.CdsDataProcessor.Validator; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; import com.sap.cds.reflect.CdsEntity; import java.util.List; @@ -50,6 +51,8 @@ public static void preserveReadonlyFields(CdsEntity target, List data, (String) path.target().values().get(prefix + Attachments.STATUS)); attachment.setScannedAt( (java.time.Instant) path.target().values().get(prefix + Attachments.SCANNED_AT)); + attachment.setFileName( + (String) path.target().values().get(prefix + MediaData.FILE_NAME)); path.target().values().put(prefix + DRAFT_READONLY_CONTEXT, attachment); } else { // Composition-based attachment: use direct field names @@ -58,6 +61,7 @@ public static void preserveReadonlyFields(CdsEntity target, List data, attachment.setContentId(values.getContentId()); attachment.setStatus(values.getStatus()); attachment.setScannedAt(values.getScannedAt()); + attachment.setFileName(values.getFileName()); path.target().values().put(DRAFT_READONLY_CONTEXT, attachment); } } else { @@ -89,6 +93,10 @@ public static void restoreReadonlyFields(CdsData data) { data.put(Attachments.CONTENT_ID, readOnlyData.get(Attachments.CONTENT_ID)); data.put(Attachments.STATUS, readOnlyData.get(Attachments.STATUS)); data.put(Attachments.SCANNED_AT, readOnlyData.get(Attachments.SCANNED_AT)); + // Only restore fileName if it was preserved (avoid overwriting framework-provided value) + if (readOnlyData.get(MediaData.FILE_NAME) != null) { + data.put(MediaData.FILE_NAME, readOnlyData.get(MediaData.FILE_NAME)); + } data.remove(DRAFT_READONLY_CONTEXT); } @@ -105,6 +113,11 @@ public static void restoreReadonlyFields(CdsData data) { data.put( prefix + "_" + Attachments.SCANNED_AT, inlineReadOnlyData.get(Attachments.SCANNED_AT)); + if (inlineReadOnlyData.get(MediaData.FILE_NAME) != null) { + data.put( + prefix + "_" + MediaData.FILE_NAME, + inlineReadOnlyData.get(MediaData.FILE_NAME)); + } data.remove(key); } } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java index 91ae28f3c..b27d7ff4a 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java @@ -59,6 +59,25 @@ public InputStream processEvent( Optional fileNameOptional = getFieldValue(MediaData.FILE_NAME, values, attachment, inlinePrefix); + // For inline attachments, CDS flattening breaks the Core.ContentDisposition.Filename and + // Core.MediaType path references, so the framework won't extract fileName/mimeType from the + // upload request headers. Extract them manually and persist into the prefixed fields. + if (inlinePrefix.isPresent()) { + if (fileNameOptional.isEmpty()) { + fileNameOptional = extractFileNameFromHeader(eventContext); + fileNameOptional.ifPresent( + fn -> values.put(inlinePrefix.get() + "_" + MediaData.FILE_NAME, fn)); + } + if (mimeTypeOptional.isEmpty() + || "application/octet-stream".equals(mimeTypeOptional.get())) { + Optional headerMimeType = extractMimeTypeFromHeader(eventContext); + if (headerMimeType.isPresent()) { + mimeTypeOptional = headerMimeType; + values.put(inlinePrefix.get() + "_" + MediaData.MIME_TYPE, mimeTypeOptional.get()); + } + } + } + CreateAttachmentInput createEventInput = new CreateAttachmentInput( keys, @@ -102,4 +121,45 @@ private static Optional getFieldValue( Object value = nonNull(annotationValue) ? annotationValue : attachment.get(fieldName); return Optional.ofNullable((String) value); } + + private static Optional extractFileNameFromHeader(EventContext eventContext) { + String header = eventContext.getParameterInfo().getHeader("Content-Disposition"); + if (header != null) { + // Try RFC 5987 encoded filename first (filename*=UTF-8''...) + java.util.regex.Matcher utf8 = + java.util.regex.Pattern.compile( + "filename\\*=UTF-8''(.+)", java.util.regex.Pattern.CASE_INSENSITIVE) + .matcher(header); + if (utf8.find()) { + try { + return Optional.of(java.net.URLDecoder.decode(utf8.group(1), "UTF-8")); + } catch (java.io.UnsupportedEncodingException e) { + logger.debug("Failed to decode RFC 5987 filename", e); + } + } + // Fall back to plain filename= + java.util.regex.Matcher plain = + java.util.regex.Pattern.compile( + "filename=\"?([^\";]+)\"?", java.util.regex.Pattern.CASE_INSENSITIVE) + .matcher(header); + if (plain.find()) { + return Optional.of(plain.group(1).trim()); + } + } + // Fiori Elements may use the slug header instead + String slug = eventContext.getParameterInfo().getHeader("slug"); + return Optional.ofNullable(slug); + } + + private static Optional extractMimeTypeFromHeader(EventContext eventContext) { + String contentType = eventContext.getParameterInfo().getHeader("Content-Type"); + if (contentType == null) { + return Optional.empty(); + } + String mimeType = contentType.split(";")[0].trim(); + if (mimeType.isEmpty() || "application/octet-stream".equalsIgnoreCase(mimeType)) { + return Optional.empty(); + } + return Optional.of(mimeType); + } } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java index a40cdc6c6..0ca11362c 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java @@ -10,11 +10,14 @@ import com.sap.cds.CdsDataProcessor.Converter; import com.sap.cds.Result; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper; import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.ModifyAttachmentEventFactory; import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; import com.sap.cds.ql.Select; +import com.sap.cds.ql.Update; import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.ql.cqn.CqnUpdate; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.draft.DraftPatchEventContext; import com.sap.cds.services.draft.DraftService; @@ -24,6 +27,7 @@ import com.sap.cds.services.handler.annotations.ServiceName; import com.sap.cds.services.persistence.PersistenceService; import java.io.InputStream; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -103,5 +107,53 @@ void processBeforeDraftPatch(DraftPatchEventContext context, List data) { + List inlinePrefixes = + ApplicationHandlerHelper.getInlineAttachmentFieldNames(target); + if (inlinePrefixes.isEmpty()) { + return; + } + + CdsEntity draftEntity = DraftUtils.getDraftEntity(target); + for (CdsData d : data) { + for (String prefix : inlinePrefixes) { + String mimeTypeField = prefix + "_" + MediaData.MIME_TYPE; + String fileNameField = prefix + "_" + MediaData.FILE_NAME; + String contentIdField = prefix + "_" + Attachments.CONTENT_ID; + + // Only update if the attachment was actually processed (contentId present) + Object contentId = d.get(contentIdField); + if (contentId == null) { + continue; + } + + Map updateData = new HashMap<>(); + Object mimeType = d.get(mimeTypeField); + Object fileName = d.get(fileNameField); + if (mimeType != null) { + updateData.put(mimeTypeField, mimeType); + } + if (fileName != null) { + updateData.put(fileNameField, fileName); + } + if (updateData.isEmpty()) { + continue; + } + + CqnUpdate update = + Update.entity(draftEntity) + .data(updateData) + .where(entry -> entry.get(contentIdField).eq(contentId)); + persistence.run(update); + } + } } } diff --git a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds index 0ef76002d..7ebeeb20f 100644 --- a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds +++ b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds @@ -9,36 +9,32 @@ using { // CDS flattening rewrites 'content' to 'prefix_content' but does NOT rewrite the path reference 'mimeType' to 'prefix_mimeType', causing a broken reference to a non-existent field. // Static annotations on the Attachment type propagate through CDS flattening to inline fields (e.g. profileIcon_status gets @readonly automatically). // Only static values work here. Path references (like Core.MediaType: mimeType, ContentDisposition.Filename: fileName, or SideEffects) do NOT work for inline attachments due to CDS flattening limitations. -annotate Attachment with { - content @Core.MediaType: 'application/octet-stream'; - status @(title: '{i18n>attachment_status}', readonly); - contentId @(UI.Hidden: true, readonly); - scannedAt @(UI.Hidden: true, readonly); - fileName @(title: '{i18n>attachment_fileName}'); - mimeType @(title: '{i18n>attachment_mimeType}'); -} -annotate MediaData with @UI.MediaResource: {Stream: content} { +annotate sap.attachments.MediaData with @UI.MediaResource: {Stream: content} { content @( title : '{i18n>attachment_content}', - Core.MediaType : mimeType, - Core.ContentDisposition.Filename: fileName, - Core.ContentDisposition.Type : 'inline' + Core.ContentDisposition.Type : 'inline', + Core.MediaType : 'application/octet-stream' ); mimeType @( - title: '{i18n>attachment_mimeType}', - Core.IsMediaType + title: '{i18n>attachment_mimeType}' ); fileName @( title: '{i18n>attachment_fileName}', - UI.MultiLineText + UI.MultiLineText, + readonly ); - status @(title: '{i18n>attachment_status}', Common.Text : statusNav.name, Common.TextArrangement : #TextOnly); - contentId @(UI.Hidden: true); - scannedAt @(UI.Hidden: true); + status @(title: '{i18n>attachment_status}', readonly); + contentId @(UI.Hidden: true, readonly); + scannedAt @(UI.Hidden: true, readonly); } -annotate Attachments with @UI: { +annotate sap.attachments.Attachments with +@Capabilities: { + UpdateRestrictions.NonUpdateableProperties: [content], + SortRestrictions: { NonSortableProperties: [content] } +} +@UI: { HeaderInfo: { TypeName : '{i18n>attachment}', TypeNamePlural: '{i18n>attachments}', @@ -51,7 +47,17 @@ annotate Attachments with @UI: { {Value: note, @HTML5.CssDefaults: {width: '25%'}}, {Value: up__ID, @UI.Hidden} ] -} { +} +@Common: {SideEffects #ContentChanged: { + SourceProperties: [content], + TargetProperties: ['status'] +}} { + content @( + Core.ContentDisposition.Filename: fileName, + Core.MediaType: mimeType + ); + mimeType @Core.IsMediaType; + status @(Common.Text : statusNav.name, Common.TextArrangement : #TextOnly); note @( title: '{i18n>attachment_note}', UI.MultiLineText @@ -59,7 +65,6 @@ annotate Attachments with @UI: { modifiedAt @(odata.etag); } -annotate Attachments with @Common: {SideEffects #ContentChanged: { - SourceProperties: [content], - TargetProperties: ['status'] -}} {}; +annotate sap.attachments.Attachment with { + content @Core.ContentDisposition.Filename: fileName; +} diff --git a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds index b8779abcf..41682fb37 100644 --- a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds +++ b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds @@ -1,37 +1,45 @@ -namespace sap.attachments; - using { cuid, managed, sap.common.CodeList } from '@sap/cds/common'; -type StatusCode : String(32) enum { - Unscanned; - Scanning; - Clean; - Infected; - Failed; -} +// The common root-level aspect used in applications like that: +// using { Attachments } from 'com.sap.cds/cds-feature-attachments' +aspect Attachments : sap.attachments.Attachments {} -type Attachment @(_is_media_data) { - content : LargeBinary; // stored only for db-based services - mimeType : String; - fileName : String(5000); - contentId : String @readonly; // id of attachment in external storage, if database storage is used, same as id - status : StatusCode default 'Unscanned' @readonly; - scannedAt : Timestamp @readonly; -} +type Attachment : sap.attachments.Attachment; -entity ScanStates : CodeList { - key code : StatusCode @Common.Text: name @Common.TextArrangement: #TextOnly; - name : localized String(64); - criticality : Integer @UI.Hidden; -} -aspect MediaData : Attachment { - statusNav : Association to one ScanStates on statusNav.code = status; -} +context sap.attachments { -aspect Attachments : cuid, managed, MediaData { - note : String(5000); + type StatusCode : String(32) enum { + Unscanned; + Scanning; + Clean; + Infected; + Failed; + } + + entity ScanStates : CodeList { + key code : StatusCode @Common.Text: name @Common.TextArrangement: #TextOnly; + name : localized String(64); + criticality : Integer @UI.Hidden; + } + + aspect MediaData @(_is_media_data) : managed { + content : LargeBinary; // stored only for db-based services + mimeType : String default 'application/octet-stream'; + fileName : String(5000); + contentId : String @readonly; // id of attachment in external storage, if database storage is used, same as id + status : StatusCode default 'Unscanned' @readonly; + scannedAt : Timestamp @readonly; + note : String(5000); + } + + type Attachment : MediaData {} + + aspect Attachments : cuid, MediaData { + statusNav : Association to one ScanStates on statusNav.code = status; + } } + diff --git a/samples/bookshop/app/admin-books/fiori-service.cds b/samples/bookshop/app/admin-books/fiori-service.cds index 152a8cf9f..4b3fcbab8 100644 --- a/samples/bookshop/app/admin-books/fiori-service.cds +++ b/samples/bookshop/app/admin-books/fiori-service.cds @@ -67,11 +67,15 @@ annotate AdminService.Books with @(UI: { ]}, FieldGroup #ProfileIcon: {Data: [ {Value: profileIcon_content}, - {Value: profileIcon_status} + {Value: profileIcon_fileName}, + {Value: profileIcon_status}, + {Value: profileIcon_note} ]}, FieldGroup #CoverImage: {Data: [ {Value: coverImage_content}, - {Value: coverImage_status} + {Value: coverImage_fileName}, + {Value: coverImage_status}, + {Value: coverImage_note} ]} }); diff --git a/samples/bookshop/srv/attachments.cds b/samples/bookshop/srv/attachments.cds index f6ba9c4c5..35cbcd088 100644 --- a/samples/bookshop/srv/attachments.cds +++ b/samples/bookshop/srv/attachments.cds @@ -1,6 +1,6 @@ using {sap.capire.bookshop as my} from '../db/schema'; -using {sap.attachments.Attachments} from 'com.sap.cds/cds-feature-attachments'; -using {sap.attachments.Attachment} from 'com.sap.cds/cds-feature-attachments'; +using {Attachments} from 'com.sap.cds/cds-feature-attachments'; +using {Attachment} from 'com.sap.cds/cds-feature-attachments'; // Extend Books entity to support file attachments (images, PDFs, documents) // Each book can have multiple attachments via composition relationship @@ -40,11 +40,3 @@ annotate service.Books with @(UI.Facets: [{ Target: 'attachments/@UI.LineItem' }]); -// AdminService Facets (including attachments and profileIcon) are defined in -// app/admin-books/fiori-service.cds. Don't re-annotate UI.Facets here, -// as it would override the complete facet list defined there. - - -service nonDraft { - entity Books as projection on my.Books; -} From ab2a73a5125a529aae868147cda66b81dcd801e3 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 16 Apr 2026 14:01:09 +0200 Subject: [PATCH 15/45] spotless --- .../helper/ReadonlyDataContextEnhancer.java | 3 +-- .../modifyevents/CreateAttachmentEvent.java | 3 +-- .../handler/draftservice/DraftPatchAttachmentsHandler.java | 6 ++---- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancer.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancer.java index d2dbaf542..343adb9d5 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancer.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancer.java @@ -115,8 +115,7 @@ public static void restoreReadonlyFields(CdsData data) { inlineReadOnlyData.get(Attachments.SCANNED_AT)); if (inlineReadOnlyData.get(MediaData.FILE_NAME) != null) { data.put( - prefix + "_" + MediaData.FILE_NAME, - inlineReadOnlyData.get(MediaData.FILE_NAME)); + prefix + "_" + MediaData.FILE_NAME, inlineReadOnlyData.get(MediaData.FILE_NAME)); } data.remove(key); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java index b27d7ff4a..a4ee59315 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java @@ -68,8 +68,7 @@ public InputStream processEvent( fileNameOptional.ifPresent( fn -> values.put(inlinePrefix.get() + "_" + MediaData.FILE_NAME, fn)); } - if (mimeTypeOptional.isEmpty() - || "application/octet-stream".equals(mimeTypeOptional.get())) { + if (mimeTypeOptional.isEmpty() || "application/octet-stream".equals(mimeTypeOptional.get())) { Optional headerMimeType = extractMimeTypeFromHeader(eventContext); if (headerMimeType.isPresent()) { mimeTypeOptional = headerMimeType; diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java index 0ca11362c..a94160cc5 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java @@ -114,10 +114,8 @@ void processBeforeDraftPatch(DraftPatchEventContext context, List data) { - List inlinePrefixes = - ApplicationHandlerHelper.getInlineAttachmentFieldNames(target); + private void persistInlineAttachmentMetadata(CdsEntity target, List data) { + List inlinePrefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(target); if (inlinePrefixes.isEmpty()) { return; } From 304f772ca05bc1749c3e020e69553cdb7ea052a2 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 16 Apr 2026 14:23:08 +0200 Subject: [PATCH 16/45] Fix compilation after merge: add third argument to getChangeSetListener mock The feature branch added an inlinePrefix parameter to EndTransactionMalwareScanProvider.getChangeSetListener. A test added on main was still using the 2-arg overload. --- .../service/handler/AttachmentsServiceImplHandlerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java index 97e29ae63..4dc81b183 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java @@ -147,7 +147,7 @@ void createAttachment_emptyAttachmentIds_handlesGracefully() { @Test void afterCreateAttachment_noChangeSetContext_throws() { var entity = mock(CdsEntity.class); - when(malwareScanProvider.getChangeSetListener(any(), any())) + when(malwareScanProvider.getChangeSetListener(any(), any(), any())) .thenReturn(mock(ChangeSetListener.class)); var createContext = AttachmentCreateEventContext.create(); createContext.setAttachmentIds(Map.of(Attachments.ID, "some-id")); From cc93c13dac53a349864c64d67901512bce5f7b99 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 16 Apr 2026 21:19:12 +0200 Subject: [PATCH 17/45] add tests --- .../DefaultAttachmentMalwareScanner.java | 4 +- .../attachments-annotations.cds | 6 +- .../ReadonlyDataContextEnhancerTest.java | 36 ++++ .../CreateAttachmentEventTest.java | 182 ++++++++++++++++++ .../DraftPatchAttachmentsHandlerTest.java | 134 +++++++++++++ .../DefaultAttachmentMalwareScannerTest.java | 25 +++ 6 files changed, 384 insertions(+), 3 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java index c6f7bc719..4d7c06402 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java @@ -97,7 +97,9 @@ private MalwareScanResultStatus findAndScanAttachments( return selectionResults.stream() .filter(result -> validateAndFilter(result, contentId)) .findFirst() - .map(result -> scanDocument(extractAttachment(result.result(), inlinePrefix), result.entity())) + .map( + result -> + scanDocument(extractAttachment(result.result(), inlinePrefix), result.entity())) .orElse(null); } diff --git a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds index 7ebeeb20f..dc064eb2a 100644 --- a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds +++ b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds @@ -14,10 +14,12 @@ annotate sap.attachments.MediaData with @UI.MediaResource: {Stream: content} { content @( title : '{i18n>attachment_content}', Core.ContentDisposition.Type : 'inline', - Core.MediaType : 'application/octet-stream' + Core.MediaType : 'application/octet-stream', + odata.draft.skip ); mimeType @( - title: '{i18n>attachment_mimeType}' + title: '{i18n>attachment_mimeType}', + Core.IsMediaType ); fileName @( title: '{i18n>attachment_fileName}', diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java index dd46c523f..3f4596210 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java @@ -7,6 +7,7 @@ import com.sap.cds.CdsData; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Events_; 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.RootTable_; @@ -240,4 +241,39 @@ void restoreReadonlyFieldsInlineWithNullBackupDoesNothing() { assertThat(data.get("ID")).isEqualTo("123"); assertThat(data.containsKey("profilePicture_contentId")).isFalse(); } + + @Test + void restoreReadonlyFieldsCompositionWithFileName() { + CdsData data = CdsData.create(); + Attachments backup = Attachments.create(); + backup.setContentId("cid-fn"); + backup.setStatus("Clean"); + backup.setFileName("document.pdf"); + data.put(DRAFT_READONLY_CONTEXT, backup); + + ReadonlyDataContextEnhancer.restoreReadonlyFields(data); + + assertThat(data.get(Attachments.CONTENT_ID)).isEqualTo("cid-fn"); + assertThat(data.get(Attachments.STATUS)).isEqualTo("Clean"); + assertThat(data.get(MediaData.FILE_NAME)).isEqualTo("document.pdf"); + assertThat(data.containsKey(DRAFT_READONLY_CONTEXT)).isFalse(); + } + + @Test + void restoreReadonlyFieldsInlineWithFileName() { + CdsData data = CdsData.create(); + data.put("ID", "123"); + Attachments backup = Attachments.create(); + backup.setContentId("cid-inline-fn"); + backup.setStatus("Clean"); + backup.setFileName("avatar.png"); + data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, backup); + + ReadonlyDataContextEnhancer.restoreReadonlyFields(data); + + assertThat(data.get("profilePicture_contentId")).isEqualTo("cid-inline-fn"); + assertThat(data.get("profilePicture_status")).isEqualTo("Clean"); + assertThat(data.get("profilePicture_fileName")).isEqualTo("avatar.png"); + assertThat(data.containsKey("profilePicture_" + DRAFT_READONLY_CONTEXT)).isFalse(); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java index d10b59aa3..943483847 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java @@ -6,6 +6,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -23,6 +24,7 @@ import com.sap.cds.services.EventContext; import com.sap.cds.services.changeset.ChangeSetContext; import com.sap.cds.services.changeset.ChangeSetListener; +import com.sap.cds.services.request.ParameterInfo; import com.sap.cds.services.runtime.CdsRuntime; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -52,6 +54,7 @@ class CreateAttachmentEventTest { private ArgumentCaptor contextArgumentCaptor; private EventContext eventContext; private ChangeSetContext changeSetContext; + private ParameterInfo parameterInfo; @BeforeEach void setup() { @@ -66,6 +69,8 @@ void setup() { eventContext = mock(EventContext.class); changeSetContext = mock(ChangeSetContext.class); when(eventContext.getChangeSetContext()).thenReturn(changeSetContext); + parameterInfo = mock(ParameterInfo.class); + when(eventContext.getParameterInfo()).thenReturn(parameterInfo); when(target.entity()).thenReturn(entity); when(path.target()).thenReturn(target); } @@ -319,4 +324,181 @@ void processEventWritesScannedAtWhenNonNull() { assertThat(values).containsEntry("profilePicture_status", "Clean"); assertThat(values).containsEntry("profilePicture_scannedAt", scannedAt); } + + // --- Inline Header Extraction Tests --- + + private Map prepareInlineValuesWithoutMetadata() { + CdsEntity realEntity = + RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + when(target.entity()).thenReturn(realEntity); + + Map values = new HashMap<>(); + values.put("ID", UUID.randomUUID().toString()); + when(target.values()).thenReturn(values); + when(target.keys()).thenReturn(Map.of("ID", values.get("ID"))); + when(attachmentService.createAttachment(any())) + .thenReturn(new AttachmentModificationResult(false, "id", "ok", null)); + return values; + } + + @Test + void inlineExtractsFileNameFromRfc5987Header() { + Map values = prepareInlineValuesWithoutMetadata(); + when(parameterInfo.getHeader("Content-Disposition")) + .thenReturn("attachment; filename*=UTF-8''my%20file.txt"); + + cut.processEvent( + path, + mock(InputStream.class), + Attachments.create(), + eventContext, + Optional.of("profilePicture")); + + assertThat(values).containsEntry("profilePicture_fileName", "my file.txt"); + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("my file.txt"); + } + + @Test + void inlineExtractsFileNameFromPlainHeader() { + Map values = prepareInlineValuesWithoutMetadata(); + when(parameterInfo.getHeader("Content-Disposition")) + .thenReturn("attachment; filename=\"report.pdf\""); + + cut.processEvent( + path, + mock(InputStream.class), + Attachments.create(), + eventContext, + Optional.of("profilePicture")); + + assertThat(values).containsEntry("profilePicture_fileName", "report.pdf"); + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("report.pdf"); + } + + @Test + void inlineExtractsFileNameFromSlugHeader() { + Map values = prepareInlineValuesWithoutMetadata(); + when(parameterInfo.getHeader("Content-Disposition")).thenReturn(null); + when(parameterInfo.getHeader("slug")).thenReturn("slug-file.png"); + + cut.processEvent( + path, + mock(InputStream.class), + Attachments.create(), + eventContext, + Optional.of("profilePicture")); + + assertThat(values).containsEntry("profilePicture_fileName", "slug-file.png"); + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("slug-file.png"); + } + + @Test + void inlineBothHeadersNullReturnsEmptyFileName() { + Map values = prepareInlineValuesWithoutMetadata(); + when(parameterInfo.getHeader("Content-Disposition")).thenReturn(null); + when(parameterInfo.getHeader("slug")).thenReturn(null); + + cut.processEvent( + path, + mock(InputStream.class), + Attachments.create(), + eventContext, + Optional.of("profilePicture")); + + assertThat(values).doesNotContainKey("profilePicture_fileName"); + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + assertThat(contextArgumentCaptor.getValue().fileName()).isNull(); + } + + @Test + void inlineExtractsMimeTypeFromContentTypeHeader() { + Map values = prepareInlineValuesWithoutMetadata(); + // No mimeType in values → mimeTypeOptional.isEmpty() triggers header extraction + when(parameterInfo.getHeader("Content-Type")).thenReturn("image/jpeg; charset=utf-8"); + + cut.processEvent( + path, + mock(InputStream.class), + Attachments.create(), + eventContext, + Optional.of("profilePicture")); + + assertThat(values).containsEntry("profilePicture_mimeType", "image/jpeg"); + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + assertThat(contextArgumentCaptor.getValue().mimeType()).isEqualTo("image/jpeg"); + } + + @Test + void inlineMimeTypeOctetStreamOverriddenByHeader() { + Map values = prepareInlineValuesWithoutMetadata(); + values.put("profilePicture_mimeType", "application/octet-stream"); + when(parameterInfo.getHeader("Content-Type")).thenReturn("image/png"); + + cut.processEvent( + path, + mock(InputStream.class), + Attachments.create(), + eventContext, + Optional.of("profilePicture")); + + assertThat(values).containsEntry("profilePicture_mimeType", "image/png"); + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + assertThat(contextArgumentCaptor.getValue().mimeType()).isEqualTo("image/png"); + } + + @Test + void inlineMimeTypeNullContentTypeReturnsEmpty() { + Map values = prepareInlineValuesWithoutMetadata(); + // No mimeType in values, Content-Type header is null + when(parameterInfo.getHeader("Content-Type")).thenReturn(null); + + cut.processEvent( + path, + mock(InputStream.class), + Attachments.create(), + eventContext, + Optional.of("profilePicture")); + + assertThat(values).doesNotContainKey("profilePicture_mimeType"); + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + assertThat(contextArgumentCaptor.getValue().mimeType()).isNull(); + } + + @Test + void inlineMimeTypeOctetStreamContentTypeNotUsed() { + Map values = prepareInlineValuesWithoutMetadata(); + // No mimeType in values, Content-Type header is application/octet-stream → returns empty + when(parameterInfo.getHeader("Content-Type")).thenReturn("application/octet-stream"); + + cut.processEvent( + path, + mock(InputStream.class), + Attachments.create(), + eventContext, + Optional.of("profilePicture")); + + assertThat(values).doesNotContainKey("profilePicture_mimeType"); + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + assertThat(contextArgumentCaptor.getValue().mimeType()).isNull(); + } + + @Test + void inlineFileNameAlreadyPresentSkipsHeaderExtraction() { + Map values = prepareInlineValuesWithoutMetadata(); + values.put("profilePicture_fileName", "already-set.pdf"); + + cut.processEvent( + path, + mock(InputStream.class), + Attachments.create(), + eventContext, + Optional.of("profilePicture")); + + // Header extraction should not be attempted + verify(parameterInfo, never()).getHeader("Content-Disposition"); + assertThat(values).containsEntry("profilePicture_fileName", "already-set.pdf"); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java index b8add72a1..7771b8429 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java @@ -7,6 +7,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -25,6 +26,7 @@ import com.sap.cds.feature.attachments.handler.applicationservice.readhelper.CountingInputStream; import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.ql.cqn.CqnUpdate; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.draft.DraftPatchEventContext; import com.sap.cds.services.draft.DraftService; @@ -255,4 +257,136 @@ void inlineDeleteExtractsExistingContentIdFromFlattenedDbResult() { Attachments captured = attachmentCaptor.getValue(); assertThat(captured.getContentId()).isEqualTo(existingContentId); } + + // --- persistInlineAttachmentMetadata Tests --- + + @Test + void inlinePatchPersistsMetadataWhenContentIdMimeTypeAndFileNamePresent() { + getEntityAndMockContext(RootTable_.CDS_NAME); + + var data = CdsData.create(); + data.put("ID", UUID.randomUUID().toString()); + data.put("profilePicture_content", mock(InputStream.class)); + + var result = mock(Result.class); + when(persistence.run(any(CqnSelect.class))).thenReturn(result); + + // The event.processEvent simulates CreateAttachmentEvent putting metadata into data + when(event.processEvent(any(), any(), any(), any(), any())) + .thenAnswer( + invocation -> { + data.put("profilePicture_contentId", "cid-123"); + data.put("profilePicture_mimeType", "image/png"); + data.put("profilePicture_fileName", "photo.png"); + return null; + }); + + cut.processBeforeDraftPatch(eventContext, List.of(data)); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); + verify(persistence).run(updateCaptor.capture()); + CqnUpdate update = updateCaptor.getValue(); + assertThat(update.entries()).isNotEmpty(); + assertThat(update.entries().get(0)).containsEntry("profilePicture_mimeType", "image/png"); + assertThat(update.entries().get(0)).containsEntry("profilePicture_fileName", "photo.png"); + } + + @Test + void inlinePatchSkipsWhenContentIdNull() { + getEntityAndMockContext(RootTable_.CDS_NAME); + + var data = CdsData.create(); + data.put("ID", UUID.randomUUID().toString()); + data.put("profilePicture_content", mock(InputStream.class)); + + var result = mock(Result.class); + when(persistence.run(any(CqnSelect.class))).thenReturn(result); + + // processEvent does NOT put profilePicture_contentId → contentId remains null + when(event.processEvent(any(), any(), any(), any(), any())).thenReturn(null); + + cut.processBeforeDraftPatch(eventContext, List.of(data)); + + verify(persistence, never()).run(any(CqnUpdate.class)); + } + + @Test + void inlinePatchSkipsUpdateWhenNoMetadata() { + getEntityAndMockContext(RootTable_.CDS_NAME); + + var data = CdsData.create(); + data.put("ID", UUID.randomUUID().toString()); + data.put("profilePicture_content", mock(InputStream.class)); + + var result = mock(Result.class); + when(persistence.run(any(CqnSelect.class))).thenReturn(result); + + // processEvent puts contentId but no mimeType/fileName + when(event.processEvent(any(), any(), any(), any(), any())) + .thenAnswer( + invocation -> { + data.put("profilePicture_contentId", "cid-456"); + return null; + }); + + cut.processBeforeDraftPatch(eventContext, List.of(data)); + + verify(persistence, never()).run(any(CqnUpdate.class)); + } + + @Test + void inlinePatchPersistsOnlyMimeType() { + getEntityAndMockContext(RootTable_.CDS_NAME); + + var data = CdsData.create(); + data.put("ID", UUID.randomUUID().toString()); + data.put("profilePicture_content", mock(InputStream.class)); + + var result = mock(Result.class); + when(persistence.run(any(CqnSelect.class))).thenReturn(result); + + when(event.processEvent(any(), any(), any(), any(), any())) + .thenAnswer( + invocation -> { + data.put("profilePicture_contentId", "cid-789"); + data.put("profilePicture_mimeType", "text/plain"); + return null; + }); + + cut.processBeforeDraftPatch(eventContext, List.of(data)); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); + verify(persistence).run(updateCaptor.capture()); + CqnUpdate update = updateCaptor.getValue(); + assertThat(update.entries().get(0)).containsEntry("profilePicture_mimeType", "text/plain"); + assertThat(update.entries().get(0)).doesNotContainKey("profilePicture_fileName"); + } + + @Test + void inlinePatchPersistsOnlyFileName() { + getEntityAndMockContext(RootTable_.CDS_NAME); + + var data = CdsData.create(); + data.put("ID", UUID.randomUUID().toString()); + data.put("profilePicture_content", mock(InputStream.class)); + + var result = mock(Result.class); + when(persistence.run(any(CqnSelect.class))).thenReturn(result); + + when(event.processEvent(any(), any(), any(), any(), any())) + .thenAnswer( + invocation -> { + data.put("profilePicture_contentId", "cid-000"); + data.put("profilePicture_fileName", "document.pdf"); + return null; + }); + + cut.processBeforeDraftPatch(eventContext, List.of(data)); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); + verify(persistence).run(updateCaptor.capture()); + CqnUpdate update = updateCaptor.getValue(); + assertThat(update.entries().get(0)).containsEntry("profilePicture_fileName", "document.pdf"); + assertThat(update.entries().get(0)).doesNotContainKey("profilePicture_mimeType"); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java index 92de73886..fbf30c3e2 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java @@ -14,6 +14,7 @@ import static org.mockito.Mockito.when; import com.sap.cds.Result; +import com.sap.cds.Row; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.StatusCode; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Attachment_; @@ -380,4 +381,28 @@ void resolveColumnWithoutPrefixReturnsFieldName() { String result = DefaultAttachmentMalwareScanner.resolveColumn("contentId", Optional.empty()); assertThat(result).isEqualTo("contentId"); } + + @Test + void scanAttachmentWithInlinePrefixExtractsFromPrefixedColumns() { + var entity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME); + var content = mock(InputStream.class); + + // Build a row with prefixed column names as the inline path expects + var row = mock(Row.class); + when(row.get("avatar_contentId")).thenReturn("cid-inline"); + when(row.get("avatar_content")).thenReturn(content); + when(row.get("avatar_status")).thenReturn("Unscanned"); + + when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); + when(result.rowCount()).thenReturn(1L); + when(result.single()).thenReturn(row); + when(malwareScanClient.scanContent(any())).thenReturn(MalwareScanResultStatus.CLEAN); + + cut.scanAttachment(entity.orElseThrow(), "cid-inline", Optional.of("avatar")); + + verify(malwareScanClient).scanContent(content); + verify(persistenceService).run(updateCaptor.capture()); + CqnUpdate update = updateCaptor.getValue(); + assertThat(update.entries().get(0)).containsEntry("avatar_status", StatusCode.CLEAN); + } } From 779c25326c185b3c27139fa9656f7a3761228dc9 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 16 Apr 2026 21:21:18 +0200 Subject: [PATCH 18/45] reduce diff --- .../cds-feature-attachments/attachments-annotations.cds | 6 ------ 1 file changed, 6 deletions(-) diff --git a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds index dc064eb2a..60a4209c0 100644 --- a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds +++ b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds @@ -4,12 +4,6 @@ using { sap.attachments.Attachments } from './attachments'; -// Annotate Attachment type with a static Core.MediaType so that LargeBinary content is exposed as Edm.Stream (enabling Fiori upload widget). -// Using 'mimeType' (path reference) instead of a static value would break inline usage: -// CDS flattening rewrites 'content' to 'prefix_content' but does NOT rewrite the path reference 'mimeType' to 'prefix_mimeType', causing a broken reference to a non-existent field. -// Static annotations on the Attachment type propagate through CDS flattening to inline fields (e.g. profileIcon_status gets @readonly automatically). -// Only static values work here. Path references (like Core.MediaType: mimeType, ContentDisposition.Filename: fileName, or SideEffects) do NOT work for inline attachments due to CDS flattening limitations. - annotate sap.attachments.MediaData with @UI.MediaResource: {Stream: content} { content @( title : '{i18n>attachment_content}', From 666916837287afdde5690b3370df475b88fe3758 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Sun, 19 Apr 2026 20:56:58 +0200 Subject: [PATCH 19/45] fix with cds-services fix/stream-utils-cqn-element-ref branch --- .../DeleteAttachmentsHandler.java | 15 +- .../ModifyApplicationHandlerHelper.java | 19 +- .../modifyevents/CreateAttachmentEvent.java | 27 +- .../common/ApplicationHandlerHelper.java | 9 + .../handler/common/AttachmentsReader.java | 2 + .../DraftPatchAttachmentsHandler.java | 56 +- .../attachments-annotations.cds | 18 +- integration-tests/db/data-model.cds | 4 + .../SingleAttachmentNonDraftTest.java | 830 ++++++++++++++++++ pom.xml | 2 +- .../app/admin-books/fiori-service.cds | 2 +- samples/bookshop/pom.xml | 4 +- 12 files changed, 947 insertions(+), 41 deletions(-) create mode 100644 integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandler.java index 93af00333..0deb5d237 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandler.java @@ -57,12 +57,17 @@ void processBefore(CdsDeleteEventContext context) { Optional inlinePrefix = ApplicationHandlerHelper.getInlineAttachmentPrefix( path.target().entity(), element.getName()); + // For inline attachments, extract the prefixed fields to get proper contentId + Attachments attachment; + if (inlinePrefix.isPresent()) { + attachment = + ApplicationHandlerHelper.extractInlineAttachment( + path.target().values(), inlinePrefix.get()); + } else { + attachment = Attachments.of(path.target().values()); + } return deleteEvent.processEvent( - path, - (InputStream) value, - Attachments.of(path.target().values()), - context, - inlinePrefix); + path, (InputStream) value, attachment, context, inlinePrefix); }; CdsDataProcessor.create() diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java index 24d095022..1ff996a05 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java @@ -93,7 +93,7 @@ public static InputStream handleAttachmentForEntity( Optional inlinePrefix) { Map keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys()); ReadonlyDataContextEnhancer.restoreReadonlyFields((CdsData) path.target().values()); - Attachments attachment = getExistingAttachment(keys, existingAttachments); + Attachments attachment = getExistingAttachment(keys, existingAttachments, inlinePrefix); // For inline attachment fields, extract contentId using the known prefix String contentId; @@ -103,6 +103,7 @@ public static InputStream handleAttachmentForEntity( } else { contentId = (String) path.target().values().get(Attachments.CONTENT_ID); } + String contentLength = eventContext.getParameterInfo().getHeader("Content-Length"); String maxSizeStr = getValMaxValue(path.target().entity(), defaultMaxSize); eventContext.put( @@ -161,9 +162,21 @@ private static String getValMaxValue(CdsEntity entity, String defaultMaxSize) { } private static Attachments getExistingAttachment( - Map keys, List existingAttachments) { + Map keys, + List existingAttachments, + Optional inlinePrefix) { return existingAttachments.stream() - .filter(existingData -> ApplicationHandlerHelper.areKeysInData(keys, existingData)) + .filter( + existingData -> { + // For inline attachments, match by the prefix marker + if (inlinePrefix.isPresent()) { + String existingPrefix = + (String) existingData.get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER); + return inlinePrefix.get().equals(existingPrefix); + } + // For composition-based attachments, match by keys + return ApplicationHandlerHelper.areKeysInData(keys, existingData); + }) .findAny() .orElse(Attachments.create()); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java index a4ee59315..6504a1078 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java @@ -62,18 +62,21 @@ public InputStream processEvent( // For inline attachments, CDS flattening breaks the Core.ContentDisposition.Filename and // Core.MediaType path references, so the framework won't extract fileName/mimeType from the // upload request headers. Extract them manually and persist into the prefixed fields. - if (inlinePrefix.isPresent()) { - if (fileNameOptional.isEmpty()) { - fileNameOptional = extractFileNameFromHeader(eventContext); - fileNameOptional.ifPresent( - fn -> values.put(inlinePrefix.get() + "_" + MediaData.FILE_NAME, fn)); - } - if (mimeTypeOptional.isEmpty() || "application/octet-stream".equals(mimeTypeOptional.get())) { - Optional headerMimeType = extractMimeTypeFromHeader(eventContext); - if (headerMimeType.isPresent()) { - mimeTypeOptional = headerMimeType; - values.put(inlinePrefix.get() + "_" + MediaData.MIME_TYPE, mimeTypeOptional.get()); - } + if (inlinePrefix.isPresent() && fileNameOptional.isEmpty()) { + fileNameOptional = extractFileNameFromHeader(eventContext); + fileNameOptional.ifPresent( + fn -> values.put(inlinePrefix.get() + "_" + MediaData.FILE_NAME, fn)); + } + + // Extract mimeType from Content-Type header for ALL attachment types (inline and composition) + // when mimeType is not already set or is the default 'application/octet-stream' + if (mimeTypeOptional.isEmpty() || "application/octet-stream".equals(mimeTypeOptional.get())) { + Optional headerMimeType = extractMimeTypeFromHeader(eventContext); + if (headerMimeType.isPresent()) { + mimeTypeOptional = headerMimeType; + String mimeTypeField = + inlinePrefix.map(p -> p + "_" + MediaData.MIME_TYPE).orElse(MediaData.MIME_TYPE); + values.put(mimeTypeField, mimeTypeOptional.get()); } } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java index 2b4d70304..668c93573 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java @@ -30,6 +30,13 @@ public final class ApplicationHandlerHelper { private static final String ANNOTATION_IS_MEDIA_DATA = "_is_media_data"; private static final String ANNOTATION_CORE_MEDIA_TYPE = "Core.MediaType"; + /** + * Internal marker key used to store the inline attachment prefix in extracted attachment data. + * This enables correct matching of inline attachments when operating on multiple inline + * attachments on the same entity. + */ + public static final String INLINE_PREFIX_MARKER = "_inlinePrefix"; + /** * A filter for media content fields. The filter checks if the entity is a media entity and if the * element has the annotation "Core.MediaType". Also supports inline attachment type fields where @@ -249,6 +256,8 @@ public static Attachments extractInlineAttachment( attachment.put(logicalName, value); } }); + // Store the inline prefix so we can match later + attachment.put(INLINE_PREFIX_MARKER, prefix); return attachment; } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java index 1b1653860..6153d4b2a 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java @@ -51,6 +51,8 @@ public List readAttachments( List inlineFields = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); List inlineColumns = new ArrayList<>(); for (String fieldName : inlineFields) { + // Include the content field so CdsDataProcessor's MEDIA_CONTENT_FILTER can match it + inlineColumns.add(CQL.get(fieldName + "_content")); inlineColumns.add(CQL.get(fieldName + "_" + Attachments.CONTENT_ID)); inlineColumns.add(CQL.get(fieldName + "_" + Attachments.STATUS)); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java index a94160cc5..65f7f8999 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java @@ -94,14 +94,25 @@ void processBeforeDraftPatch(DraftPatchEventContext context, List values, Map keys, CdsEntity draftEntity) { + Object contentId = values.get(Attachments.CONTENT_ID); + if (contentId == null) { + return; + } + + Map updateData = new HashMap<>(); + Object mimeType = values.get(MediaData.MIME_TYPE); + Object fileName = values.get(MediaData.FILE_NAME); + if (mimeType != null) { + updateData.put(MediaData.MIME_TYPE, mimeType); + } + if (fileName != null) { + updateData.put(MediaData.FILE_NAME, fileName); + } + if (updateData.isEmpty()) { + return; + } + + // Use entity keys directly to identify the record (including IsActiveEntity for draft) + if (keys.isEmpty()) { + return; + } + + CqnUpdate update = Update.entity(draftEntity).data(updateData).matching(keys); + persistence.run(update); + } + private void persistInlineAttachmentMetadata(CdsEntity target, List data) { List inlinePrefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(target); if (inlinePrefixes.isEmpty()) { diff --git a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds index 60a4209c0..713ec7c05 100644 --- a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds +++ b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds @@ -8,8 +8,8 @@ annotate sap.attachments.MediaData with @UI.MediaResource: {Stream: content} { content @( title : '{i18n>attachment_content}', Core.ContentDisposition.Type : 'inline', - Core.MediaType : 'application/octet-stream', - odata.draft.skip + Core.MediaType : (mimeType), + Core.ContentDisposition.Filename: (fileName), ); mimeType @( title: '{i18n>attachment_mimeType}', @@ -17,10 +17,13 @@ annotate sap.attachments.MediaData with @UI.MediaResource: {Stream: content} { ); fileName @( title: '{i18n>attachment_fileName}', - UI.MultiLineText, - readonly + UI.MultiLineText ); status @(title: '{i18n>attachment_status}', readonly); + note @( + title: '{i18n>attachment_note}', + UI.MultiLineText + ); contentId @(UI.Hidden: true, readonly); scannedAt @(UI.Hidden: true, readonly); } @@ -54,13 +57,10 @@ annotate sap.attachments.Attachments with ); mimeType @Core.IsMediaType; status @(Common.Text : statusNav.name, Common.TextArrangement : #TextOnly); - note @( - title: '{i18n>attachment_note}', - UI.MultiLineText - ); modifiedAt @(odata.etag); } annotate sap.attachments.Attachment with { - content @Core.ContentDisposition.Filename: fileName; + content @Core.ContentDisposition.Filename: (fileName); } + diff --git a/integration-tests/db/data-model.cds b/integration-tests/db/data-model.cds index b0cc7aa76..52378becb 100644 --- a/integration-tests/db/data-model.cds +++ b/integration-tests/db/data-model.cds @@ -2,6 +2,7 @@ namespace test.data.model; using {cuid} from '@sap/cds/common'; using {sap.attachments.Attachments} from 'com.sap.cds/cds-feature-attachments'; +using {Attachment} from 'com.sap.cds/cds-feature-attachments'; entity AttachmentEntity : Attachments { parentKey : UUID; @@ -9,6 +10,8 @@ entity AttachmentEntity : Attachments { entity Roots : cuid { title : String; + avatar : Attachment; + coverImage : Attachment; attachments : Composition of many AttachmentEntity on attachments.parentKey = $self.ID; items : Composition of many Items @@ -28,6 +31,7 @@ entity Contributors : cuid { entity Items : cuid { parentID : UUID; title : String; + icon : Attachment; events : Association to many Events on events.itemId = $self.ID; attachments : Composition of many Attachments; diff --git a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java new file mode 100644 index 000000000..5d29fd503 --- /dev/null +++ b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java @@ -0,0 +1,830 @@ +/* + * © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.integrationtests.nondraftservice; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testservice.Items; +import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testservice.Roots; +import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testservice.Roots_; +import com.sap.cds.feature.attachments.integrationtests.common.MockHttpRequestHelper; +import com.sap.cds.feature.attachments.integrationtests.common.TableDataDeleter; +import com.sap.cds.feature.attachments.integrationtests.constants.Profiles; +import com.sap.cds.feature.attachments.integrationtests.testhandler.TestPluginAttachmentsServiceHandler; +import com.sap.cds.feature.attachments.service.AttachmentService; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentMarkAsDeletedEventContext; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentReadEventContext; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.StructuredType; +import com.sap.cds.services.persistence.PersistenceService; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.concurrent.TimeUnit; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; + +/** + * Integration tests for single (inline) attachments functionality. + * + *

Tests the Attachment type which flattens attachment fields directly onto an entity, as opposed + * to the composition-based Attachments aspect. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles(Profiles.TEST_HANDLER_ENABLED) +class SingleAttachmentNonDraftTest { + + @Autowired private TestPluginAttachmentsServiceHandler serviceHandler; + @Autowired private MockHttpRequestHelper requestHelper; + @Autowired private PersistenceService persistenceService; + @Autowired private TableDataDeleter dataDeleter; + + @AfterEach + void teardown() { + dataDeleter.deleteData(Roots_.CDS_NAME); + serviceHandler.clearEventContext(); + serviceHandler.clearDocuments(); + requestHelper.resetHelper(); + } + + @Test + void createRootWithoutInlineAttachmentWorks() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + + var selectedRoot = selectStoredRoot(); + assertThat(selectedRoot.getId()).isNotEmpty(); + assertThat(selectedRoot.getTitle()).isEqualTo(root.getTitle()); + assertThat(selectedRoot.getAvatarContent()).isNull(); + assertThat(selectedRoot.getAvatarContentId()).isNull(); + assertThat(selectedRoot.getAvatarFileName()).isNull(); + verifyNoAttachmentEventsCalled(); + } + + @Test + void putContentToInlineAttachmentOnRootWorks() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var content = putInlineAttachmentContentOnRoot(selectedRoot.getId()); + var rootAfterPut = selectStoredRoot(); + + assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty(); + assertThat(rootAfterPut.getAvatarStatus()).isNotEmpty(); + verifySingleCreateEvent(rootAfterPut.getAvatarContentId(), content); + } + + @Test + void readInlineAttachmentContentOnRootReturnsContent() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var content = putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + + var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + var response = requestHelper.executeGet(url); + + assertThat(response.getResponse().getContentAsString()).isEqualTo(content); + verifySingleReadEvent(rootAfterPut.getAvatarContentId()); + } + + @Test + void readInlineAttachmentContentReturnsCorrectContentTypeHeader() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var content = putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + + var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + var response = requestHelper.executeGet(url); + + // Verify content is returned correctly + assertThat(response.getResponse().getContentAsString()).isEqualTo(content); + + // Verify Content-Type header matches the mimeType field (application/octet-stream by default) + assertThat(response.getResponse().getContentType()).isEqualTo("application/octet-stream"); + } + + @Test + void readInlineAttachmentContentReturnsContentDispositionHeader() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + + var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + var response = requestHelper.executeGet(url); + + // Verify Content-Disposition header is set (inline disposition type from @Core.ContentDisposition.Type) + var contentDisposition = response.getResponse().getHeader("Content-Disposition"); + assertThat(contentDisposition).isNotNull(); + assertThat(contentDisposition).startsWith("inline"); + } + + @Test + void readInlineAttachmentContentReturnsFilenameInContentDisposition() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + + // Set the fileName via PATCH + var patchUrl = buildRootUrl(rootAfterPut.getId()); + requestHelper.executePatchWithODataResponseAndAssertStatusOk( + patchUrl, "{\"avatar_fileName\": \"test-file.bin\"}"); + + var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + var response = requestHelper.executeGet(url); + + // Verify Content-Disposition header includes the filename + var contentDisposition = response.getResponse().getHeader("Content-Disposition"); + assertThat(contentDisposition).isNotNull(); + assertThat(contentDisposition).contains("filename=\"test-file.bin\""); + } + + @Test + void readInlineAttachmentContentWithCustomMimeTypeReturnsCorrectContentType() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + // Upload content with a specific content type + var url = buildRootUrl(selectedRoot.getId()) + "/avatar_content"; + requestHelper.setContentType(MediaType.IMAGE_PNG); + requestHelper.executePutWithMatcher( + url, "fake-image-content".getBytes(StandardCharsets.UTF_8), status().isNoContent()); + requestHelper.resetHelper(); + + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + + // Now read the content and verify Content-Type matches what was uploaded + var readUrl = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + var response = requestHelper.executeGet(readUrl); + + // The Content-Type should reflect the mimeType stored in the database + assertThat(response.getResponse().getContentType()).isEqualTo("image/png"); + } + + @Test + void selectInlineAttachmentIncludesMediaContentTypeAnnotation() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + // Upload content with a specific content type + var url = buildRootUrl(selectedRoot.getId()) + "/avatar_content"; + requestHelper.setContentType(MediaType.TEXT_PLAIN); + requestHelper.executePutWithMatcher( + url, "test-content".getBytes(StandardCharsets.UTF_8), status().isNoContent()); + requestHelper.resetHelper(); + + serviceHandler.clearEventContext(); + + // Query the entity with $select including the content field + var selectUrl = + MockHttpRequestHelper.ODATA_BASE_URL + + "TestService/Roots(" + + selectedRoot.getId() + + ")?$select=avatar_content,avatar_mimeType"; + var response = requestHelper.executeGetWithSingleODataResponseAndAssertStatus(selectUrl, HttpStatus.OK); + + // The response should include the @mediaContentType annotation with the correct MIME type + // This validates that the Core.MediaType annotation path is correctly resolved for inline attachments + assertThat(response).contains("avatar_content@mediaContentType"); + assertThat(response).contains("text/plain"); + } + + @Test + void deleteInlineAttachmentContentOnRootClearsContent() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + var contentIdBeforeDelete = rootAfterPut.getAvatarContentId(); + + var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + requestHelper.executeDelete(url); + + var rootAfterDelete = selectStoredRoot(); + assertThat(rootAfterDelete.getAvatarContentId()).isNull(); + assertThat(rootAfterDelete.getAvatarContent()).isNull(); + verifySingleDeletionEvent(contentIdBeforeDelete); + } + + @Test + void updateInlineAttachmentContentOnRootWorks() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterFirstPut = selectStoredRoot(); + var firstContentId = rootAfterFirstPut.getAvatarContentId(); + + var newContent = putInlineAttachmentContentOnRoot(rootAfterFirstPut.getId(), "newContent"); + var rootAfterSecondPut = selectStoredRoot(); + + assertThat(rootAfterSecondPut.getAvatarContentId()).isNotEmpty(); + assertThat(rootAfterSecondPut.getAvatarContentId()).isNotEqualTo(firstContentId); + verifySingleCreateAndDeleteEvent( + rootAfterSecondPut.getAvatarContentId(), firstContentId, newContent); + } + + @Test + void deleteRootDeletesInlineAttachmentContent() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + var contentId = rootAfterPut.getAvatarContentId(); + + var url = buildRootUrl(rootAfterPut.getId()); + requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); + + waitTillExpectedHandlerMessageSize(1); + verifySingleDeletionEvent(contentId); + } + + @Test + void inlineAttachmentReadViaExpandHasNoFilledContent() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + + var url = MockHttpRequestHelper.ODATA_BASE_URL + "TestService/Roots?$select=ID,avatar_content"; + var response = + requestHelper.executeGetWithSingleODataResponseAndAssertStatus( + url, Roots.class, HttpStatus.OK); + + // In expand/collection reads, the content should not be filled + assertThat(response.getAvatarContent()).isNull(); + verifyNoAttachmentEventsCalled(); + } + + // Tests for inline attachment on composition child (Items.icon) + + @Test + void createRootWithItemWithoutInlineAttachmentWorks() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + + var selectedRoot = selectStoredRootWithItems(); + assertThat(selectedRoot.getItems()).hasSize(1); + var item = selectedRoot.getItems().get(0); + assertThat(item.getIconContent()).isNull(); + assertThat(item.getIconContentId()).isNull(); + verifyNoAttachmentEventsCalled(); + } + + @Test + void putContentToInlineAttachmentOnItemWorks() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + var content = putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + var rootAfterPut = selectStoredRootWithItems(); + var itemAfterPut = rootAfterPut.getItems().get(0); + + assertThat(itemAfterPut.getIconContentId()).isNotEmpty(); + assertThat(itemAfterPut.getIconStatus()).isNotEmpty(); + verifySingleCreateEvent(itemAfterPut.getIconContentId(), content); + } + + @Test + void readInlineAttachmentContentOnItemReturnsContent() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + var content = putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRootWithItems(); + var itemAfterPut = rootAfterPut.getItems().get(0); + + var url = buildItemUrl(selectedRoot.getId(), item.getId()) + "/icon_content"; + var response = requestHelper.executeGet(url); + + assertThat(response.getResponse().getContentAsString()).isEqualTo(content); + verifySingleReadEvent(itemAfterPut.getIconContentId()); + } + + @Test + void deleteInlineAttachmentContentOnItemClearsContent() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRootWithItems(); + var itemAfterPut = rootAfterPut.getItems().get(0); + var contentIdBeforeDelete = itemAfterPut.getIconContentId(); + + var url = buildItemUrl(selectedRoot.getId(), item.getId()) + "/icon_content"; + requestHelper.executeDelete(url); + + var rootAfterDelete = selectStoredRootWithItems(); + var itemAfterDelete = rootAfterDelete.getItems().get(0); + assertThat(itemAfterDelete.getIconContentId()).isNull(); + assertThat(itemAfterDelete.getIconContent()).isNull(); + verifySingleDeletionEvent(contentIdBeforeDelete); + } + + @Test + void updateInlineAttachmentContentOnItemWorks() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + serviceHandler.clearEventContext(); + var rootAfterFirstPut = selectStoredRootWithItems(); + var itemAfterFirstPut = rootAfterFirstPut.getItems().get(0); + var firstContentId = itemAfterFirstPut.getIconContentId(); + + var newContent = + putInlineAttachmentContentOnItem( + rootAfterFirstPut.getId(), itemAfterFirstPut.getId(), "newContent"); + var rootAfterSecondPut = selectStoredRootWithItems(); + var itemAfterSecondPut = rootAfterSecondPut.getItems().get(0); + + assertThat(itemAfterSecondPut.getIconContentId()).isNotEmpty(); + assertThat(itemAfterSecondPut.getIconContentId()).isNotEqualTo(firstContentId); + verifySingleCreateAndDeleteEvent( + itemAfterSecondPut.getIconContentId(), firstContentId, newContent); + } + + @Test + void deleteItemDeletesInlineAttachmentContent() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRootWithItems(); + var itemAfterPut = rootAfterPut.getItems().get(0); + var contentId = itemAfterPut.getIconContentId(); + + var url = buildItemUrl(selectedRoot.getId(), item.getId()); + requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); + + waitTillExpectedHandlerMessageSize(1); + verifySingleDeletionEvent(contentId); + } + + @Test + @Disabled( + "Known issue: Deleting root with inline attachment on item does not trigger expected deletion event") + void deleteRootDeletesInlineAttachmentOnItemContent() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRootWithItems(); + var itemAfterPut = rootAfterPut.getItems().get(0); + var contentId = itemAfterPut.getIconContentId(); + + var url = buildRootUrl(rootAfterPut.getId()); + requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); + + waitTillExpectedHandlerMessageSize(1); + verifySingleDeletionEvent(contentId); + } + + @Test + @Disabled( + "Known issue: Delete of root with multiple inline attachments not triggering expected events") + void deleteRootDeletesBothRootAndItemInlineAttachments() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRootWithItems(); + var itemAfterPut = rootAfterPut.getItems().get(0); + var rootContentId = rootAfterPut.getAvatarContentId(); + var itemContentId = itemAfterPut.getIconContentId(); + + var url = buildRootUrl(rootAfterPut.getId()); + requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); + + waitTillExpectedHandlerMessageSize(2); + verifyTwoDeletionEvents(rootContentId, itemContentId); + } + + // Tests for multiple inline attachments on the same entity (no data collision) + + @Test + void twoInlineAttachmentsOnSameEntityDoNotCollide() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData"); + serviceHandler.clearEventContext(); + var coverImageContent = putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData"); + + var rootAfterPut = selectStoredRoot(); + + // Both attachments should have different content IDs + assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty(); + assertThat(rootAfterPut.getCoverImageContentId()).isNotEmpty(); + assertThat(rootAfterPut.getAvatarContentId()) + .isNotEqualTo(rootAfterPut.getCoverImageContentId()); + + // Verify coverImage create event was triggered + verifySingleCreateEvent(rootAfterPut.getCoverImageContentId(), coverImageContent); + } + + @Test + void readingOneInlineAttachmentDoesNotAffectOther() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var avatarContent = putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData"); + var coverImageContent = putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData"); + serviceHandler.clearEventContext(); + + var rootAfterPut = selectStoredRoot(); + + // Read avatar content + var avatarUrl = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + var avatarResponse = requestHelper.executeGet(avatarUrl); + assertThat(avatarResponse.getResponse().getContentAsString()).isEqualTo(avatarContent); + + serviceHandler.clearEventContext(); + + // Read coverImage content - should be independent + var coverImageUrl = buildRootUrl(rootAfterPut.getId()) + "/coverImage_content"; + var coverImageResponse = requestHelper.executeGet(coverImageUrl); + assertThat(coverImageResponse.getResponse().getContentAsString()).isEqualTo(coverImageContent); + + verifySingleReadEvent(rootAfterPut.getCoverImageContentId()); + } + + @Test + void deletingOneInlineAttachmentDoesNotAffectOther() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData"); + putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData"); + serviceHandler.clearEventContext(); + + var rootAfterPut = selectStoredRoot(); + var avatarContentId = rootAfterPut.getAvatarContentId(); + var coverImageContentId = rootAfterPut.getCoverImageContentId(); + + // Delete only avatar content + var avatarUrl = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + requestHelper.executeDelete(avatarUrl); + + var rootAfterDelete = selectStoredRoot(); + + // Avatar should be cleared + assertThat(rootAfterDelete.getAvatarContentId()).isNull(); + assertThat(rootAfterDelete.getAvatarContent()).isNull(); + + // CoverImage should still exist + assertThat(rootAfterDelete.getCoverImageContentId()).isEqualTo(coverImageContentId); + + verifySingleDeletionEvent(avatarContentId); + } + + @Test + void updatingOneInlineAttachmentDoesNotAffectOther() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData"); + putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData"); + serviceHandler.clearEventContext(); + + var rootAfterFirstPut = selectStoredRoot(); + var originalAvatarContentId = rootAfterFirstPut.getAvatarContentId(); + var originalCoverImageContentId = rootAfterFirstPut.getCoverImageContentId(); + + // Update only avatar + var newAvatarContent = + putInlineAttachmentContentOnRoot(rootAfterFirstPut.getId(), "newAvatarData"); + + var rootAfterUpdate = selectStoredRoot(); + + // Avatar should have new content ID + assertThat(rootAfterUpdate.getAvatarContentId()).isNotEmpty(); + assertThat(rootAfterUpdate.getAvatarContentId()).isNotEqualTo(originalAvatarContentId); + + // CoverImage should be unchanged + assertThat(rootAfterUpdate.getCoverImageContentId()).isEqualTo(originalCoverImageContentId); + + verifySingleCreateAndDeleteEvent( + rootAfterUpdate.getAvatarContentId(), originalAvatarContentId, newAvatarContent); + } + + @Test + void deleteRootDeletesBothInlineAttachments() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData"); + putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData"); + serviceHandler.clearEventContext(); + + var rootAfterPut = selectStoredRoot(); + var avatarContentId = rootAfterPut.getAvatarContentId(); + var coverImageContentId = rootAfterPut.getCoverImageContentId(); + + var url = buildRootUrl(rootAfterPut.getId()); + requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); + + waitTillExpectedHandlerMessageSize(2); + verifyTwoDeletionEvents(avatarContentId, coverImageContentId); + } + + @Test + void bothInlineAttachmentsCanBeCreatedAndReadIndependently() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + // Create both attachments + var avatarContent = putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData123"); + var coverImageContent = putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData456"); + serviceHandler.clearEventContext(); + + var rootAfterPut = selectStoredRoot(); + + // Verify both exist with different content IDs + assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty(); + assertThat(rootAfterPut.getCoverImageContentId()).isNotEmpty(); + assertThat(rootAfterPut.getAvatarContentId()) + .isNotEqualTo(rootAfterPut.getCoverImageContentId()); + + // Read and verify avatar content + var avatarUrl = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + var avatarResponse = requestHelper.executeGet(avatarUrl); + assertThat(avatarResponse.getResponse().getContentAsString()).isEqualTo(avatarContent); + + serviceHandler.clearEventContext(); + + // Read and verify coverImage content + var coverImageUrl = buildRootUrl(rootAfterPut.getId()) + "/coverImage_content"; + var coverImageResponse = requestHelper.executeGet(coverImageUrl); + assertThat(coverImageResponse.getResponse().getContentAsString()).isEqualTo(coverImageContent); + } + + // Helper methods + + private Roots buildRootWithoutContent() { + var root = Roots.create(); + root.setTitle("root with inline attachment"); + return root; + } + + private Roots buildRootWithItem() { + var root = Roots.create(); + root.setTitle("root with item"); + var items = new ArrayList(); + var item = Items.create(); + item.setTitle("item with inline attachment"); + items.add(item); + root.setItems(items); + return root; + } + + private void postServiceRoot(Roots root) throws Exception { + var url = MockHttpRequestHelper.ODATA_BASE_URL + "TestService/Roots"; + requestHelper.executePostWithMatcher(url, root.toJson(), status().isCreated()); + } + + private Roots selectStoredRoot() { + var select = Select.from(Roots_.class).columns(StructuredType::_all); + return persistenceService.run(select).single(Roots.class); + } + + private Roots selectStoredRootWithItems() { + var select = + Select.from(Roots_.class) + .columns(StructuredType::_all, root -> root.items().expand(StructuredType::_all)); + return persistenceService.run(select).single(Roots.class); + } + + private String buildRootUrl(String rootId) { + return MockHttpRequestHelper.ODATA_BASE_URL + "TestService/Roots(" + rootId + ")"; + } + + private String buildItemUrl(String rootId, String itemId) { + return MockHttpRequestHelper.ODATA_BASE_URL + + "TestService/Roots(" + + rootId + + ")/items(" + + itemId + + ")"; + } + + private String putInlineAttachmentContentOnRoot(String rootId) throws Exception { + return putInlineAttachmentContentOnRoot(rootId, "avatarContent"); + } + + private String putInlineAttachmentContentOnRoot(String rootId, String content) throws Exception { + var url = buildRootUrl(rootId) + "/avatar_content"; + requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher( + url, content.getBytes(StandardCharsets.UTF_8), status().isNoContent()); + return content; + } + + private String putCoverImageContentOnRoot(String rootId, String content) throws Exception { + var url = buildRootUrl(rootId) + "/coverImage_content"; + requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher( + url, content.getBytes(StandardCharsets.UTF_8), status().isNoContent()); + return content; + } + + private String putInlineAttachmentContentOnItem(String rootId, String itemId) throws Exception { + return putInlineAttachmentContentOnItem(rootId, itemId, "iconContent"); + } + + private String putInlineAttachmentContentOnItem(String rootId, String itemId, String content) + throws Exception { + var url = buildItemUrl(rootId, itemId) + "/icon_content"; + requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher( + url, content.getBytes(StandardCharsets.UTF_8), status().isNoContent()); + return content; + } + + private void verifySingleCreateEvent(String contentId, String content) { + verifyEventContextEmptyForEvent( + AttachmentService.EVENT_READ_ATTACHMENT, + AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + var createEvent = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); + assertThat(createEvent) + .hasSize(1) + .first() + .satisfies( + event -> { + assertThat(event.context()).isInstanceOf(AttachmentCreateEventContext.class); + var createContext = (AttachmentCreateEventContext) event.context(); + assertThat(createContext.getContentId()).isEqualTo(contentId); + assertThat(createContext.getData().getContent().readAllBytes()) + .isEqualTo(content.getBytes(StandardCharsets.UTF_8)); + }); + } + + private void verifySingleReadEvent(String contentId) { + verifyEventContextEmptyForEvent( + AttachmentService.EVENT_CREATE_ATTACHMENT, + AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + var readContext = serviceHandler.getEventContext(); + assertThat(readContext) + .hasSize(1) + .first() + .satisfies( + event -> { + assertThat(event.event()).isEqualTo(AttachmentService.EVENT_READ_ATTACHMENT); + assertThat(((AttachmentReadEventContext) event.context()).getContentId()) + .isEqualTo(contentId); + }); + } + + private void verifySingleDeletionEvent(String contentId) { + waitTillExpectedHandlerMessageSize(1); + verifyEventContextEmptyForEvent( + AttachmentService.EVENT_CREATE_ATTACHMENT, AttachmentService.EVENT_READ_ATTACHMENT); + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + assertThat(deleteEvents) + .hasSize(1) + .first() + .satisfies( + event -> { + assertThat(event.context()).isInstanceOf(AttachmentMarkAsDeletedEventContext.class); + var deleteContext = (AttachmentMarkAsDeletedEventContext) event.context(); + assertThat(deleteContext.getContentId()).isEqualTo(contentId); + }); + } + + private void verifySingleCreateAndDeleteEvent( + String newContentId, String deletedContentId, String content) { + waitTillExpectedHandlerMessageSize(2); + verifyEventContextEmptyForEvent(AttachmentService.EVENT_READ_ATTACHMENT); + var createEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); + assertThat(createEvents).hasSize(1); + assertThat(createEvents) + .first() + .satisfies( + event -> { + var createContext = (AttachmentCreateEventContext) event.context(); + assertThat(createContext.getContentId()).isEqualTo(newContentId); + assertThat(createContext.getData().getContent().readAllBytes()) + .isEqualTo(content.getBytes(StandardCharsets.UTF_8)); + }); + + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + assertThat(deleteEvents) + .hasSize(1) + .first() + .satisfies( + event -> { + var deleteContext = (AttachmentMarkAsDeletedEventContext) event.context(); + assertThat(deleteContext.getContentId()).isEqualTo(deletedContentId); + }); + } + + private void verifyTwoDeletionEvents(String contentId1, String contentId2) { + verifyEventContextEmptyForEvent( + AttachmentService.EVENT_CREATE_ATTACHMENT, AttachmentService.EVENT_READ_ATTACHMENT); + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + assertThat(deleteEvents).hasSize(2); + assertThat( + deleteEvents.stream() + .anyMatch( + event -> + ((AttachmentMarkAsDeletedEventContext) event.context()) + .getContentId() + .equals(contentId1))) + .isTrue(); + assertThat( + deleteEvents.stream() + .anyMatch( + event -> + ((AttachmentMarkAsDeletedEventContext) event.context()) + .getContentId() + .equals(contentId2))) + .isTrue(); + } + + private void verifyNoAttachmentEventsCalled() { + assertThat(serviceHandler.getEventContext()).isEmpty(); + } + + private void verifyEventContextEmptyForEvent(String... events) { + for (var event : events) { + assertThat(serviceHandler.getEventContextForEvent(event)).isEmpty(); + } + } + + private void waitTillExpectedHandlerMessageSize(int expectedSize) { + Awaitility.await() + .atMost(30, TimeUnit.SECONDS) + .pollDelay(1, TimeUnit.SECONDS) + .until(() -> serviceHandler.getEventContext().size() >= expectedSize); + } +} diff --git a/pom.xml b/pom.xml index 20bf29c43..a3dade4e2 100644 --- a/pom.xml +++ b/pom.xml @@ -70,7 +70,7 @@ - 4.6.1 + 4.8.1 9.6.1 diff --git a/samples/bookshop/app/admin-books/fiori-service.cds b/samples/bookshop/app/admin-books/fiori-service.cds index 4b3fcbab8..e4dac8980 100644 --- a/samples/bookshop/app/admin-books/fiori-service.cds +++ b/samples/bookshop/app/admin-books/fiori-service.cds @@ -66,7 +66,7 @@ annotate AdminService.Books with @(UI: { {Value: modifiedAt} ]}, FieldGroup #ProfileIcon: {Data: [ - {Value: profileIcon_content}, + {Value: profileIcon_content, Label: 'Download'}, {Value: profileIcon_fileName}, {Value: profileIcon_status}, {Value: profileIcon_note} diff --git a/samples/bookshop/pom.xml b/samples/bookshop/pom.xml index a49e3c384..eb4139f75 100644 --- a/samples/bookshop/pom.xml +++ b/samples/bookshop/pom.xml @@ -14,7 +14,7 @@ 17 - 4.6.1 + 4.8.1 3.5.7 UTF-8 @@ -48,7 +48,7 @@ com.sap.cds cds-feature-attachments - 1.5.0 + 1.6.0-SNAPSHOT From 1d7c89c6114f0c3dc32c12e607873c90fe0b74a0 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 20 Apr 2026 11:46:09 +0200 Subject: [PATCH 20/45] add tests --- .../ModifyApplicationHandlerHelper.java | 2 +- .../DeleteAttachmentsHandlerTest.java | 32 ++++ .../DraftPatchAttachmentsHandlerTest.java | 151 ++++++++++++++++++ .../SingleAttachmentNonDraftTest.java | 19 ++- 4 files changed, 197 insertions(+), 7 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java index 1ff996a05..0cd29f65d 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java @@ -103,7 +103,7 @@ public static InputStream handleAttachmentForEntity( } else { contentId = (String) path.target().values().get(Attachments.CONTENT_ID); } - + String contentLength = eventContext.getParameterInfo().getHeader("Content-Length"); String maxSizeStr = getValMaxValue(path.target().entity(), defaultMaxSize); eventContext.put( diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java index 9d0020ac0..eacba6977 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java @@ -120,4 +120,36 @@ private Attachment buildAttachment(String id, InputStream inputStream) { attachment.setContent(inputStream); return attachment; } + + @Test + void inlineAttachmentDeleteExtractsContentIdFromFlattenedFields() { + // RootTable has inline attachment profilePicture : AttachmentType + // When deleting RootTable, the MEDIA_CONTENT_FILTER triggers for profilePicture_content + // and the handler should extract the contentId from the flattened field + // profilePicture_contentId + var rootEntity = runtime.getCdsModel().findEntity(Roots_.CDS_NAME).orElseThrow(); + when(context.getTarget()).thenReturn(rootEntity); + when(context.getModel()).thenReturn(runtime.getCdsModel()); + + var inputStream = mock(InputStream.class); + + // Build data with flattened inline attachment fields (as they appear in DB) + var root = Roots.create(); + root.setId(UUID.randomUUID().toString()); + root.put("profilePicture_content", inputStream); + root.put("profilePicture_contentId", "inline-cid-123"); + root.put("profilePicture_mimeType", "image/png"); + root.put("profilePicture_fileName", "avatar.png"); + + when(attachmentsReader.readAttachments( + context.getModel(), context.getTarget(), context.getCqn())) + .thenReturn(List.of(Attachments.of(root))); + + cut.processBefore(context); + + // Verify the modifyAttachmentEvent receives an Attachments object with the extracted + // (unprefixed) contentId from the inline attachment + verify(modifyAttachmentEvent) + .processEvent(any(Path.class), eq(inputStream), any(Attachments.class), eq(context), any()); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java index 7771b8429..39f37599f 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java @@ -389,4 +389,155 @@ void inlinePatchPersistsOnlyFileName() { assertThat(update.entries().get(0)).containsEntry("profilePicture_fileName", "document.pdf"); assertThat(update.entries().get(0)).doesNotContainKey("profilePicture_mimeType"); } + + // --- Composition-based Attachment Metadata Tests (persistCompositionAttachmentMetadata) --- + + @Test + void compositionAttachmentPersistsMetadataWhenMimeTypeAndFileNamePresent() { + // Target the attachment entity directly to trigger persistCompositionAttachmentMetadata + var draftAttachmentName = Attachment_.CDS_NAME + DraftUtils.DRAFT_TABLE_POSTFIX; + getEntityAndMockContext(draftAttachmentName); + + var attachment = Attachments.create(); + attachment.setId(UUID.randomUUID().toString()); + attachment.setContent(mock(InputStream.class)); + + var result = mock(Result.class); + when(persistence.run(any(CqnSelect.class))).thenReturn(result); + when(result.listOf(Attachments.class)).thenReturn(List.of(attachment)); + + // Simulate event.processEvent setting contentId, mimeType, and fileName in + // path.target().values() + when(event.processEvent(any(), any(), any(), any(), any())) + .thenAnswer( + invocation -> { + attachment.setContentId("cid-composition"); + attachment.setMimeType("application/pdf"); + attachment.setFileName("report.pdf"); + return null; + }); + + cut.processBeforeDraftPatch(eventContext, List.of(attachment)); + + // Verify update was called with mimeType and fileName + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); + verify(persistence).run(updateCaptor.capture()); + CqnUpdate update = updateCaptor.getValue(); + assertThat(update.entries()).isNotEmpty(); + assertThat(update.entries().get(0)).containsEntry("mimeType", "application/pdf"); + assertThat(update.entries().get(0)).containsEntry("fileName", "report.pdf"); + } + + @Test + void compositionAttachmentPersistsOnlyMimeType() { + var draftAttachmentName = Attachment_.CDS_NAME + DraftUtils.DRAFT_TABLE_POSTFIX; + getEntityAndMockContext(draftAttachmentName); + + var attachment = Attachments.create(); + attachment.setId(UUID.randomUUID().toString()); + attachment.setContent(mock(InputStream.class)); + + var result = mock(Result.class); + when(persistence.run(any(CqnSelect.class))).thenReturn(result); + when(result.listOf(Attachments.class)).thenReturn(List.of(attachment)); + + when(event.processEvent(any(), any(), any(), any(), any())) + .thenAnswer( + invocation -> { + attachment.setContentId("cid-mime-only"); + attachment.setMimeType("text/plain"); + // fileName not set + return null; + }); + + cut.processBeforeDraftPatch(eventContext, List.of(attachment)); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); + verify(persistence).run(updateCaptor.capture()); + CqnUpdate update = updateCaptor.getValue(); + assertThat(update.entries().get(0)).containsEntry("mimeType", "text/plain"); + assertThat(update.entries().get(0)).doesNotContainKey("fileName"); + } + + @Test + void compositionAttachmentPersistsOnlyFileName() { + var draftAttachmentName = Attachment_.CDS_NAME + DraftUtils.DRAFT_TABLE_POSTFIX; + getEntityAndMockContext(draftAttachmentName); + + var attachment = Attachments.create(); + attachment.setId(UUID.randomUUID().toString()); + attachment.setContent(mock(InputStream.class)); + + var result = mock(Result.class); + when(persistence.run(any(CqnSelect.class))).thenReturn(result); + when(result.listOf(Attachments.class)).thenReturn(List.of(attachment)); + + when(event.processEvent(any(), any(), any(), any(), any())) + .thenAnswer( + invocation -> { + attachment.setContentId("cid-filename-only"); + attachment.setFileName("data.csv"); + // mimeType not set + return null; + }); + + cut.processBeforeDraftPatch(eventContext, List.of(attachment)); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); + verify(persistence).run(updateCaptor.capture()); + CqnUpdate update = updateCaptor.getValue(); + assertThat(update.entries().get(0)).containsEntry("fileName", "data.csv"); + assertThat(update.entries().get(0)).doesNotContainKey("mimeType"); + } + + @Test + void compositionAttachmentSkipsUpdateWhenContentIdNull() { + var draftAttachmentName = Attachment_.CDS_NAME + DraftUtils.DRAFT_TABLE_POSTFIX; + getEntityAndMockContext(draftAttachmentName); + + var attachment = Attachments.create(); + attachment.setId(UUID.randomUUID().toString()); + attachment.setContent(mock(InputStream.class)); + + var result = mock(Result.class); + when(persistence.run(any(CqnSelect.class))).thenReturn(result); + when(result.listOf(Attachments.class)).thenReturn(List.of(attachment)); + + // processEvent does NOT set contentId + when(event.processEvent(any(), any(), any(), any(), any())).thenReturn(null); + + cut.processBeforeDraftPatch(eventContext, List.of(attachment)); + + // Should only call persistence.run once for the SELECT, not for UPDATE + verify(persistence).run(any(CqnSelect.class)); + verify(persistence, never()).run(any(CqnUpdate.class)); + } + + @Test + void compositionAttachmentSkipsUpdateWhenNoMetadata() { + var draftAttachmentName = Attachment_.CDS_NAME + DraftUtils.DRAFT_TABLE_POSTFIX; + getEntityAndMockContext(draftAttachmentName); + + var attachment = Attachments.create(); + attachment.setId(UUID.randomUUID().toString()); + attachment.setContent(mock(InputStream.class)); + + var result = mock(Result.class); + when(persistence.run(any(CqnSelect.class))).thenReturn(result); + when(result.listOf(Attachments.class)).thenReturn(List.of(attachment)); + + // processEvent sets contentId but no mimeType/fileName + when(event.processEvent(any(), any(), any(), any(), any())) + .thenAnswer( + invocation -> { + attachment.setContentId("cid-no-metadata"); + return null; + }); + + cut.processBeforeDraftPatch(eventContext, List.of(attachment)); + + // Should only call persistence.run once for the SELECT, not for UPDATE + verify(persistence).run(any(CqnSelect.class)); + verify(persistence, never()).run(any(CqnUpdate.class)); + } } diff --git a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java index 5d29fd503..1ab89b259 100644 --- a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java +++ b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java @@ -120,7 +120,8 @@ void readInlineAttachmentContentReturnsCorrectContentTypeHeader() throws Excepti assertThat(response.getResponse().getContentAsString()).isEqualTo(content); // Verify Content-Type header matches the mimeType field (application/octet-stream by default) - assertThat(response.getResponse().getContentType()).isEqualTo("application/octet-stream"); + // Note: OData adapter may append charset, so we use startsWith for robustness + assertThat(response.getResponse().getContentType()).startsWith("application/octet-stream"); } @Test @@ -136,7 +137,8 @@ void readInlineAttachmentContentReturnsContentDispositionHeader() throws Excepti var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; var response = requestHelper.executeGet(url); - // Verify Content-Disposition header is set (inline disposition type from @Core.ContentDisposition.Type) + // Verify Content-Disposition header is set (inline disposition type from + // @Core.ContentDisposition.Type) var contentDisposition = response.getResponse().getHeader("Content-Disposition"); assertThat(contentDisposition).isNotNull(); assertThat(contentDisposition).startsWith("inline"); @@ -150,6 +152,7 @@ void readInlineAttachmentContentReturnsFilenameInContentDisposition() throws Exc putInlineAttachmentContentOnRoot(selectedRoot.getId()); serviceHandler.clearEventContext(); + requestHelper.resetHelper(); // Reset after PUT to use JSON for PATCH var rootAfterPut = selectStoredRoot(); // Set the fileName via PATCH @@ -187,7 +190,8 @@ void readInlineAttachmentContentWithCustomMimeTypeReturnsCorrectContentType() th var response = requestHelper.executeGet(readUrl); // The Content-Type should reflect the mimeType stored in the database - assertThat(response.getResponse().getContentType()).isEqualTo("image/png"); + // Note: OData adapter may append charset, so we use startsWith for robustness + assertThat(response.getResponse().getContentType()).startsWith("image/png"); } @Test @@ -211,10 +215,12 @@ void selectInlineAttachmentIncludesMediaContentTypeAnnotation() throws Exception + "TestService/Roots(" + selectedRoot.getId() + ")?$select=avatar_content,avatar_mimeType"; - var response = requestHelper.executeGetWithSingleODataResponseAndAssertStatus(selectUrl, HttpStatus.OK); + var response = + requestHelper.executeGetWithSingleODataResponseAndAssertStatus(selectUrl, HttpStatus.OK); // The response should include the @mediaContentType annotation with the correct MIME type - // This validates that the Core.MediaType annotation path is correctly resolved for inline attachments + // This validates that the Core.MediaType annotation path is correctly resolved for inline + // attachments assertThat(response).contains("avatar_content@mediaContentType"); assertThat(response).contains("text/plain"); } @@ -415,7 +421,8 @@ void deleteItemDeletesInlineAttachmentContent() throws Exception { @Test @Disabled( - "Known issue: Deleting root with inline attachment on item does not trigger expected deletion event") + "Known issue: Deleting root with inline attachment on item does not trigger expected deletion" + + " event") void deleteRootDeletesInlineAttachmentOnItemContent() throws Exception { var root = buildRootWithItem(); postServiceRoot(root); From bf81e44cdb213bfc94dabf534722a9e8a9226b91 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 20 Apr 2026 12:58:44 +0200 Subject: [PATCH 21/45] add deep deletion --- .../handler/common/AttachmentsReader.java | 51 +++++++++++++------ .../handler/common/AttachmentsReaderTest.java | 33 ++++++++++++ .../SingleAttachmentNonDraftTest.java | 6 --- 3 files changed, 69 insertions(+), 21 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java index 6153d4b2a..e6aadae9d 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java @@ -45,17 +45,10 @@ public List readAttachments( logger.debug("Start reading attachments for entity {}", entity.getQualifiedName()); NodeTree nodePath = cascader.findEntityPath(model, entity); - List> expandList = buildExpandList(nodePath); + List> expandList = buildExpandList(model, nodePath); // Also include inline attachment fields directly in the select - List inlineFields = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); - List inlineColumns = new ArrayList<>(); - for (String fieldName : inlineFields) { - // Include the content field so CdsDataProcessor's MEDIA_CONTENT_FILTER can match it - inlineColumns.add(CQL.get(fieldName + "_content")); - inlineColumns.add(CQL.get(fieldName + "_" + Attachments.CONTENT_ID)); - inlineColumns.add(CQL.get(fieldName + "_" + Attachments.STATUS)); - } + List inlineColumns = buildInlineAttachmentColumns(entity); Select select; if (!expandList.isEmpty() || !inlineColumns.isEmpty()) { @@ -73,18 +66,46 @@ public List readAttachments( return attachments; } - private List> buildExpandList(NodeTree root) { + private List buildInlineAttachmentColumns(CdsEntity entity) { + List inlineFields = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); + List columns = new ArrayList<>(); + for (String fieldName : inlineFields) { + // Include the content field so CdsDataProcessor's MEDIA_CONTENT_FILTER can match it + columns.add(CQL.get(fieldName + "_content")); + columns.add(CQL.get(fieldName + "_" + Attachments.CONTENT_ID)); + columns.add(CQL.get(fieldName + "_" + Attachments.STATUS)); + } + return columns; + } + + private List> buildExpandList(CdsModel model, NodeTree root) { List> expandResultList = new ArrayList<>(); - root.getChildren().forEach(child -> expandResultList.add(buildExpandFromTree(child))); + root.getChildren().forEach(child -> expandResultList.add(buildExpandFromTree(model, child))); return expandResultList; } - private Expand buildExpandFromTree(NodeTree node) { - return node.getChildren().isEmpty() + private Expand buildExpandFromTree(CdsModel model, NodeTree node) { + // Look up the entity for this node to check for inline attachments + CdsEntity nodeEntity = model.findEntity(node.getIdentifier().fullEntityName()).orElse(null); + + // Build inline attachment columns for this child entity if it has any + List inlineColumns = + nodeEntity != null ? buildInlineAttachmentColumns(nodeEntity) : List.of(); + + // Build child expands recursively + List childExpands = new ArrayList<>(); + for (NodeTree child : node.getChildren()) { + childExpands.add(buildExpandFromTree(model, child)); + } + + // Combine inline columns and child expands + List expandItems = new ArrayList<>(inlineColumns); + expandItems.addAll(childExpands); + + return expandItems.isEmpty() ? CQL.to(node.getIdentifier().associationName()).expand() - : CQL.to(node.getIdentifier().associationName()) - .expand(node.getChildren().stream().map(this::buildExpandFromTree).toList()); + : CQL.to(node.getIdentifier().associationName()).expand(expandItems); } private static void logResultData(CdsEntity entity, List attachments) { diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReaderTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReaderTest.java index d86cdda5d..b094e130d 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReaderTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReaderTest.java @@ -320,4 +320,37 @@ void selectIncludesInlineColumnsForEntityWithInlineAttachments() { assertThat(selectStr).contains("profilePicture_contentId"); assertThat(selectStr).contains("profilePicture_status"); } + + @Test + void selectIncludesInlineColumnsForChildEntityWithInlineAttachments() { + // Use real model to test the new functionality of including inline columns in child expands + CdsModel realModel = RuntimeHelper.runtime.getCdsModel(); + CdsEntity realRootEntity = realModel.findEntity(RootTable_.CDS_NAME).orElseThrow(); + + // Build a node tree where RootTable has items composition to Items + // Items entity has inline attachment "profilePicture" + var rootPath = new LinkedList(); + rootPath.add(new AssociationIdentifier("", RootTable_.CDS_NAME)); + rootPath.add(new AssociationIdentifier("items", Items_.CDS_NAME)); + + var nodeTree = new NodeTree(new AssociationIdentifier("", RootTable_.CDS_NAME)); + nodeTree.addPath(rootPath); + + when(cascader.findEntityPath(realModel, realRootEntity)).thenReturn(nodeTree); + List data = List.of(Attachments.create()); + when(result.listOf(Attachments.class)).thenReturn(data); + + CqnDelete delete = Delete.from(RootTable_.CDS_NAME); + cut.readAttachments(realModel, realRootEntity, delete); + + verify(persistenceService).run(selectArgumentCaptor.capture()); + var selectStr = selectArgumentCaptor.getValue().toString(); + + // The items expand should include inline attachment columns from Items entity + // Items entity has profilePicture inline attachment + assertThat(selectStr).contains("items"); + assertThat(selectStr).contains("profilePicture_content"); + assertThat(selectStr).contains("profilePicture_contentId"); + assertThat(selectStr).contains("profilePicture_status"); + } } diff --git a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java index 1ab89b259..1e3f0656b 100644 --- a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java +++ b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java @@ -25,7 +25,6 @@ import java.util.concurrent.TimeUnit; import org.awaitility.Awaitility; import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -420,9 +419,6 @@ void deleteItemDeletesInlineAttachmentContent() throws Exception { } @Test - @Disabled( - "Known issue: Deleting root with inline attachment on item does not trigger expected deletion" - + " event") void deleteRootDeletesInlineAttachmentOnItemContent() throws Exception { var root = buildRootWithItem(); postServiceRoot(root); @@ -443,8 +439,6 @@ void deleteRootDeletesInlineAttachmentOnItemContent() throws Exception { } @Test - @Disabled( - "Known issue: Delete of root with multiple inline attachments not triggering expected events") void deleteRootDeletesBothRootAndItemInlineAttachments() throws Exception { var root = buildRootWithItem(); postServiceRoot(root); From eb409cfafa5c8557080b53ed0b8313c1e9fa5428 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Tue, 21 Apr 2026 08:30:27 +0200 Subject: [PATCH 22/45] Remove integration tests (moved to feature/support-single-attachments-itests branch) --- integration-tests/db/data-model.cds | 4 - .../SingleAttachmentNonDraftTest.java | 831 ------------------ 2 files changed, 835 deletions(-) delete mode 100644 integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java diff --git a/integration-tests/db/data-model.cds b/integration-tests/db/data-model.cds index 52378becb..b0cc7aa76 100644 --- a/integration-tests/db/data-model.cds +++ b/integration-tests/db/data-model.cds @@ -2,7 +2,6 @@ namespace test.data.model; using {cuid} from '@sap/cds/common'; using {sap.attachments.Attachments} from 'com.sap.cds/cds-feature-attachments'; -using {Attachment} from 'com.sap.cds/cds-feature-attachments'; entity AttachmentEntity : Attachments { parentKey : UUID; @@ -10,8 +9,6 @@ entity AttachmentEntity : Attachments { entity Roots : cuid { title : String; - avatar : Attachment; - coverImage : Attachment; attachments : Composition of many AttachmentEntity on attachments.parentKey = $self.ID; items : Composition of many Items @@ -31,7 +28,6 @@ entity Contributors : cuid { entity Items : cuid { parentID : UUID; title : String; - icon : Attachment; events : Association to many Events on events.itemId = $self.ID; attachments : Composition of many Attachments; diff --git a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java deleted file mode 100644 index 1e3f0656b..000000000 --- a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java +++ /dev/null @@ -1,831 +0,0 @@ -/* - * © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. - */ -package com.sap.cds.feature.attachments.integrationtests.nondraftservice; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testservice.Items; -import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testservice.Roots; -import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testservice.Roots_; -import com.sap.cds.feature.attachments.integrationtests.common.MockHttpRequestHelper; -import com.sap.cds.feature.attachments.integrationtests.common.TableDataDeleter; -import com.sap.cds.feature.attachments.integrationtests.constants.Profiles; -import com.sap.cds.feature.attachments.integrationtests.testhandler.TestPluginAttachmentsServiceHandler; -import com.sap.cds.feature.attachments.service.AttachmentService; -import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; -import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentMarkAsDeletedEventContext; -import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentReadEventContext; -import com.sap.cds.ql.Select; -import com.sap.cds.ql.StructuredType; -import com.sap.cds.services.persistence.PersistenceService; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.concurrent.TimeUnit; -import org.awaitility.Awaitility; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; - -/** - * Integration tests for single (inline) attachments functionality. - * - *

Tests the Attachment type which flattens attachment fields directly onto an entity, as opposed - * to the composition-based Attachments aspect. - */ -@SpringBootTest -@AutoConfigureMockMvc -@ActiveProfiles(Profiles.TEST_HANDLER_ENABLED) -class SingleAttachmentNonDraftTest { - - @Autowired private TestPluginAttachmentsServiceHandler serviceHandler; - @Autowired private MockHttpRequestHelper requestHelper; - @Autowired private PersistenceService persistenceService; - @Autowired private TableDataDeleter dataDeleter; - - @AfterEach - void teardown() { - dataDeleter.deleteData(Roots_.CDS_NAME); - serviceHandler.clearEventContext(); - serviceHandler.clearDocuments(); - requestHelper.resetHelper(); - } - - @Test - void createRootWithoutInlineAttachmentWorks() throws Exception { - var root = buildRootWithoutContent(); - postServiceRoot(root); - - var selectedRoot = selectStoredRoot(); - assertThat(selectedRoot.getId()).isNotEmpty(); - assertThat(selectedRoot.getTitle()).isEqualTo(root.getTitle()); - assertThat(selectedRoot.getAvatarContent()).isNull(); - assertThat(selectedRoot.getAvatarContentId()).isNull(); - assertThat(selectedRoot.getAvatarFileName()).isNull(); - verifyNoAttachmentEventsCalled(); - } - - @Test - void putContentToInlineAttachmentOnRootWorks() throws Exception { - var root = buildRootWithoutContent(); - postServiceRoot(root); - var selectedRoot = selectStoredRoot(); - - var content = putInlineAttachmentContentOnRoot(selectedRoot.getId()); - var rootAfterPut = selectStoredRoot(); - - assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty(); - assertThat(rootAfterPut.getAvatarStatus()).isNotEmpty(); - verifySingleCreateEvent(rootAfterPut.getAvatarContentId(), content); - } - - @Test - void readInlineAttachmentContentOnRootReturnsContent() throws Exception { - var root = buildRootWithoutContent(); - postServiceRoot(root); - var selectedRoot = selectStoredRoot(); - - var content = putInlineAttachmentContentOnRoot(selectedRoot.getId()); - serviceHandler.clearEventContext(); - var rootAfterPut = selectStoredRoot(); - - var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; - var response = requestHelper.executeGet(url); - - assertThat(response.getResponse().getContentAsString()).isEqualTo(content); - verifySingleReadEvent(rootAfterPut.getAvatarContentId()); - } - - @Test - void readInlineAttachmentContentReturnsCorrectContentTypeHeader() throws Exception { - var root = buildRootWithoutContent(); - postServiceRoot(root); - var selectedRoot = selectStoredRoot(); - - var content = putInlineAttachmentContentOnRoot(selectedRoot.getId()); - serviceHandler.clearEventContext(); - var rootAfterPut = selectStoredRoot(); - - var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; - var response = requestHelper.executeGet(url); - - // Verify content is returned correctly - assertThat(response.getResponse().getContentAsString()).isEqualTo(content); - - // Verify Content-Type header matches the mimeType field (application/octet-stream by default) - // Note: OData adapter may append charset, so we use startsWith for robustness - assertThat(response.getResponse().getContentType()).startsWith("application/octet-stream"); - } - - @Test - void readInlineAttachmentContentReturnsContentDispositionHeader() throws Exception { - var root = buildRootWithoutContent(); - postServiceRoot(root); - var selectedRoot = selectStoredRoot(); - - putInlineAttachmentContentOnRoot(selectedRoot.getId()); - serviceHandler.clearEventContext(); - var rootAfterPut = selectStoredRoot(); - - var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; - var response = requestHelper.executeGet(url); - - // Verify Content-Disposition header is set (inline disposition type from - // @Core.ContentDisposition.Type) - var contentDisposition = response.getResponse().getHeader("Content-Disposition"); - assertThat(contentDisposition).isNotNull(); - assertThat(contentDisposition).startsWith("inline"); - } - - @Test - void readInlineAttachmentContentReturnsFilenameInContentDisposition() throws Exception { - var root = buildRootWithoutContent(); - postServiceRoot(root); - var selectedRoot = selectStoredRoot(); - - putInlineAttachmentContentOnRoot(selectedRoot.getId()); - serviceHandler.clearEventContext(); - requestHelper.resetHelper(); // Reset after PUT to use JSON for PATCH - var rootAfterPut = selectStoredRoot(); - - // Set the fileName via PATCH - var patchUrl = buildRootUrl(rootAfterPut.getId()); - requestHelper.executePatchWithODataResponseAndAssertStatusOk( - patchUrl, "{\"avatar_fileName\": \"test-file.bin\"}"); - - var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; - var response = requestHelper.executeGet(url); - - // Verify Content-Disposition header includes the filename - var contentDisposition = response.getResponse().getHeader("Content-Disposition"); - assertThat(contentDisposition).isNotNull(); - assertThat(contentDisposition).contains("filename=\"test-file.bin\""); - } - - @Test - void readInlineAttachmentContentWithCustomMimeTypeReturnsCorrectContentType() throws Exception { - var root = buildRootWithoutContent(); - postServiceRoot(root); - var selectedRoot = selectStoredRoot(); - - // Upload content with a specific content type - var url = buildRootUrl(selectedRoot.getId()) + "/avatar_content"; - requestHelper.setContentType(MediaType.IMAGE_PNG); - requestHelper.executePutWithMatcher( - url, "fake-image-content".getBytes(StandardCharsets.UTF_8), status().isNoContent()); - requestHelper.resetHelper(); - - serviceHandler.clearEventContext(); - var rootAfterPut = selectStoredRoot(); - - // Now read the content and verify Content-Type matches what was uploaded - var readUrl = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; - var response = requestHelper.executeGet(readUrl); - - // The Content-Type should reflect the mimeType stored in the database - // Note: OData adapter may append charset, so we use startsWith for robustness - assertThat(response.getResponse().getContentType()).startsWith("image/png"); - } - - @Test - void selectInlineAttachmentIncludesMediaContentTypeAnnotation() throws Exception { - var root = buildRootWithoutContent(); - postServiceRoot(root); - var selectedRoot = selectStoredRoot(); - - // Upload content with a specific content type - var url = buildRootUrl(selectedRoot.getId()) + "/avatar_content"; - requestHelper.setContentType(MediaType.TEXT_PLAIN); - requestHelper.executePutWithMatcher( - url, "test-content".getBytes(StandardCharsets.UTF_8), status().isNoContent()); - requestHelper.resetHelper(); - - serviceHandler.clearEventContext(); - - // Query the entity with $select including the content field - var selectUrl = - MockHttpRequestHelper.ODATA_BASE_URL - + "TestService/Roots(" - + selectedRoot.getId() - + ")?$select=avatar_content,avatar_mimeType"; - var response = - requestHelper.executeGetWithSingleODataResponseAndAssertStatus(selectUrl, HttpStatus.OK); - - // The response should include the @mediaContentType annotation with the correct MIME type - // This validates that the Core.MediaType annotation path is correctly resolved for inline - // attachments - assertThat(response).contains("avatar_content@mediaContentType"); - assertThat(response).contains("text/plain"); - } - - @Test - void deleteInlineAttachmentContentOnRootClearsContent() throws Exception { - var root = buildRootWithoutContent(); - postServiceRoot(root); - var selectedRoot = selectStoredRoot(); - - putInlineAttachmentContentOnRoot(selectedRoot.getId()); - serviceHandler.clearEventContext(); - var rootAfterPut = selectStoredRoot(); - var contentIdBeforeDelete = rootAfterPut.getAvatarContentId(); - - var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; - requestHelper.executeDelete(url); - - var rootAfterDelete = selectStoredRoot(); - assertThat(rootAfterDelete.getAvatarContentId()).isNull(); - assertThat(rootAfterDelete.getAvatarContent()).isNull(); - verifySingleDeletionEvent(contentIdBeforeDelete); - } - - @Test - void updateInlineAttachmentContentOnRootWorks() throws Exception { - var root = buildRootWithoutContent(); - postServiceRoot(root); - var selectedRoot = selectStoredRoot(); - - putInlineAttachmentContentOnRoot(selectedRoot.getId()); - serviceHandler.clearEventContext(); - var rootAfterFirstPut = selectStoredRoot(); - var firstContentId = rootAfterFirstPut.getAvatarContentId(); - - var newContent = putInlineAttachmentContentOnRoot(rootAfterFirstPut.getId(), "newContent"); - var rootAfterSecondPut = selectStoredRoot(); - - assertThat(rootAfterSecondPut.getAvatarContentId()).isNotEmpty(); - assertThat(rootAfterSecondPut.getAvatarContentId()).isNotEqualTo(firstContentId); - verifySingleCreateAndDeleteEvent( - rootAfterSecondPut.getAvatarContentId(), firstContentId, newContent); - } - - @Test - void deleteRootDeletesInlineAttachmentContent() throws Exception { - var root = buildRootWithoutContent(); - postServiceRoot(root); - var selectedRoot = selectStoredRoot(); - - putInlineAttachmentContentOnRoot(selectedRoot.getId()); - serviceHandler.clearEventContext(); - var rootAfterPut = selectStoredRoot(); - var contentId = rootAfterPut.getAvatarContentId(); - - var url = buildRootUrl(rootAfterPut.getId()); - requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); - - waitTillExpectedHandlerMessageSize(1); - verifySingleDeletionEvent(contentId); - } - - @Test - void inlineAttachmentReadViaExpandHasNoFilledContent() throws Exception { - var root = buildRootWithoutContent(); - postServiceRoot(root); - var selectedRoot = selectStoredRoot(); - putInlineAttachmentContentOnRoot(selectedRoot.getId()); - serviceHandler.clearEventContext(); - - var url = MockHttpRequestHelper.ODATA_BASE_URL + "TestService/Roots?$select=ID,avatar_content"; - var response = - requestHelper.executeGetWithSingleODataResponseAndAssertStatus( - url, Roots.class, HttpStatus.OK); - - // In expand/collection reads, the content should not be filled - assertThat(response.getAvatarContent()).isNull(); - verifyNoAttachmentEventsCalled(); - } - - // Tests for inline attachment on composition child (Items.icon) - - @Test - void createRootWithItemWithoutInlineAttachmentWorks() throws Exception { - var root = buildRootWithItem(); - postServiceRoot(root); - - var selectedRoot = selectStoredRootWithItems(); - assertThat(selectedRoot.getItems()).hasSize(1); - var item = selectedRoot.getItems().get(0); - assertThat(item.getIconContent()).isNull(); - assertThat(item.getIconContentId()).isNull(); - verifyNoAttachmentEventsCalled(); - } - - @Test - void putContentToInlineAttachmentOnItemWorks() throws Exception { - var root = buildRootWithItem(); - postServiceRoot(root); - var selectedRoot = selectStoredRootWithItems(); - var item = selectedRoot.getItems().get(0); - - var content = putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); - var rootAfterPut = selectStoredRootWithItems(); - var itemAfterPut = rootAfterPut.getItems().get(0); - - assertThat(itemAfterPut.getIconContentId()).isNotEmpty(); - assertThat(itemAfterPut.getIconStatus()).isNotEmpty(); - verifySingleCreateEvent(itemAfterPut.getIconContentId(), content); - } - - @Test - void readInlineAttachmentContentOnItemReturnsContent() throws Exception { - var root = buildRootWithItem(); - postServiceRoot(root); - var selectedRoot = selectStoredRootWithItems(); - var item = selectedRoot.getItems().get(0); - - var content = putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); - serviceHandler.clearEventContext(); - var rootAfterPut = selectStoredRootWithItems(); - var itemAfterPut = rootAfterPut.getItems().get(0); - - var url = buildItemUrl(selectedRoot.getId(), item.getId()) + "/icon_content"; - var response = requestHelper.executeGet(url); - - assertThat(response.getResponse().getContentAsString()).isEqualTo(content); - verifySingleReadEvent(itemAfterPut.getIconContentId()); - } - - @Test - void deleteInlineAttachmentContentOnItemClearsContent() throws Exception { - var root = buildRootWithItem(); - postServiceRoot(root); - var selectedRoot = selectStoredRootWithItems(); - var item = selectedRoot.getItems().get(0); - - putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); - serviceHandler.clearEventContext(); - var rootAfterPut = selectStoredRootWithItems(); - var itemAfterPut = rootAfterPut.getItems().get(0); - var contentIdBeforeDelete = itemAfterPut.getIconContentId(); - - var url = buildItemUrl(selectedRoot.getId(), item.getId()) + "/icon_content"; - requestHelper.executeDelete(url); - - var rootAfterDelete = selectStoredRootWithItems(); - var itemAfterDelete = rootAfterDelete.getItems().get(0); - assertThat(itemAfterDelete.getIconContentId()).isNull(); - assertThat(itemAfterDelete.getIconContent()).isNull(); - verifySingleDeletionEvent(contentIdBeforeDelete); - } - - @Test - void updateInlineAttachmentContentOnItemWorks() throws Exception { - var root = buildRootWithItem(); - postServiceRoot(root); - var selectedRoot = selectStoredRootWithItems(); - var item = selectedRoot.getItems().get(0); - - putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); - serviceHandler.clearEventContext(); - var rootAfterFirstPut = selectStoredRootWithItems(); - var itemAfterFirstPut = rootAfterFirstPut.getItems().get(0); - var firstContentId = itemAfterFirstPut.getIconContentId(); - - var newContent = - putInlineAttachmentContentOnItem( - rootAfterFirstPut.getId(), itemAfterFirstPut.getId(), "newContent"); - var rootAfterSecondPut = selectStoredRootWithItems(); - var itemAfterSecondPut = rootAfterSecondPut.getItems().get(0); - - assertThat(itemAfterSecondPut.getIconContentId()).isNotEmpty(); - assertThat(itemAfterSecondPut.getIconContentId()).isNotEqualTo(firstContentId); - verifySingleCreateAndDeleteEvent( - itemAfterSecondPut.getIconContentId(), firstContentId, newContent); - } - - @Test - void deleteItemDeletesInlineAttachmentContent() throws Exception { - var root = buildRootWithItem(); - postServiceRoot(root); - var selectedRoot = selectStoredRootWithItems(); - var item = selectedRoot.getItems().get(0); - - putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); - serviceHandler.clearEventContext(); - var rootAfterPut = selectStoredRootWithItems(); - var itemAfterPut = rootAfterPut.getItems().get(0); - var contentId = itemAfterPut.getIconContentId(); - - var url = buildItemUrl(selectedRoot.getId(), item.getId()); - requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); - - waitTillExpectedHandlerMessageSize(1); - verifySingleDeletionEvent(contentId); - } - - @Test - void deleteRootDeletesInlineAttachmentOnItemContent() throws Exception { - var root = buildRootWithItem(); - postServiceRoot(root); - var selectedRoot = selectStoredRootWithItems(); - var item = selectedRoot.getItems().get(0); - - putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); - serviceHandler.clearEventContext(); - var rootAfterPut = selectStoredRootWithItems(); - var itemAfterPut = rootAfterPut.getItems().get(0); - var contentId = itemAfterPut.getIconContentId(); - - var url = buildRootUrl(rootAfterPut.getId()); - requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); - - waitTillExpectedHandlerMessageSize(1); - verifySingleDeletionEvent(contentId); - } - - @Test - void deleteRootDeletesBothRootAndItemInlineAttachments() throws Exception { - var root = buildRootWithItem(); - postServiceRoot(root); - var selectedRoot = selectStoredRootWithItems(); - var item = selectedRoot.getItems().get(0); - - putInlineAttachmentContentOnRoot(selectedRoot.getId()); - putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); - serviceHandler.clearEventContext(); - var rootAfterPut = selectStoredRootWithItems(); - var itemAfterPut = rootAfterPut.getItems().get(0); - var rootContentId = rootAfterPut.getAvatarContentId(); - var itemContentId = itemAfterPut.getIconContentId(); - - var url = buildRootUrl(rootAfterPut.getId()); - requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); - - waitTillExpectedHandlerMessageSize(2); - verifyTwoDeletionEvents(rootContentId, itemContentId); - } - - // Tests for multiple inline attachments on the same entity (no data collision) - - @Test - void twoInlineAttachmentsOnSameEntityDoNotCollide() throws Exception { - var root = buildRootWithoutContent(); - postServiceRoot(root); - var selectedRoot = selectStoredRoot(); - - putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData"); - serviceHandler.clearEventContext(); - var coverImageContent = putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData"); - - var rootAfterPut = selectStoredRoot(); - - // Both attachments should have different content IDs - assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty(); - assertThat(rootAfterPut.getCoverImageContentId()).isNotEmpty(); - assertThat(rootAfterPut.getAvatarContentId()) - .isNotEqualTo(rootAfterPut.getCoverImageContentId()); - - // Verify coverImage create event was triggered - verifySingleCreateEvent(rootAfterPut.getCoverImageContentId(), coverImageContent); - } - - @Test - void readingOneInlineAttachmentDoesNotAffectOther() throws Exception { - var root = buildRootWithoutContent(); - postServiceRoot(root); - var selectedRoot = selectStoredRoot(); - - var avatarContent = putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData"); - var coverImageContent = putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData"); - serviceHandler.clearEventContext(); - - var rootAfterPut = selectStoredRoot(); - - // Read avatar content - var avatarUrl = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; - var avatarResponse = requestHelper.executeGet(avatarUrl); - assertThat(avatarResponse.getResponse().getContentAsString()).isEqualTo(avatarContent); - - serviceHandler.clearEventContext(); - - // Read coverImage content - should be independent - var coverImageUrl = buildRootUrl(rootAfterPut.getId()) + "/coverImage_content"; - var coverImageResponse = requestHelper.executeGet(coverImageUrl); - assertThat(coverImageResponse.getResponse().getContentAsString()).isEqualTo(coverImageContent); - - verifySingleReadEvent(rootAfterPut.getCoverImageContentId()); - } - - @Test - void deletingOneInlineAttachmentDoesNotAffectOther() throws Exception { - var root = buildRootWithoutContent(); - postServiceRoot(root); - var selectedRoot = selectStoredRoot(); - - putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData"); - putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData"); - serviceHandler.clearEventContext(); - - var rootAfterPut = selectStoredRoot(); - var avatarContentId = rootAfterPut.getAvatarContentId(); - var coverImageContentId = rootAfterPut.getCoverImageContentId(); - - // Delete only avatar content - var avatarUrl = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; - requestHelper.executeDelete(avatarUrl); - - var rootAfterDelete = selectStoredRoot(); - - // Avatar should be cleared - assertThat(rootAfterDelete.getAvatarContentId()).isNull(); - assertThat(rootAfterDelete.getAvatarContent()).isNull(); - - // CoverImage should still exist - assertThat(rootAfterDelete.getCoverImageContentId()).isEqualTo(coverImageContentId); - - verifySingleDeletionEvent(avatarContentId); - } - - @Test - void updatingOneInlineAttachmentDoesNotAffectOther() throws Exception { - var root = buildRootWithoutContent(); - postServiceRoot(root); - var selectedRoot = selectStoredRoot(); - - putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData"); - putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData"); - serviceHandler.clearEventContext(); - - var rootAfterFirstPut = selectStoredRoot(); - var originalAvatarContentId = rootAfterFirstPut.getAvatarContentId(); - var originalCoverImageContentId = rootAfterFirstPut.getCoverImageContentId(); - - // Update only avatar - var newAvatarContent = - putInlineAttachmentContentOnRoot(rootAfterFirstPut.getId(), "newAvatarData"); - - var rootAfterUpdate = selectStoredRoot(); - - // Avatar should have new content ID - assertThat(rootAfterUpdate.getAvatarContentId()).isNotEmpty(); - assertThat(rootAfterUpdate.getAvatarContentId()).isNotEqualTo(originalAvatarContentId); - - // CoverImage should be unchanged - assertThat(rootAfterUpdate.getCoverImageContentId()).isEqualTo(originalCoverImageContentId); - - verifySingleCreateAndDeleteEvent( - rootAfterUpdate.getAvatarContentId(), originalAvatarContentId, newAvatarContent); - } - - @Test - void deleteRootDeletesBothInlineAttachments() throws Exception { - var root = buildRootWithoutContent(); - postServiceRoot(root); - var selectedRoot = selectStoredRoot(); - - putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData"); - putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData"); - serviceHandler.clearEventContext(); - - var rootAfterPut = selectStoredRoot(); - var avatarContentId = rootAfterPut.getAvatarContentId(); - var coverImageContentId = rootAfterPut.getCoverImageContentId(); - - var url = buildRootUrl(rootAfterPut.getId()); - requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); - - waitTillExpectedHandlerMessageSize(2); - verifyTwoDeletionEvents(avatarContentId, coverImageContentId); - } - - @Test - void bothInlineAttachmentsCanBeCreatedAndReadIndependently() throws Exception { - var root = buildRootWithoutContent(); - postServiceRoot(root); - var selectedRoot = selectStoredRoot(); - - // Create both attachments - var avatarContent = putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData123"); - var coverImageContent = putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData456"); - serviceHandler.clearEventContext(); - - var rootAfterPut = selectStoredRoot(); - - // Verify both exist with different content IDs - assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty(); - assertThat(rootAfterPut.getCoverImageContentId()).isNotEmpty(); - assertThat(rootAfterPut.getAvatarContentId()) - .isNotEqualTo(rootAfterPut.getCoverImageContentId()); - - // Read and verify avatar content - var avatarUrl = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; - var avatarResponse = requestHelper.executeGet(avatarUrl); - assertThat(avatarResponse.getResponse().getContentAsString()).isEqualTo(avatarContent); - - serviceHandler.clearEventContext(); - - // Read and verify coverImage content - var coverImageUrl = buildRootUrl(rootAfterPut.getId()) + "/coverImage_content"; - var coverImageResponse = requestHelper.executeGet(coverImageUrl); - assertThat(coverImageResponse.getResponse().getContentAsString()).isEqualTo(coverImageContent); - } - - // Helper methods - - private Roots buildRootWithoutContent() { - var root = Roots.create(); - root.setTitle("root with inline attachment"); - return root; - } - - private Roots buildRootWithItem() { - var root = Roots.create(); - root.setTitle("root with item"); - var items = new ArrayList(); - var item = Items.create(); - item.setTitle("item with inline attachment"); - items.add(item); - root.setItems(items); - return root; - } - - private void postServiceRoot(Roots root) throws Exception { - var url = MockHttpRequestHelper.ODATA_BASE_URL + "TestService/Roots"; - requestHelper.executePostWithMatcher(url, root.toJson(), status().isCreated()); - } - - private Roots selectStoredRoot() { - var select = Select.from(Roots_.class).columns(StructuredType::_all); - return persistenceService.run(select).single(Roots.class); - } - - private Roots selectStoredRootWithItems() { - var select = - Select.from(Roots_.class) - .columns(StructuredType::_all, root -> root.items().expand(StructuredType::_all)); - return persistenceService.run(select).single(Roots.class); - } - - private String buildRootUrl(String rootId) { - return MockHttpRequestHelper.ODATA_BASE_URL + "TestService/Roots(" + rootId + ")"; - } - - private String buildItemUrl(String rootId, String itemId) { - return MockHttpRequestHelper.ODATA_BASE_URL - + "TestService/Roots(" - + rootId - + ")/items(" - + itemId - + ")"; - } - - private String putInlineAttachmentContentOnRoot(String rootId) throws Exception { - return putInlineAttachmentContentOnRoot(rootId, "avatarContent"); - } - - private String putInlineAttachmentContentOnRoot(String rootId, String content) throws Exception { - var url = buildRootUrl(rootId) + "/avatar_content"; - requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); - requestHelper.executePutWithMatcher( - url, content.getBytes(StandardCharsets.UTF_8), status().isNoContent()); - return content; - } - - private String putCoverImageContentOnRoot(String rootId, String content) throws Exception { - var url = buildRootUrl(rootId) + "/coverImage_content"; - requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); - requestHelper.executePutWithMatcher( - url, content.getBytes(StandardCharsets.UTF_8), status().isNoContent()); - return content; - } - - private String putInlineAttachmentContentOnItem(String rootId, String itemId) throws Exception { - return putInlineAttachmentContentOnItem(rootId, itemId, "iconContent"); - } - - private String putInlineAttachmentContentOnItem(String rootId, String itemId, String content) - throws Exception { - var url = buildItemUrl(rootId, itemId) + "/icon_content"; - requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); - requestHelper.executePutWithMatcher( - url, content.getBytes(StandardCharsets.UTF_8), status().isNoContent()); - return content; - } - - private void verifySingleCreateEvent(String contentId, String content) { - verifyEventContextEmptyForEvent( - AttachmentService.EVENT_READ_ATTACHMENT, - AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); - var createEvent = - serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); - assertThat(createEvent) - .hasSize(1) - .first() - .satisfies( - event -> { - assertThat(event.context()).isInstanceOf(AttachmentCreateEventContext.class); - var createContext = (AttachmentCreateEventContext) event.context(); - assertThat(createContext.getContentId()).isEqualTo(contentId); - assertThat(createContext.getData().getContent().readAllBytes()) - .isEqualTo(content.getBytes(StandardCharsets.UTF_8)); - }); - } - - private void verifySingleReadEvent(String contentId) { - verifyEventContextEmptyForEvent( - AttachmentService.EVENT_CREATE_ATTACHMENT, - AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); - var readContext = serviceHandler.getEventContext(); - assertThat(readContext) - .hasSize(1) - .first() - .satisfies( - event -> { - assertThat(event.event()).isEqualTo(AttachmentService.EVENT_READ_ATTACHMENT); - assertThat(((AttachmentReadEventContext) event.context()).getContentId()) - .isEqualTo(contentId); - }); - } - - private void verifySingleDeletionEvent(String contentId) { - waitTillExpectedHandlerMessageSize(1); - verifyEventContextEmptyForEvent( - AttachmentService.EVENT_CREATE_ATTACHMENT, AttachmentService.EVENT_READ_ATTACHMENT); - var deleteEvents = - serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); - assertThat(deleteEvents) - .hasSize(1) - .first() - .satisfies( - event -> { - assertThat(event.context()).isInstanceOf(AttachmentMarkAsDeletedEventContext.class); - var deleteContext = (AttachmentMarkAsDeletedEventContext) event.context(); - assertThat(deleteContext.getContentId()).isEqualTo(contentId); - }); - } - - private void verifySingleCreateAndDeleteEvent( - String newContentId, String deletedContentId, String content) { - waitTillExpectedHandlerMessageSize(2); - verifyEventContextEmptyForEvent(AttachmentService.EVENT_READ_ATTACHMENT); - var createEvents = - serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); - assertThat(createEvents).hasSize(1); - assertThat(createEvents) - .first() - .satisfies( - event -> { - var createContext = (AttachmentCreateEventContext) event.context(); - assertThat(createContext.getContentId()).isEqualTo(newContentId); - assertThat(createContext.getData().getContent().readAllBytes()) - .isEqualTo(content.getBytes(StandardCharsets.UTF_8)); - }); - - var deleteEvents = - serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); - assertThat(deleteEvents) - .hasSize(1) - .first() - .satisfies( - event -> { - var deleteContext = (AttachmentMarkAsDeletedEventContext) event.context(); - assertThat(deleteContext.getContentId()).isEqualTo(deletedContentId); - }); - } - - private void verifyTwoDeletionEvents(String contentId1, String contentId2) { - verifyEventContextEmptyForEvent( - AttachmentService.EVENT_CREATE_ATTACHMENT, AttachmentService.EVENT_READ_ATTACHMENT); - var deleteEvents = - serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); - assertThat(deleteEvents).hasSize(2); - assertThat( - deleteEvents.stream() - .anyMatch( - event -> - ((AttachmentMarkAsDeletedEventContext) event.context()) - .getContentId() - .equals(contentId1))) - .isTrue(); - assertThat( - deleteEvents.stream() - .anyMatch( - event -> - ((AttachmentMarkAsDeletedEventContext) event.context()) - .getContentId() - .equals(contentId2))) - .isTrue(); - } - - private void verifyNoAttachmentEventsCalled() { - assertThat(serviceHandler.getEventContext()).isEmpty(); - } - - private void verifyEventContextEmptyForEvent(String... events) { - for (var event : events) { - assertThat(serviceHandler.getEventContextForEvent(event)).isEmpty(); - } - } - - private void waitTillExpectedHandlerMessageSize(int expectedSize) { - Awaitility.await() - .atMost(30, TimeUnit.SECONDS) - .pollDelay(1, TimeUnit.SECONDS) - .until(() -> serviceHandler.getEventContext().size() >= expectedSize); - } -} From 976d5648259cf3fa0c1a040e7279cf30121234e3 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 24 Apr 2026 14:04:40 +0200 Subject: [PATCH 23/45] update versions and fix merging issue --- .../cds-feature-attachments/attachments-annotations.cds | 2 +- pom.xml | 4 ++-- samples/bookshop/pom.xml | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds index cb1881c80..3525f653c 100644 --- a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds +++ b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds @@ -95,7 +95,7 @@ annotate sap.attachments.Attachments with @Common : {SideEffects #ContentChanged: { SourceProperties: [content], TargetProperties: ['status'] -}} +}}; annotate sap.attachments.Attachment with { content @Core.ContentDisposition.Filename: (fileName); diff --git a/pom.xml b/pom.xml index d0ad09962..561b763dd 100644 --- a/pom.xml +++ b/pom.xml @@ -71,9 +71,9 @@ - 4.8.1 + 4.9.0 - 9.6.1 + 9.9.0 4.8.0 diff --git a/samples/bookshop/pom.xml b/samples/bookshop/pom.xml index eb4139f75..a86e4ad2a 100644 --- a/samples/bookshop/pom.xml +++ b/samples/bookshop/pom.xml @@ -12,9 +12,10 @@ bookshop parent + 1.6.0-SNAPSHOT 17 - 4.8.1 + 4.9.0 3.5.7 UTF-8 @@ -48,7 +49,7 @@ com.sap.cds cds-feature-attachments - 1.6.0-SNAPSHOT + ${cds.feature.attachments.version} From 8b965062cf288d30da727a6b3f86d33a6b154a2c Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 24 Apr 2026 14:21:41 +0200 Subject: [PATCH 24/45] fix etag issue --- .../DraftPatchAttachmentsHandler.java | 37 ----- .../DraftPatchAttachmentsHandlerTest.java | 150 ------------------ 2 files changed, 187 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java index 65f7f8999..76021113c 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java @@ -104,14 +104,6 @@ void processBeforeDraftPatch(DraftPatchEventContext context, List values, Map keys, CdsEntity draftEntity) { - Object contentId = values.get(Attachments.CONTENT_ID); - if (contentId == null) { - return; - } - - Map updateData = new HashMap<>(); - Object mimeType = values.get(MediaData.MIME_TYPE); - Object fileName = values.get(MediaData.FILE_NAME); - if (mimeType != null) { - updateData.put(MediaData.MIME_TYPE, mimeType); - } - if (fileName != null) { - updateData.put(MediaData.FILE_NAME, fileName); - } - if (updateData.isEmpty()) { - return; - } - - // Use entity keys directly to identify the record (including IsActiveEntity for draft) - if (keys.isEmpty()) { - return; - } - - CqnUpdate update = Update.entity(draftEntity).data(updateData).matching(keys); - persistence.run(update); - } - private void persistInlineAttachmentMetadata(CdsEntity target, List data) { List inlinePrefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(target); if (inlinePrefixes.isEmpty()) { diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java index 39f37599f..40f3d7871 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java @@ -390,154 +390,4 @@ void inlinePatchPersistsOnlyFileName() { assertThat(update.entries().get(0)).doesNotContainKey("profilePicture_mimeType"); } - // --- Composition-based Attachment Metadata Tests (persistCompositionAttachmentMetadata) --- - - @Test - void compositionAttachmentPersistsMetadataWhenMimeTypeAndFileNamePresent() { - // Target the attachment entity directly to trigger persistCompositionAttachmentMetadata - var draftAttachmentName = Attachment_.CDS_NAME + DraftUtils.DRAFT_TABLE_POSTFIX; - getEntityAndMockContext(draftAttachmentName); - - var attachment = Attachments.create(); - attachment.setId(UUID.randomUUID().toString()); - attachment.setContent(mock(InputStream.class)); - - var result = mock(Result.class); - when(persistence.run(any(CqnSelect.class))).thenReturn(result); - when(result.listOf(Attachments.class)).thenReturn(List.of(attachment)); - - // Simulate event.processEvent setting contentId, mimeType, and fileName in - // path.target().values() - when(event.processEvent(any(), any(), any(), any(), any())) - .thenAnswer( - invocation -> { - attachment.setContentId("cid-composition"); - attachment.setMimeType("application/pdf"); - attachment.setFileName("report.pdf"); - return null; - }); - - cut.processBeforeDraftPatch(eventContext, List.of(attachment)); - - // Verify update was called with mimeType and fileName - ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); - verify(persistence).run(updateCaptor.capture()); - CqnUpdate update = updateCaptor.getValue(); - assertThat(update.entries()).isNotEmpty(); - assertThat(update.entries().get(0)).containsEntry("mimeType", "application/pdf"); - assertThat(update.entries().get(0)).containsEntry("fileName", "report.pdf"); - } - - @Test - void compositionAttachmentPersistsOnlyMimeType() { - var draftAttachmentName = Attachment_.CDS_NAME + DraftUtils.DRAFT_TABLE_POSTFIX; - getEntityAndMockContext(draftAttachmentName); - - var attachment = Attachments.create(); - attachment.setId(UUID.randomUUID().toString()); - attachment.setContent(mock(InputStream.class)); - - var result = mock(Result.class); - when(persistence.run(any(CqnSelect.class))).thenReturn(result); - when(result.listOf(Attachments.class)).thenReturn(List.of(attachment)); - - when(event.processEvent(any(), any(), any(), any(), any())) - .thenAnswer( - invocation -> { - attachment.setContentId("cid-mime-only"); - attachment.setMimeType("text/plain"); - // fileName not set - return null; - }); - - cut.processBeforeDraftPatch(eventContext, List.of(attachment)); - - ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); - verify(persistence).run(updateCaptor.capture()); - CqnUpdate update = updateCaptor.getValue(); - assertThat(update.entries().get(0)).containsEntry("mimeType", "text/plain"); - assertThat(update.entries().get(0)).doesNotContainKey("fileName"); - } - - @Test - void compositionAttachmentPersistsOnlyFileName() { - var draftAttachmentName = Attachment_.CDS_NAME + DraftUtils.DRAFT_TABLE_POSTFIX; - getEntityAndMockContext(draftAttachmentName); - - var attachment = Attachments.create(); - attachment.setId(UUID.randomUUID().toString()); - attachment.setContent(mock(InputStream.class)); - - var result = mock(Result.class); - when(persistence.run(any(CqnSelect.class))).thenReturn(result); - when(result.listOf(Attachments.class)).thenReturn(List.of(attachment)); - - when(event.processEvent(any(), any(), any(), any(), any())) - .thenAnswer( - invocation -> { - attachment.setContentId("cid-filename-only"); - attachment.setFileName("data.csv"); - // mimeType not set - return null; - }); - - cut.processBeforeDraftPatch(eventContext, List.of(attachment)); - - ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); - verify(persistence).run(updateCaptor.capture()); - CqnUpdate update = updateCaptor.getValue(); - assertThat(update.entries().get(0)).containsEntry("fileName", "data.csv"); - assertThat(update.entries().get(0)).doesNotContainKey("mimeType"); - } - - @Test - void compositionAttachmentSkipsUpdateWhenContentIdNull() { - var draftAttachmentName = Attachment_.CDS_NAME + DraftUtils.DRAFT_TABLE_POSTFIX; - getEntityAndMockContext(draftAttachmentName); - - var attachment = Attachments.create(); - attachment.setId(UUID.randomUUID().toString()); - attachment.setContent(mock(InputStream.class)); - - var result = mock(Result.class); - when(persistence.run(any(CqnSelect.class))).thenReturn(result); - when(result.listOf(Attachments.class)).thenReturn(List.of(attachment)); - - // processEvent does NOT set contentId - when(event.processEvent(any(), any(), any(), any(), any())).thenReturn(null); - - cut.processBeforeDraftPatch(eventContext, List.of(attachment)); - - // Should only call persistence.run once for the SELECT, not for UPDATE - verify(persistence).run(any(CqnSelect.class)); - verify(persistence, never()).run(any(CqnUpdate.class)); - } - - @Test - void compositionAttachmentSkipsUpdateWhenNoMetadata() { - var draftAttachmentName = Attachment_.CDS_NAME + DraftUtils.DRAFT_TABLE_POSTFIX; - getEntityAndMockContext(draftAttachmentName); - - var attachment = Attachments.create(); - attachment.setId(UUID.randomUUID().toString()); - attachment.setContent(mock(InputStream.class)); - - var result = mock(Result.class); - when(persistence.run(any(CqnSelect.class))).thenReturn(result); - when(result.listOf(Attachments.class)).thenReturn(List.of(attachment)); - - // processEvent sets contentId but no mimeType/fileName - when(event.processEvent(any(), any(), any(), any(), any())) - .thenAnswer( - invocation -> { - attachment.setContentId("cid-no-metadata"); - return null; - }); - - cut.processBeforeDraftPatch(eventContext, List.of(attachment)); - - // Should only call persistence.run once for the SELECT, not for UPDATE - verify(persistence).run(any(CqnSelect.class)); - verify(persistence, never()).run(any(CqnUpdate.class)); - } } From 9721ac44000a64109804bfdde0dd8749689ef3a2 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 24 Apr 2026 14:50:16 +0200 Subject: [PATCH 25/45] simplify --- .../mimeTypeValidation/MediaTypeService.java | 16 ++++++---------- .../modifyevents/CreateAttachmentEvent.java | 4 ++-- .../cds-feature-attachments/attachments.cds | 8 ++++---- .../mimeTypeValidation/MediaTypeServiceTest.java | 16 ++++++++-------- .../modifyevents/CreateAttachmentEventTest.java | 12 ++++++------ .../DraftPatchAttachmentsHandlerTest.java | 1 - 6 files changed, 26 insertions(+), 31 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeService.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeService.java index a647ad240..629556667 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeService.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeService.java @@ -13,13 +13,12 @@ public final class MediaTypeService { private static final Logger logger = LoggerFactory.getLogger(MediaTypeService.class); - public static final String DEFAULT_MEDIA_TYPE = "application/octet-stream"; /** * Resolves the MIME type of a file based on its filename (specifically its extension). * * @param fileName the name of the file (including extension) - * @return the resolved MIME type, or a default MIME type if it cannot be determined + * @return the resolved MIME type, or {@code null} if it cannot be determined * @throws ServiceException if the filename is null or blank */ public static String resolveMimeType(String fileName) { @@ -29,14 +28,14 @@ public static String resolveMimeType(String fileName) { int lastDotIndex = fileName.lastIndexOf('.'); if (lastDotIndex == -1 || lastDotIndex == fileName.length() - 1) { - return fallbackToDefaultMimeType(fileName); + return logUnresolvableMimeType(fileName); } FileNameMap fileNameMap = URLConnection.getFileNameMap(); String actualMimeType = fileNameMap.getContentTypeFor(fileName); if (actualMimeType == null) { - return fallbackToDefaultMimeType(fileName); + return logUnresolvableMimeType(fileName); } return actualMimeType; } @@ -74,12 +73,9 @@ public static boolean isMimeTypeAllowed( : baseMimeType.equals(type)); } - private static String fallbackToDefaultMimeType(String fileName) { - logger.warn( - "Could not determine mime type for file: {}. Setting mime type to default: {}", - fileName, - DEFAULT_MEDIA_TYPE); - return DEFAULT_MEDIA_TYPE; + private static String logUnresolvableMimeType(String fileName) { + logger.warn("Could not determine mime type for file: {}", fileName); + return null; } private MediaTypeService() { diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java index d53494800..f76c278a4 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java @@ -77,8 +77,8 @@ public InputStream processEvent( } // Extract mimeType from Content-Type header for ALL attachment types (inline and composition) - // when mimeType is not already set or is the default 'application/octet-stream' - if (mimeTypeOptional.isEmpty() || "application/octet-stream".equals(mimeTypeOptional.get())) { + // when mimeType is not already set + if (mimeTypeOptional.isEmpty() && eventContext.getParameterInfo() != null) { Optional headerMimeType = extractMimeTypeFromHeader(eventContext); if (headerMimeType.isPresent()) { mimeTypeOptional = headerMimeType; diff --git a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds index b46b2634c..a1fd61e2d 100644 --- a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds +++ b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds @@ -8,7 +8,7 @@ using { // using { Attachments } from 'com.sap.cds/cds-feature-attachments' aspect Attachments : sap.attachments.Attachments {} -type Attachment : sap.attachments.Attachment; +type Attachment : sap.attachments.Attachment; context sap.attachments { @@ -26,9 +26,9 @@ context sap.attachments { criticality : Integer @UI.Hidden; } - aspect MediaData @(_is_media_data) : managed { + aspect MediaData @(_is_media_data) { content : LargeBinary; // stored only for db-based services - mimeType : String default 'application/octet-stream'; + mimeType : String; fileName : String(5000); contentId : String @readonly; // id of attachment in external storage, if database storage is used, same as id status : StatusCode default 'Unscanned' @readonly; @@ -38,7 +38,7 @@ context sap.attachments { type Attachment : MediaData {} - aspect Attachments : cuid, MediaData { + aspect Attachments : cuid, MediaData, managed { statusNav : Association to one ScanStates on statusNav.code = status; } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeServiceTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeServiceTest.java index 9dc898b1e..2f0d1b4e2 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeServiceTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeServiceTest.java @@ -26,17 +26,17 @@ void returnsCorrectMimeType_caseInsensitive() { } @Test - void returnsDefaultMimeType_forUnknownExtension() { + void returnsNull_forUnknownExtension() { String result = MediaTypeService.resolveMimeType("file.unknown"); - assertEquals(MediaTypeService.DEFAULT_MEDIA_TYPE, result); + assertNull(result); } @Test - void returnsDefaultMimeType_whenNoExtensionPresent() { + void returnsNull_whenNoExtensionPresent() { String result = MediaTypeService.resolveMimeType("file"); - assertEquals(MediaTypeService.DEFAULT_MEDIA_TYPE, result); + assertNull(result); } @Test @@ -57,28 +57,28 @@ void handlesDoubleDotFiles() { void handlesTrailingDotFile() { String result = MediaTypeService.resolveMimeType("file."); - assertEquals(MediaTypeService.DEFAULT_MEDIA_TYPE, result); + assertNull(result); } @Test void handlesHiddenDotFile() { String result = MediaTypeService.resolveMimeType(".gitignore"); - assertEquals(MediaTypeService.DEFAULT_MEDIA_TYPE, result); + assertNull(result); } @Test void handlesOnlyDotsFile() { String result = MediaTypeService.resolveMimeType("..."); - assertEquals(MediaTypeService.DEFAULT_MEDIA_TYPE, result); + assertNull(result); } @Test void handlesWeirdFilename() { String result = MediaTypeService.resolveMimeType("file..unknown"); - assertEquals(MediaTypeService.DEFAULT_MEDIA_TYPE, result); + assertNull(result); } @Test diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java index 29f735245..9d124b5e5 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java @@ -614,7 +614,7 @@ void inlineExtractsMimeTypeFromContentTypeHeader() { } @Test - void inlineMimeTypeOctetStreamOverriddenByHeader() { + void inlineMimeTypeOctetStreamKeptWhenExplicitlySet() { Map values = prepareInlineValuesWithoutMetadata(); values.put("profilePicture_mimeType", "application/octet-stream"); when(parameterInfo.getHeader("Content-Type")).thenReturn("image/png"); @@ -626,9 +626,9 @@ void inlineMimeTypeOctetStreamOverriddenByHeader() { eventContext, Optional.of("profilePicture")); - assertThat(values).containsEntry("profilePicture_mimeType", "image/png"); + assertThat(values).containsEntry("profilePicture_mimeType", "application/octet-stream"); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); - assertThat(contextArgumentCaptor.getValue().mimeType()).isEqualTo("image/png"); + assertThat(contextArgumentCaptor.getValue().mimeType()).isEqualTo("application/octet-stream"); } @Test @@ -649,7 +649,7 @@ void inlineMimeTypeNullContentTypeReturnsEmpty() { } @Test - void inlineMimeTypeOctetStreamContentTypeNotUsed() { + void inlineMimeTypeOctetStreamFromContentTypeHeaderIsUsed() { Map values = prepareInlineValuesWithoutMetadata(); when(parameterInfo.getHeader("Content-Type")).thenReturn("application/octet-stream"); @@ -660,9 +660,9 @@ void inlineMimeTypeOctetStreamContentTypeNotUsed() { eventContext, Optional.of("profilePicture")); - assertThat(values).doesNotContainKey("profilePicture_mimeType"); + assertThat(values).containsEntry("profilePicture_mimeType", "application/octet-stream"); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); - assertThat(contextArgumentCaptor.getValue().mimeType()).isNull(); + assertThat(contextArgumentCaptor.getValue().mimeType()).isEqualTo("application/octet-stream"); } @Test diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java index 40f3d7871..7771b8429 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java @@ -389,5 +389,4 @@ void inlinePatchPersistsOnlyFileName() { assertThat(update.entries().get(0)).containsEntry("profilePicture_fileName", "document.pdf"); assertThat(update.entries().get(0)).doesNotContainKey("profilePicture_mimeType"); } - } From 818f0a8cdadb46adfed49a1cf282311d0919a163 Mon Sep 17 00:00:00 2001 From: Marvin Date: Sat, 25 Apr 2026 11:19:24 +0200 Subject: [PATCH 26/45] Add integration tests for inline (single) attachments (#805) --- .../handler/common/AttachmentsReader.java | 4 + .../DraftCancelAttachmentsHandler.java | 18 +- integration-tests/db/data-model.cds | 4 + .../SingleAttachmentDraftTest.java | 396 ++++++++ .../SingleAttachmentNonDraftTest.java | 897 ++++++++++++++++++ 5 files changed, 1316 insertions(+), 3 deletions(-) create mode 100644 integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java create mode 100644 integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java index 4bafc555a..33308fd65 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java @@ -15,6 +15,7 @@ import com.sap.cds.ql.cqn.CqnSelectListItem; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.reflect.CdsModel; +import com.sap.cds.services.draft.Drafts; import com.sap.cds.services.persistence.PersistenceService; import java.util.ArrayList; import java.util.List; @@ -81,6 +82,9 @@ private List buildInlineAttachmentColumns(CdsEntity entity) { columns.add(CQL.get(fieldName + "_" + Attachments.CONTENT_ID)); columns.add(CQL.get(fieldName + "_" + Attachments.STATUS)); } + if (!columns.isEmpty()) { + columns.add(CQL.get(Drafts.HAS_ACTIVE_ENTITY)); + } return columns; } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java index eaa8daf42..f785f5c1c 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java @@ -103,7 +103,20 @@ private Validator buildDeleteContentValidator( Optional inlinePrefix = ApplicationHandlerHelper.getInlineAttachmentPrefix( path.target().entity(), element.getName()); - Attachments attachment = Attachments.of(path.target().values()); + + Attachments attachment; + if (inlinePrefix.isPresent()) { + attachment = + ApplicationHandlerHelper.extractInlineAttachment( + path.target().values(), inlinePrefix.get()); + Object hasActiveEntity = path.target().values().get(Drafts.HAS_ACTIVE_ENTITY); + if (hasActiveEntity != null) { + attachment.put(Drafts.HAS_ACTIVE_ENTITY, hasActiveEntity); + } + } else { + attachment = Attachments.of(path.target().values()); + } + if (Boolean.FALSE.equals(attachment.get(Drafts.HAS_ACTIVE_ENTITY))) { deleteEvent.processEvent(path, null, attachment, context, inlinePrefix); return; @@ -115,14 +128,13 @@ private Validator buildDeleteContentValidator( .findAny(); existingEntry.ifPresent( entry -> { - if (!entry.get(Attachments.CONTENT_ID).equals(value)) { + if (!entry.get(Attachments.CONTENT_ID).equals(attachment.getContentId())) { deleteEvent.processEvent(null, null, attachment, context, inlinePrefix); } }); }; } - private List readAttachments( DraftCancelEventContext context, CdsStructuredType entity, boolean isActiveEntity) { logger.debug( diff --git a/integration-tests/db/data-model.cds b/integration-tests/db/data-model.cds index b0cc7aa76..52378becb 100644 --- a/integration-tests/db/data-model.cds +++ b/integration-tests/db/data-model.cds @@ -2,6 +2,7 @@ namespace test.data.model; using {cuid} from '@sap/cds/common'; using {sap.attachments.Attachments} from 'com.sap.cds/cds-feature-attachments'; +using {Attachment} from 'com.sap.cds/cds-feature-attachments'; entity AttachmentEntity : Attachments { parentKey : UUID; @@ -9,6 +10,8 @@ entity AttachmentEntity : Attachments { entity Roots : cuid { title : String; + avatar : Attachment; + coverImage : Attachment; attachments : Composition of many AttachmentEntity on attachments.parentKey = $self.ID; items : Composition of many Items @@ -28,6 +31,7 @@ entity Contributors : cuid { entity Items : cuid { parentID : UUID; title : String; + icon : Attachment; events : Association to many Events on events.itemId = $self.ID; attachments : Composition of many Attachments; diff --git a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java new file mode 100644 index 000000000..8761410e6 --- /dev/null +++ b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java @@ -0,0 +1,396 @@ +/* + * © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.integrationtests.draftservice; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.sap.cds.Struct; +import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testdraftservice.DraftRoots; +import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testdraftservice.DraftRoots_; +import com.sap.cds.feature.attachments.integrationtests.common.MockHttpRequestHelper; +import com.sap.cds.feature.attachments.integrationtests.common.TableDataDeleter; +import com.sap.cds.feature.attachments.integrationtests.constants.Profiles; +import com.sap.cds.feature.attachments.integrationtests.testhandler.TestPersistenceHandler; +import com.sap.cds.feature.attachments.integrationtests.testhandler.TestPluginAttachmentsServiceHandler; +import com.sap.cds.feature.attachments.service.AttachmentService; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentMarkAsDeletedEventContext; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentReadEventContext; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.StructuredType; +import com.sap.cds.services.persistence.PersistenceService; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles(Profiles.TEST_HANDLER_ENABLED) +class SingleAttachmentDraftTest { + + private static final Logger logger = LoggerFactory.getLogger(SingleAttachmentDraftTest.class); + private static final String BASE_URL = MockHttpRequestHelper.ODATA_BASE_URL + "TestDraftService/"; + + @Autowired private TestPluginAttachmentsServiceHandler serviceHandler; + @Autowired private MockHttpRequestHelper requestHelper; + @Autowired private PersistenceService persistenceService; + @Autowired private TableDataDeleter dataDeleter; + @Autowired private TestPersistenceHandler testPersistenceHandler; + + @AfterEach + void teardown() { + dataDeleter.deleteData( + DraftRoots_.CDS_NAME, DraftRoots_.CDS_NAME + "_drafts", "cds.outbox.Messages"); + serviceHandler.clearEventContext(); + serviceHandler.clearDocuments(); + requestHelper.resetHelper(); + testPersistenceHandler.reset(); + } + + @Test + void createInlineAttachmentInDraftAndActivate() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + + requestHelper.executePatchWithODataResponseAndAssertStatusOk( + draftRootUrl, "{\"title\":\"some title\"}"); + + var content = putInlineAttachmentContent(draftRootUrl, "avatarContent"); + prepareAndActivateDraft(draftRootUrl); + + var activeRoot = selectActiveRoot(draft.getId()); + assertThat(activeRoot.getAvatarContentId()).isNotEmpty(); + assertThat(activeRoot.getAvatarStatus()).isNotEmpty(); + verifySingleCreateEvent(activeRoot.getAvatarContentId(), content); + } + + @Test + void createInlineAttachmentInDraftAndCancel() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + + var content = putInlineAttachmentContent(draftRootUrl, "avatarContent"); + cancelDraft(draftRootUrl); + + waitTillExpectedHandlerMessageSize(2); + var createEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); + assertThat(createEvents).hasSize(1); + var createContext = (AttachmentCreateEventContext) createEvents.get(0).context(); + assertThat(createContext.getData().getContent().readAllBytes()) + .isEqualTo(content.getBytes(StandardCharsets.UTF_8)); + + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + assertThat(deleteEvents).hasSize(1); + var deleteContext = (AttachmentMarkAsDeletedEventContext) deleteEvents.get(0).context(); + assertThat(deleteContext.getContentId()).isEqualTo(createContext.getContentId()); + } + + @Test + void updateInlineAttachmentInDraftAndActivate() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + putInlineAttachmentContent(draftRootUrl, "originalContent"); + prepareAndActivateDraft(draftRootUrl); + var activeRootAfterFirstActivation = selectActiveRoot(draft.getId()); + var originalContentId = activeRootAfterFirstActivation.getAvatarContentId(); + serviceHandler.clearEventContext(); + + editExistingRoot(draft.getId()); + var newDraftRootUrl = getDraftRootUrl(draft.getId()); + var newContent = putInlineAttachmentContent(newDraftRootUrl, "updatedContent"); + prepareAndActivateDraft(newDraftRootUrl); + + var activeRootAfterUpdate = selectActiveRoot(draft.getId()); + assertThat(activeRootAfterUpdate.getAvatarContentId()).isNotEmpty(); + assertThat(activeRootAfterUpdate.getAvatarContentId()).isNotEqualTo(originalContentId); + + waitTillExpectedHandlerMessageSize(2); + var createEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); + assertThat(createEvents).hasSize(1); + var createContext = (AttachmentCreateEventContext) createEvents.get(0).context(); + assertThat(createContext.getContentId()).isEqualTo(activeRootAfterUpdate.getAvatarContentId()); + assertThat(createContext.getData().getContent().readAllBytes()) + .isEqualTo(newContent.getBytes(StandardCharsets.UTF_8)); + + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + assertThat(deleteEvents).hasSize(1); + var deleteContext = (AttachmentMarkAsDeletedEventContext) deleteEvents.get(0).context(); + assertThat(deleteContext.getContentId()).isEqualTo(originalContentId); + } + + @Test + void deleteInlineAttachmentInDraftAndActivate() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + putInlineAttachmentContent(draftRootUrl, "contentToDelete"); + prepareAndActivateDraft(draftRootUrl); + var activeRootAfterFirstActivation = selectActiveRoot(draft.getId()); + var originalContentId = activeRootAfterFirstActivation.getAvatarContentId(); + serviceHandler.clearEventContext(); + + editExistingRoot(draft.getId()); + var newDraftRootUrl = getDraftRootUrl(draft.getId()); + requestHelper.executeDeleteWithMatcher( + newDraftRootUrl + "/avatar_content", status().isNoContent()); + prepareAndActivateDraft(newDraftRootUrl); + + var activeRootAfterDelete = selectActiveRoot(draft.getId()); + assertThat(activeRootAfterDelete.getAvatarContentId()).isNull(); + verifySingleDeletionEvent(originalContentId); + } + + @Test + void deleteInlineAttachmentInDraftAndCancel() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + putInlineAttachmentContent(draftRootUrl, "contentToKeep"); + prepareAndActivateDraft(draftRootUrl); + var activeRootAfterFirstActivation = selectActiveRoot(draft.getId()); + assertThat(activeRootAfterFirstActivation.getAvatarContentId()).isNotEmpty(); + serviceHandler.clearEventContext(); + + editExistingRoot(draft.getId()); + var newDraftRootUrl = getDraftRootUrl(draft.getId()); + requestHelper.executeDeleteWithMatcher( + newDraftRootUrl + "/avatar_content", status().isNoContent()); + cancelDraft(newDraftRootUrl); + + verifyNoAttachmentEventsCalled(); + var activeRootAfterCancel = selectActiveRoot(draft.getId()); + assertThat(activeRootAfterCancel.getAvatarContentId()).isNotEmpty(); + } + + @Test + void contentReadableFromDraftBeforeActivation() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + var content = putInlineAttachmentContent(draftRootUrl, "readableContent"); + serviceHandler.clearEventContext(); + + var contentUrl = draftRootUrl + "/avatar_content"; + Awaitility.await() + .atMost(60, TimeUnit.SECONDS) + .pollDelay(1, TimeUnit.SECONDS) + .pollInterval(2, TimeUnit.SECONDS) + .until( + () -> { + var response = requestHelper.executeGet(contentUrl); + var responseContent = response.getResponse().getContentAsString(); + var matches = responseContent.equals(content); + if (!matches) { + logger.info( + "Waiting for draft content to be readable. Response: '{}', Expected: '{}'", + responseContent, + content); + } + return matches; + }); + serviceHandler.clearEventContext(); + + var response = requestHelper.executeGet(contentUrl); + assertThat(response.getResponse().getContentAsString()).isEqualTo(content); + verifySingleReadEvent(null); + } + + @Test + void noChangesOnInlineAttachmentStillAvailableAfterActivate() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + var content = putInlineAttachmentContent(draftRootUrl, "stableContent"); + prepareAndActivateDraft(draftRootUrl); + serviceHandler.clearEventContext(); + + editExistingRoot(draft.getId()); + var newDraftRootUrl = getDraftRootUrl(draft.getId()); + requestHelper.executePatchWithODataResponseAndAssertStatusOk( + newDraftRootUrl, "{\"title\":\"changed title\"}"); + prepareAndActivateDraft(newDraftRootUrl); + verifyNoAttachmentEventsCalled(); + + var activeContentUrl = getActiveRootUrl(draft.getId()) + "/avatar_content"; + Awaitility.await() + .atMost(60, TimeUnit.SECONDS) + .pollDelay(1, TimeUnit.SECONDS) + .pollInterval(2, TimeUnit.SECONDS) + .until( + () -> { + var response = requestHelper.executeGet(activeContentUrl); + return response.getResponse().getContentAsString().equals(content); + }); + serviceHandler.clearEventContext(); + + var response = requestHelper.executeGet(activeContentUrl); + assertThat(response.getResponse().getContentAsString()).isEqualTo(content); + } + + @Test + void errorInTransactionAfterCreateCallsDelete() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + + testPersistenceHandler.setThrowExceptionOnUpdate(true); + var contentUrl = draftRootUrl + "/avatar_content"; + requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher( + contentUrl, "errorContent".getBytes(StandardCharsets.UTF_8), status().is5xxServerError()); + requestHelper.resetHelper(); + + waitTillExpectedHandlerMessageSize(2); + var createEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); + assertThat(createEvents).hasSize(1); + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + assertThat(deleteEvents).hasSize(1); + var createContext = (AttachmentCreateEventContext) createEvents.get(0).context(); + var deleteContext = (AttachmentMarkAsDeletedEventContext) deleteEvents.get(0).context(); + assertThat(deleteContext.getContentId()).isEqualTo(createContext.getContentId()); + } + + // Helper methods + + private DraftRoots createNewDraft() throws Exception { + var responseData = + requestHelper.executePostWithODataResponseAndAssertStatusCreated( + BASE_URL + "DraftRoots", "{}"); + return Struct.access(responseData).as(DraftRoots.class); + } + + private String getDraftRootUrl(String rootId) { + return BASE_URL + "DraftRoots(ID=" + rootId + ",IsActiveEntity=false)"; + } + + private String getActiveRootUrl(String rootId) { + return BASE_URL + "DraftRoots(ID=" + rootId + ",IsActiveEntity=true)"; + } + + private void prepareAndActivateDraft(String draftRootUrl) throws Exception { + var draftPrepareUrl = draftRootUrl + "/TestDraftService.draftPrepare"; + var draftActivateUrl = draftRootUrl + "/TestDraftService.draftActivate"; + requestHelper.executePostWithMatcher( + draftPrepareUrl, "{\"SideEffectsQualifier\":\"\"}", status().isOk()); + requestHelper.executePostWithMatcher(draftActivateUrl, "{}", status().isOk()); + } + + private void editExistingRoot(String rootId) throws Exception { + var url = getActiveRootUrl(rootId) + "/TestDraftService.draftEdit"; + requestHelper.executePostWithMatcher(url, "{\"PreserveChanges\":true}", status().isOk()); + } + + private void cancelDraft(String draftRootUrl) throws Exception { + requestHelper.executeDeleteWithMatcher(draftRootUrl, status().isNoContent()); + } + + private String putInlineAttachmentContent(String draftRootUrl, String content) throws Exception { + var contentUrl = draftRootUrl + "/avatar_content"; + requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher( + contentUrl, content.getBytes(StandardCharsets.UTF_8), status().isNoContent()); + requestHelper.resetHelper(); + return content; + } + + private DraftRoots selectActiveRoot(String rootId) { + var select = + Select.from(DraftRoots_.CDS_NAME) + .where(root -> root.get(DraftRoots.ID).eq(rootId)) + .columns(StructuredType::_all); + return persistenceService.run(select).single(DraftRoots.class); + } + + private void verifySingleCreateEvent(String contentId, String content) { + verifyEventContextEmptyForEvent( + AttachmentService.EVENT_READ_ATTACHMENT, + AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + var createEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); + assertThat(createEvents) + .hasSize(1) + .first() + .satisfies( + event -> { + assertThat(event.context()).isInstanceOf(AttachmentCreateEventContext.class); + var createContext = (AttachmentCreateEventContext) event.context(); + assertThat(createContext.getContentId()).isEqualTo(contentId); + assertThat(createContext.getData().getContent().readAllBytes()) + .isEqualTo(content.getBytes(StandardCharsets.UTF_8)); + }); + } + + private void verifySingleDeletionEvent(String contentId) { + waitTillExpectedHandlerMessageSize(1); + verifyEventContextEmptyForEvent( + AttachmentService.EVENT_CREATE_ATTACHMENT, AttachmentService.EVENT_READ_ATTACHMENT); + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + assertThat(deleteEvents) + .hasSize(1) + .first() + .satisfies( + event -> { + assertThat(event.context()).isInstanceOf(AttachmentMarkAsDeletedEventContext.class); + var deleteContext = (AttachmentMarkAsDeletedEventContext) event.context(); + assertThat(deleteContext.getContentId()).isEqualTo(contentId); + assertThat(deleteContext.getDeletionUserInfo().getName()).isEqualTo("anonymous"); + assertThat(deleteContext.getDeletionUserInfo().getIsSystemUser()).isFalse(); + }); + } + + private void verifySingleReadEvent(String contentId) { + verifyEventContextEmptyForEvent( + AttachmentService.EVENT_CREATE_ATTACHMENT, + AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + var readContext = serviceHandler.getEventContext(); + assertThat(readContext) + .hasSize(1) + .first() + .satisfies( + event -> { + assertThat(event.event()).isEqualTo(AttachmentService.EVENT_READ_ATTACHMENT); + if (contentId != null) { + assertThat(((AttachmentReadEventContext) event.context()).getContentId()) + .isEqualTo(contentId); + } + }); + } + + private void verifyNoAttachmentEventsCalled() { + assertThat(serviceHandler.getEventContext()).isEmpty(); + } + + private void verifyEventContextEmptyForEvent(String... events) { + Arrays.stream(events) + .forEach(event -> assertThat(serviceHandler.getEventContextForEvent(event)).isEmpty()); + } + + private void waitTillExpectedHandlerMessageSize(int expectedSize) { + Awaitility.await() + .atMost(30, TimeUnit.SECONDS) + .pollDelay(1, TimeUnit.SECONDS) + .until( + () -> { + var eventCalls = serviceHandler.getEventContext().size(); + logger.debug( + "Waiting for expected size '{}' in handler context, was '{}'", + expectedSize, + eventCalls); + return eventCalls >= expectedSize; + }); + } +} diff --git a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java new file mode 100644 index 000000000..62cb41ef1 --- /dev/null +++ b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java @@ -0,0 +1,897 @@ +/* + * © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.integrationtests.nondraftservice; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testservice.Items; +import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testservice.Roots; +import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testservice.Roots_; +import com.sap.cds.feature.attachments.integrationtests.common.MockHttpRequestHelper; +import com.sap.cds.feature.attachments.integrationtests.common.TableDataDeleter; +import com.sap.cds.feature.attachments.integrationtests.constants.Profiles; +import com.sap.cds.feature.attachments.integrationtests.testhandler.EventContextHolder; +import com.sap.cds.feature.attachments.integrationtests.testhandler.TestPersistenceHandler; +import com.sap.cds.feature.attachments.integrationtests.testhandler.TestPluginAttachmentsServiceHandler; +import com.sap.cds.feature.attachments.service.AttachmentService; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentMarkAsDeletedEventContext; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentReadEventContext; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.StructuredType; +import com.sap.cds.services.persistence.PersistenceService; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.concurrent.TimeUnit; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles(Profiles.TEST_HANDLER_ENABLED) +class SingleAttachmentNonDraftTest { + + @Autowired private TestPluginAttachmentsServiceHandler serviceHandler; + @Autowired private MockHttpRequestHelper requestHelper; + @Autowired private PersistenceService persistenceService; + @Autowired private TableDataDeleter dataDeleter; + @Autowired private TestPersistenceHandler testPersistenceHandler; + @Autowired private MockMvc mvc; + + @AfterEach + void teardown() { + dataDeleter.deleteData(Roots_.CDS_NAME); + serviceHandler.clearEventContext(); + serviceHandler.clearDocuments(); + requestHelper.resetHelper(); + testPersistenceHandler.reset(); + } + + @Test + void createRootWithoutInlineAttachmentWorks() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + + var selectedRoot = selectStoredRoot(); + assertThat(selectedRoot.getId()).isNotEmpty(); + assertThat(selectedRoot.getTitle()).isEqualTo(root.getTitle()); + assertThat(selectedRoot.getAvatarContent()).isNull(); + assertThat(selectedRoot.getAvatarContentId()).isNull(); + assertThat(selectedRoot.getAvatarFileName()).isNull(); + verifyNoAttachmentEventsCalled(); + } + + @Test + void putContentToInlineAttachmentOnRootWorks() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var content = putInlineAttachmentContentOnRoot(selectedRoot.getId()); + var rootAfterPut = selectStoredRoot(); + + assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty(); + assertThat(rootAfterPut.getAvatarStatus()).isNotEmpty(); + verifySingleCreateEvent(rootAfterPut.getAvatarContentId(), content); + } + + @Test + void readInlineAttachmentContentOnRootReturnsContent() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var content = putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + + var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + var response = requestHelper.executeGet(url); + + assertThat(response.getResponse().getContentAsString()).isEqualTo(content); + verifySingleReadEvent(rootAfterPut.getAvatarContentId()); + } + + @Test + void readInlineAttachmentContentReturnsCorrectContentTypeHeader() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var content = putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + + var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + var response = requestHelper.executeGet(url); + + assertThat(response.getResponse().getContentAsString()).isEqualTo(content); + assertThat(response.getResponse().getContentType()).startsWith("application/octet-stream"); + } + + @Test + void readInlineAttachmentContentReturnsContentDispositionHeader() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + + var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + var response = requestHelper.executeGet(url); + + var contentDisposition = response.getResponse().getHeader("Content-Disposition"); + assertThat(contentDisposition).isNotNull(); + assertThat(contentDisposition).startsWith("inline"); + } + + @Test + void readInlineAttachmentContentReturnsFilenameInContentDisposition() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + requestHelper.resetHelper(); // Reset after PUT to use JSON for PATCH + var rootAfterPut = selectStoredRoot(); + + var patchUrl = buildRootUrl(rootAfterPut.getId()); + requestHelper.executePatchWithODataResponseAndAssertStatusOk( + patchUrl, "{\"avatar_fileName\": \"test-file.bin\"}"); + + var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + var response = requestHelper.executeGet(url); + + var contentDisposition = response.getResponse().getHeader("Content-Disposition"); + assertThat(contentDisposition).isNotNull(); + assertThat(contentDisposition).contains("filename=\"test-file.bin\""); + } + + @Test + void readInlineAttachmentContentWithCustomMimeTypeReturnsCorrectContentType() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var url = buildRootUrl(selectedRoot.getId()) + "/avatar_content"; + requestHelper.setContentType(MediaType.IMAGE_PNG); + requestHelper.executePutWithMatcher( + url, "fake-image-content".getBytes(StandardCharsets.UTF_8), status().isNoContent()); + requestHelper.resetHelper(); + + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + + var readUrl = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + var response = requestHelper.executeGet(readUrl); + + assertThat(response.getResponse().getContentType()).startsWith("image/png"); + } + + @Test + void selectInlineAttachmentIncludesMediaContentTypeAnnotation() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var url = buildRootUrl(selectedRoot.getId()) + "/avatar_content"; + requestHelper.setContentType(MediaType.TEXT_PLAIN); + requestHelper.executePutWithMatcher( + url, "test-content".getBytes(StandardCharsets.UTF_8), status().isNoContent()); + requestHelper.resetHelper(); + + serviceHandler.clearEventContext(); + + var selectUrl = + MockHttpRequestHelper.ODATA_BASE_URL + + "TestService/Roots(" + + selectedRoot.getId() + + ")?$select=avatar_content,avatar_mimeType"; + var response = + requestHelper.executeGetWithSingleODataResponseAndAssertStatus(selectUrl, HttpStatus.OK); + + assertThat(response).contains("avatar_content@mediaContentType"); + assertThat(response).contains("text/plain"); + } + + @Test + void deleteInlineAttachmentContentOnRootClearsContent() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + var contentIdBeforeDelete = rootAfterPut.getAvatarContentId(); + + var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + requestHelper.executeDelete(url); + + var rootAfterDelete = selectStoredRoot(); + assertThat(rootAfterDelete.getAvatarContentId()).isNull(); + assertThat(rootAfterDelete.getAvatarContent()).isNull(); + verifySingleDeletionEvent(contentIdBeforeDelete); + } + + @Test + void updateInlineAttachmentContentOnRootWorks() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterFirstPut = selectStoredRoot(); + var firstContentId = rootAfterFirstPut.getAvatarContentId(); + + var newContent = putInlineAttachmentContentOnRoot(rootAfterFirstPut.getId(), "newContent"); + var rootAfterSecondPut = selectStoredRoot(); + + assertThat(rootAfterSecondPut.getAvatarContentId()).isNotEmpty(); + assertThat(rootAfterSecondPut.getAvatarContentId()).isNotEqualTo(firstContentId); + verifySingleCreateAndDeleteEvent( + rootAfterSecondPut.getAvatarContentId(), firstContentId, newContent); + } + + @Test + void deleteRootDeletesInlineAttachmentContent() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + var contentId = rootAfterPut.getAvatarContentId(); + + var url = buildRootUrl(rootAfterPut.getId()); + requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); + + verifySingleDeletionEvent(contentId); + } + + @Test + void inlineAttachmentReadViaExpandHasNoFilledContent() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + + var url = MockHttpRequestHelper.ODATA_BASE_URL + "TestService/Roots?$select=ID,avatar_content"; + var response = + requestHelper.executeGetWithSingleODataResponseAndAssertStatus( + url, Roots.class, HttpStatus.OK); + + assertThat(response.getAvatarContent()).isNull(); + verifyNoAttachmentEventsCalled(); + } + + @Test + void createRootWithItemWithoutInlineAttachmentWorks() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + + var selectedRoot = selectStoredRootWithItems(); + assertThat(selectedRoot.getItems()).hasSize(1); + var item = selectedRoot.getItems().get(0); + assertThat(item.getIconContent()).isNull(); + assertThat(item.getIconContentId()).isNull(); + verifyNoAttachmentEventsCalled(); + } + + @Test + void putContentToInlineAttachmentOnItemWorks() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + var content = putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + var rootAfterPut = selectStoredRootWithItems(); + var itemAfterPut = rootAfterPut.getItems().get(0); + + assertThat(itemAfterPut.getIconContentId()).isNotEmpty(); + assertThat(itemAfterPut.getIconStatus()).isNotEmpty(); + verifySingleCreateEvent(itemAfterPut.getIconContentId(), content); + } + + @Test + void readInlineAttachmentContentOnItemReturnsContent() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + var content = putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRootWithItems(); + var itemAfterPut = rootAfterPut.getItems().get(0); + + var url = buildItemUrl(selectedRoot.getId(), item.getId()) + "/icon_content"; + var response = requestHelper.executeGet(url); + + assertThat(response.getResponse().getContentAsString()).isEqualTo(content); + verifySingleReadEvent(itemAfterPut.getIconContentId()); + } + + @Test + void deleteInlineAttachmentContentOnItemClearsContent() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRootWithItems(); + var itemAfterPut = rootAfterPut.getItems().get(0); + var contentIdBeforeDelete = itemAfterPut.getIconContentId(); + + var url = buildItemUrl(selectedRoot.getId(), item.getId()) + "/icon_content"; + requestHelper.executeDelete(url); + + var rootAfterDelete = selectStoredRootWithItems(); + var itemAfterDelete = rootAfterDelete.getItems().get(0); + assertThat(itemAfterDelete.getIconContentId()).isNull(); + assertThat(itemAfterDelete.getIconContent()).isNull(); + verifySingleDeletionEvent(contentIdBeforeDelete); + } + + @Test + void updateInlineAttachmentContentOnItemWorks() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + serviceHandler.clearEventContext(); + var rootAfterFirstPut = selectStoredRootWithItems(); + var itemAfterFirstPut = rootAfterFirstPut.getItems().get(0); + var firstContentId = itemAfterFirstPut.getIconContentId(); + + var newContent = + putInlineAttachmentContentOnItem( + rootAfterFirstPut.getId(), itemAfterFirstPut.getId(), "newContent"); + var rootAfterSecondPut = selectStoredRootWithItems(); + var itemAfterSecondPut = rootAfterSecondPut.getItems().get(0); + + assertThat(itemAfterSecondPut.getIconContentId()).isNotEmpty(); + assertThat(itemAfterSecondPut.getIconContentId()).isNotEqualTo(firstContentId); + verifySingleCreateAndDeleteEvent( + itemAfterSecondPut.getIconContentId(), firstContentId, newContent); + } + + @Test + void deleteItemDeletesInlineAttachmentContent() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRootWithItems(); + var itemAfterPut = rootAfterPut.getItems().get(0); + var contentId = itemAfterPut.getIconContentId(); + + var url = buildItemUrl(selectedRoot.getId(), item.getId()); + requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); + + verifySingleDeletionEvent(contentId); + } + + @Test + void deleteRootDeletesInlineAttachmentOnItemContent() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRootWithItems(); + var itemAfterPut = rootAfterPut.getItems().get(0); + var contentId = itemAfterPut.getIconContentId(); + + var url = buildRootUrl(rootAfterPut.getId()); + requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); + + verifySingleDeletionEvent(contentId); + } + + @Test + void deleteRootDeletesBothRootAndItemInlineAttachments() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRootWithItems(); + var itemAfterPut = rootAfterPut.getItems().get(0); + var rootContentId = rootAfterPut.getAvatarContentId(); + var itemContentId = itemAfterPut.getIconContentId(); + + var url = buildRootUrl(rootAfterPut.getId()); + requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); + + verifyTwoDeletionEvents(rootContentId, itemContentId); + } + + @Test + void twoInlineAttachmentsOnSameEntityDoNotCollide() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData"); + serviceHandler.clearEventContext(); + var coverImageContent = putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData"); + + var rootAfterPut = selectStoredRoot(); + + assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty(); + assertThat(rootAfterPut.getCoverImageContentId()).isNotEmpty(); + assertThat(rootAfterPut.getAvatarContentId()) + .isNotEqualTo(rootAfterPut.getCoverImageContentId()); + + verifySingleCreateEvent(rootAfterPut.getCoverImageContentId(), coverImageContent); + } + + @Test + void readingOneInlineAttachmentDoesNotAffectOther() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var avatarContent = putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData"); + var coverImageContent = putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData"); + serviceHandler.clearEventContext(); + + var rootAfterPut = selectStoredRoot(); + + var avatarUrl = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + var avatarResponse = requestHelper.executeGet(avatarUrl); + assertThat(avatarResponse.getResponse().getContentAsString()).isEqualTo(avatarContent); + + verifySingleReadEvent(rootAfterPut.getAvatarContentId()); + serviceHandler.clearEventContext(); + + var coverImageUrl = buildRootUrl(rootAfterPut.getId()) + "/coverImage_content"; + var coverImageResponse = requestHelper.executeGet(coverImageUrl); + assertThat(coverImageResponse.getResponse().getContentAsString()).isEqualTo(coverImageContent); + + verifySingleReadEvent(rootAfterPut.getCoverImageContentId()); + } + + @Test + void deletingOneInlineAttachmentDoesNotAffectOther() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData"); + putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData"); + serviceHandler.clearEventContext(); + + var rootAfterPut = selectStoredRoot(); + var avatarContentId = rootAfterPut.getAvatarContentId(); + var coverImageContentId = rootAfterPut.getCoverImageContentId(); + + var avatarUrl = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + requestHelper.executeDelete(avatarUrl); + + var rootAfterDelete = selectStoredRoot(); + + assertThat(rootAfterDelete.getAvatarContentId()).isNull(); + assertThat(rootAfterDelete.getAvatarContent()).isNull(); + + assertThat(rootAfterDelete.getCoverImageContentId()).isEqualTo(coverImageContentId); + + verifySingleDeletionEvent(avatarContentId); + } + + @Test + void updatingOneInlineAttachmentDoesNotAffectOther() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData"); + putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData"); + serviceHandler.clearEventContext(); + + var rootAfterFirstPut = selectStoredRoot(); + var originalAvatarContentId = rootAfterFirstPut.getAvatarContentId(); + var originalCoverImageContentId = rootAfterFirstPut.getCoverImageContentId(); + + var newAvatarContent = + putInlineAttachmentContentOnRoot(rootAfterFirstPut.getId(), "newAvatarData"); + + var rootAfterUpdate = selectStoredRoot(); + + assertThat(rootAfterUpdate.getAvatarContentId()).isNotEmpty(); + assertThat(rootAfterUpdate.getAvatarContentId()).isNotEqualTo(originalAvatarContentId); + + assertThat(rootAfterUpdate.getCoverImageContentId()).isEqualTo(originalCoverImageContentId); + + verifySingleCreateAndDeleteEvent( + rootAfterUpdate.getAvatarContentId(), originalAvatarContentId, newAvatarContent); + } + + @Test + void deleteRootDeletesBothInlineAttachments() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData"); + putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData"); + serviceHandler.clearEventContext(); + + var rootAfterPut = selectStoredRoot(); + var avatarContentId = rootAfterPut.getAvatarContentId(); + var coverImageContentId = rootAfterPut.getCoverImageContentId(); + + var url = buildRootUrl(rootAfterPut.getId()); + requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); + + verifyTwoDeletionEvents(avatarContentId, coverImageContentId); + } + + @Test + void doubleDeleteInlineAttachmentContentHandledCorrectly() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + var contentId = rootAfterPut.getAvatarContentId(); + + var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + requestHelper.executeDelete(url); + verifySingleDeletionEvent(contentId); + serviceHandler.clearEventContext(); + + var secondDeleteResult = requestHelper.executeDelete(url); + assertThat(secondDeleteResult.getResponse().getStatus()) + .isIn(HttpStatus.NO_CONTENT.value(), HttpStatus.OK.value()); + verifyNoAttachmentEventsCalled(); + } + + @ParameterizedTest + @CsvSource({"avatar_status,INFECTED", "avatar_contentId,TEST"}) + void readOnlyFieldsCannotBeUpdatedViaPatchOnRoot(String field, String value) throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + requestHelper.resetHelper(); + + var url = buildRootUrl(selectedRoot.getId()); + requestHelper.executePatchWithODataResponseAndAssertStatus( + url, "{\"" + field + "\":\"" + value + "\"}", HttpStatus.OK); + + var rootAfterPatch = selectStoredRoot(); + assertThat(rootAfterPatch.get(field)).isNotNull().isNotEqualTo(value); + } + + @Test + void errorInTransactionAfterCreateRollsBackContent() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + testPersistenceHandler.setThrowExceptionOnUpdate(true); + putInlineAttachmentContentOnRoot( + selectedRoot.getId(), "failContent", status().is5xxServerError()); + + var rootAfterError = selectStoredRoot(); + assertThat(rootAfterError.getAvatarContentId()).isNull(); + assertThat(rootAfterError.getAvatarContent()).isNull(); + } + + @Test + void uploadWithContentDispositionHeaderExtractsFileName() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var url = buildRootUrl(selectedRoot.getId()) + "/avatar_content"; + mvc.perform( + MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header("If-Match", "*") + .header("Content-Disposition", "attachment; filename=\"uploaded-avatar.png\"") + .content("avatar-data".getBytes(StandardCharsets.UTF_8))) + .andExpect(status().isNoContent()); + + var rootAfterPut = selectStoredRoot(); + assertThat(rootAfterPut.getAvatarFileName()).isEqualTo("uploaded-avatar.png"); + assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty(); + } + + @Test + void uploadWithSlugHeaderExtractsFileName() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var url = buildRootUrl(selectedRoot.getId()) + "/avatar_content"; + mvc.perform( + MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header("If-Match", "*") + .header("slug", "uploaded-slug-file.txt") + .content("slug-data".getBytes(StandardCharsets.UTF_8))) + .andExpect(status().isNoContent()); + + var rootAfterPut = selectStoredRoot(); + assertThat(rootAfterPut.getAvatarFileName()).isEqualTo("uploaded-slug-file.txt"); + assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty(); + } + + @Test + void uploadWithSpecificContentTypeStoresMimeType() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var url = buildRootUrl(selectedRoot.getId()) + "/avatar_content"; + requestHelper.setContentType(MediaType.IMAGE_JPEG); + requestHelper.executePutWithMatcher( + url, "jpeg-data".getBytes(StandardCharsets.UTF_8), status().isNoContent()); + + var rootAfterPut = selectStoredRoot(); + assertThat(rootAfterPut.getAvatarMimeType()).startsWith("image/jpeg"); + assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty(); + } + + @Test + void malwareScanStatusIsCleanAfterUpload() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + + var rootAfterPut = selectStoredRoot(); + assertThat(rootAfterPut.getAvatarStatus()).isEqualTo("Clean"); + assertThat(rootAfterPut.getAvatarScannedAt()).isNotNull(); + } + + private Roots buildRootWithoutContent() { + var root = Roots.create(); + root.setTitle("root with inline attachment"); + return root; + } + + private Roots buildRootWithItem() { + var root = Roots.create(); + root.setTitle("root with item"); + var items = new ArrayList(); + var item = Items.create(); + item.setTitle("item with inline attachment"); + items.add(item); + root.setItems(items); + return root; + } + + private void postServiceRoot(Roots root) throws Exception { + var url = MockHttpRequestHelper.ODATA_BASE_URL + "TestService/Roots"; + requestHelper.executePostWithMatcher(url, root.toJson(), status().isCreated()); + } + + private Roots selectStoredRoot() { + var select = Select.from(Roots_.class).columns(StructuredType::_all); + return persistenceService.run(select).single(Roots.class); + } + + private Roots selectStoredRootWithItems() { + var select = + Select.from(Roots_.class) + .columns(StructuredType::_all, root -> root.items().expand(StructuredType::_all)); + return persistenceService.run(select).single(Roots.class); + } + + private String buildRootUrl(String rootId) { + return MockHttpRequestHelper.ODATA_BASE_URL + "TestService/Roots(" + rootId + ")"; + } + + private String buildItemUrl(String rootId, String itemId) { + return MockHttpRequestHelper.ODATA_BASE_URL + + "TestService/Roots(" + + rootId + + ")/items(" + + itemId + + ")"; + } + + private String putInlineAttachmentContentOnRoot(String rootId) throws Exception { + return putInlineAttachmentContentOnRoot(rootId, "avatarContent"); + } + + private String putInlineAttachmentContentOnRoot(String rootId, String content) throws Exception { + return putInlineAttachmentContentOnRoot(rootId, content, status().isNoContent()); + } + + private String putInlineAttachmentContentOnRoot( + String rootId, String content, ResultMatcher matcher) throws Exception { + var url = buildRootUrl(rootId) + "/avatar_content"; + requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher(url, content.getBytes(StandardCharsets.UTF_8), matcher); + return content; + } + + private String putCoverImageContentOnRoot(String rootId, String content) throws Exception { + var url = buildRootUrl(rootId) + "/coverImage_content"; + requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher( + url, content.getBytes(StandardCharsets.UTF_8), status().isNoContent()); + return content; + } + + private String putInlineAttachmentContentOnItem(String rootId, String itemId) throws Exception { + return putInlineAttachmentContentOnItem(rootId, itemId, "iconContent"); + } + + private String putInlineAttachmentContentOnItem(String rootId, String itemId, String content) + throws Exception { + var url = buildItemUrl(rootId, itemId) + "/icon_content"; + requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher( + url, content.getBytes(StandardCharsets.UTF_8), status().isNoContent()); + return content; + } + + private void verifySingleCreateEvent(String contentId, String content) { + verifyEventContextEmptyForEvent( + AttachmentService.EVENT_READ_ATTACHMENT, + AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + var createEvent = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); + assertThat(createEvent) + .hasSize(1) + .first() + .satisfies( + event -> { + assertThat(event.context()).isInstanceOf(AttachmentCreateEventContext.class); + var createContext = (AttachmentCreateEventContext) event.context(); + assertThat(createContext.getContentId()).isEqualTo(contentId); + assertThat(createContext.getData().getContent().readAllBytes()) + .isEqualTo(content.getBytes(StandardCharsets.UTF_8)); + }); + } + + private void verifySingleReadEvent(String contentId) { + verifyEventContextEmptyForEvent( + AttachmentService.EVENT_CREATE_ATTACHMENT, + AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + var readContext = serviceHandler.getEventContext(); + assertThat(readContext) + .hasSize(1) + .first() + .satisfies( + event -> { + assertThat(event.event()).isEqualTo(AttachmentService.EVENT_READ_ATTACHMENT); + assertThat(((AttachmentReadEventContext) event.context()).getContentId()) + .isEqualTo(contentId); + }); + } + + private void verifySingleDeletionEvent(String contentId) { + waitTillExpectedHandlerMessageSize(1); + verifyEventContextEmptyForEvent( + AttachmentService.EVENT_CREATE_ATTACHMENT, AttachmentService.EVENT_READ_ATTACHMENT); + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + assertThat(deleteEvents) + .hasSize(1) + .first() + .satisfies( + event -> { + assertThat(event.context()).isInstanceOf(AttachmentMarkAsDeletedEventContext.class); + var deleteContext = (AttachmentMarkAsDeletedEventContext) event.context(); + assertThat(deleteContext.getContentId()).isEqualTo(contentId); + assertThat(deleteContext.getDeletionUserInfo().getName()).isEqualTo("anonymous"); + assertThat(deleteContext.getDeletionUserInfo().getIsSystemUser()).isFalse(); + }); + } + + private void verifySingleCreateAndDeleteEvent( + String newContentId, String deletedContentId, String content) { + waitTillExpectedHandlerMessageSize(2); + verifyEventContextEmptyForEvent(AttachmentService.EVENT_READ_ATTACHMENT); + var createEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); + assertThat(createEvents).hasSize(1); + assertThat(createEvents) + .first() + .satisfies( + event -> { + var createContext = (AttachmentCreateEventContext) event.context(); + assertThat(createContext.getContentId()).isEqualTo(newContentId); + assertThat(createContext.getData().getContent().readAllBytes()) + .isEqualTo(content.getBytes(StandardCharsets.UTF_8)); + }); + + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + assertThat(deleteEvents) + .hasSize(1) + .first() + .satisfies( + event -> { + var deleteContext = (AttachmentMarkAsDeletedEventContext) event.context(); + assertThat(deleteContext.getContentId()).isEqualTo(deletedContentId); + }); + } + + private void verifyTwoDeletionEvents(String contentId1, String contentId2) { + waitTillExpectedHandlerMessageSize(2); + verifyEventContextEmptyForEvent( + AttachmentService.EVENT_CREATE_ATTACHMENT, AttachmentService.EVENT_READ_ATTACHMENT); + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + assertThat(deleteEvents).hasSize(2); + assertThat( + deleteEvents.stream() + .anyMatch(event -> verifyDeleteEventContentIdAndUserInfo(event, contentId1))) + .isTrue(); + assertThat( + deleteEvents.stream() + .anyMatch(event -> verifyDeleteEventContentIdAndUserInfo(event, contentId2))) + .isTrue(); + } + + private boolean verifyDeleteEventContentIdAndUserInfo( + EventContextHolder event, String contentId) { + var ctx = (AttachmentMarkAsDeletedEventContext) event.context(); + return ctx.getContentId().equals(contentId) + && "anonymous".equals(ctx.getDeletionUserInfo().getName()) + && Boolean.FALSE.equals(ctx.getDeletionUserInfo().getIsSystemUser()); + } + + private void verifyNoAttachmentEventsCalled() { + assertThat(serviceHandler.getEventContext()).isEmpty(); + } + + private void verifyEventContextEmptyForEvent(String... events) { + for (var event : events) { + assertThat(serviceHandler.getEventContextForEvent(event)).isEmpty(); + } + } + + private void waitTillExpectedHandlerMessageSize(int expectedSize) { + Awaitility.await() + .atMost(30, TimeUnit.SECONDS) + .pollDelay(1, TimeUnit.SECONDS) + .until(() -> serviceHandler.getEventContext().size() >= expectedSize); + } +} From 51291fc27ee41b5e2fd9a9dada6341f8bac7b2e3 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Sat, 25 Apr 2026 11:40:31 +0200 Subject: [PATCH 27/45] fix build --- .../feature/attachments/handler/common/AttachmentsReader.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java index 33308fd65..9fadf551e 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java @@ -82,7 +82,7 @@ private List buildInlineAttachmentColumns(CdsEntity entity) { columns.add(CQL.get(fieldName + "_" + Attachments.CONTENT_ID)); columns.add(CQL.get(fieldName + "_" + Attachments.STATUS)); } - if (!columns.isEmpty()) { + if (!columns.isEmpty() && entity.findElement(Drafts.HAS_ACTIVE_ENTITY).isPresent()) { columns.add(CQL.get(Drafts.HAS_ACTIVE_ENTITY)); } return columns; From 5e8ee0e39aa042509f75fe225c3398de3ef85ca7 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Sat, 25 Apr 2026 11:40:53 +0200 Subject: [PATCH 28/45] remove unit tests --- .../DeleteAttachmentsHandlerTest.java | 32 --- .../ReadAttachmentsHandlerTest.java | 103 -------- .../UpdateAttachmentsHandlerTest.java | 94 -------- .../ModifyApplicationHandlerHelperTest.java | 58 ----- .../ReadonlyDataContextEnhancerTest.java | 131 ---------- .../common/ApplicationHandlerHelperTest.java | 225 ------------------ .../common/AssociationCascaderTest.java | 22 -- .../handler/common/AttachmentsReaderTest.java | 56 ----- .../DraftCancelAttachmentsHandlerTest.java | 18 -- .../DraftPatchAttachmentsHandlerTest.java | 196 --------------- .../service/AttachmentsServiceImplTest.java | 58 ----- .../AttachmentsServiceImplHandlerTest.java | 20 -- .../DefaultAttachmentMalwareScannerTest.java | 38 --- pom.xml | 4 +- 14 files changed, 2 insertions(+), 1053 deletions(-) diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java index eacba6977..9d0020ac0 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java @@ -120,36 +120,4 @@ private Attachment buildAttachment(String id, InputStream inputStream) { attachment.setContent(inputStream); return attachment; } - - @Test - void inlineAttachmentDeleteExtractsContentIdFromFlattenedFields() { - // RootTable has inline attachment profilePicture : AttachmentType - // When deleting RootTable, the MEDIA_CONTENT_FILTER triggers for profilePicture_content - // and the handler should extract the contentId from the flattened field - // profilePicture_contentId - var rootEntity = runtime.getCdsModel().findEntity(Roots_.CDS_NAME).orElseThrow(); - when(context.getTarget()).thenReturn(rootEntity); - when(context.getModel()).thenReturn(runtime.getCdsModel()); - - var inputStream = mock(InputStream.class); - - // Build data with flattened inline attachment fields (as they appear in DB) - var root = Roots.create(); - root.setId(UUID.randomUUID().toString()); - root.put("profilePicture_content", inputStream); - root.put("profilePicture_contentId", "inline-cid-123"); - root.put("profilePicture_mimeType", "image/png"); - root.put("profilePicture_fileName", "avatar.png"); - - when(attachmentsReader.readAttachments( - context.getModel(), context.getTarget(), context.getCqn())) - .thenReturn(List.of(Attachments.of(root))); - - cut.processBefore(context); - - // Verify the modifyAttachmentEvent receives an Attachments object with the extracted - // (unprefixed) contentId from the inline attachment - verify(modifyAttachmentEvent) - .processEvent(any(Path.class), eq(inputStream), any(Attachments.class), eq(context), any()); - } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java index d7f15933b..ff2226801 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java @@ -7,7 +7,6 @@ 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.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -20,7 +19,6 @@ import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.EventItems; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.EventItems_; 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.InlineOnly_; 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_; @@ -475,105 +473,4 @@ private void mockEventContext(String entityName, CqnSelect select) { when(readEventContext.getModel()).thenReturn(runtime.getCdsModel()); when(readEventContext.getCqn()).thenReturn(select); } - - // --- Inline Attachment Tests --- - - @Test - void inlineContentWrappedWithLazyProxyOnRead() { - mockEventContext(RootTable_.CDS_NAME, mock(CqnSelect.class)); - - // Create root data with inline attachment fields - var root = CdsData.create(); - root.put("ID", UUID.randomUUID().toString()); - root.put("profilePicture_content", null); - root.put("profilePicture_contentId", "inline-doc-1"); - root.put("profilePicture_status", StatusCode.CLEAN); - - cut.processAfter(readEventContext, List.of(root)); - - assertThat(root.get("profilePicture_content")).isInstanceOf(LazyProxyInputStream.class); - } - - @Test - void inlineContentWithoutContentIdRemainsNull() { - mockEventContext(RootTable_.CDS_NAME, mock(CqnSelect.class)); - - var root = CdsData.create(); - root.put("ID", UUID.randomUUID().toString()); - root.put("profilePicture_content", null); - // No contentId — should not be wrapped - - cut.processAfter(readEventContext, List.of(root)); - - assertThat(root.get("profilePicture_content")).isNull(); - } - - @Test - void inlineContentWithExistingStreamWrappedWithProxy() throws IOException { - mockEventContext(RootTable_.CDS_NAME, mock(CqnSelect.class)); - var testContent = "inline photo bytes"; - var testStream = new ByteArrayInputStream(testContent.getBytes(StandardCharsets.UTF_8)); - - var root = CdsData.create(); - root.put("ID", UUID.randomUUID().toString()); - root.put("profilePicture_content", testStream); - root.put("profilePicture_contentId", "inline-doc-2"); - root.put("profilePicture_status", StatusCode.CLEAN); - - cut.processAfter(readEventContext, List.of(root)); - - assertThat(root.get("profilePicture_content")).isInstanceOf(LazyProxyInputStream.class); - // The proxy uses the existing stream supplier - byte[] bytes = ((InputStream) root.get("profilePicture_content")).readAllBytes(); - assertThat(bytes).isEqualTo(testContent.getBytes(StandardCharsets.UTF_8)); - } - - @Test - void processBeforeWithOnlyInlineAttachmentsModifiesCqn() { - // InlineOnly entity has inline attachment but NO composition associations - // This covers the branch: fieldNames.isEmpty() && !inlinePrefixes.isEmpty() - var select = Select.from(InlineOnly_.class).columns(InlineOnly_::ID); - mockEventContext(InlineOnly_.CDS_NAME, select); - - cut.processBefore(readEventContext); - - // Verify CQN was modified (setCqn called) - verify(readEventContext).setCqn(any(CqnSelect.class)); - } - - @Test - void processAfterWithInlineOnlyEntityWrapsContent() { - mockEventContext(InlineOnly_.CDS_NAME, mock(CqnSelect.class)); - - var root = CdsData.create(); - root.put("ID", UUID.randomUUID().toString()); - root.put("avatar_content", null); - root.put("avatar_contentId", "avatar-doc-1"); - root.put("avatar_status", StatusCode.CLEAN); - - cut.processAfter(readEventContext, List.of(root)); - - assertThat(root.get("avatar_content")).isInstanceOf(LazyProxyInputStream.class); - } - - @Test - void processAfterInlineAttachmentWithStaleScanTriggersRescan() { - mockEventContext(InlineOnly_.CDS_NAME, mock(CqnSelect.class)); - - var root = CdsData.create(); - // Null key so areKeysEmpty returns true → verifyStatus proceeds - root.put("ID", null); - root.put("avatar_content", null); - root.put("avatar_contentId", "avatar-doc-stale"); - root.put("avatar_status", StatusCode.CLEAN); - // No scannedAt → stale → triggers transitionToScanning with inline prefix - - cut.processAfter(readEventContext, List.of(root)); - - // transitionToScanning calls persistenceService.run(update) with prefixed columns - verify(persistenceService).run(any(com.sap.cds.ql.cqn.CqnUpdate.class)); - verify(asyncMalwareScanExecutor) - .scanAsync(any(), eq("avatar-doc-stale"), eq(Optional.of("avatar"))); - assertThat(root.get("avatar_content")).isInstanceOf(LazyProxyInputStream.class); - } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java index 872d51c7e..d1f32eb46 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java @@ -551,98 +551,4 @@ private String getOrCondition(String key1, String key2) { .replace(" ", "") .replace("\n", ""); } - - // --- Inline Attachment Tests --- - - @Test - void inlineContentFieldTriggersProcessing() { - var id = getEntityAndMockContext(RootTable_.CDS_NAME); - var root = CdsData.create(); - root.put("ID", id); - root.put("profilePicture_content", mock(InputStream.class)); - when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) - .thenReturn(List.of()); - - cut.processBefore(updateContext, List.of(root)); - - verify(attachmentsReader).readAttachments(any(), any(), any(CqnFilterableStatement.class)); - } - - @Test - void inlineMetadataOnlyFieldTriggersReaderButNotEventFactory() { - // data contains profilePicture_mimeType but NOT profilePicture_content - // associationsAreUnchanged → false (because prefix_ key is present) - // containsContentField → false (mimeType is not content) - var id = getEntityAndMockContext(RootTable_.CDS_NAME); - var root = CdsData.create(); - root.put("ID", id); - root.put("profilePicture_mimeType", "image/png"); - when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) - .thenReturn(List.of()); - - cut.processBefore(updateContext, List.of(root)); - - // Reader is called because inline fields changed - verify(attachmentsReader).readAttachments(any(), any(), any(CqnFilterableStatement.class)); - // But eventFactory is not called because no actual content change - verifyNoInteractions(eventFactory); - } - - @Test - void noInlineOrCompositionFieldsSkipsProcessing() { - getEntityAndMockContext(RootTable_.CDS_NAME); - var root = CdsData.create(); - root.put("ID", UUID.randomUUID().toString()); - root.put("title", "Just a title update"); - - cut.processBefore(updateContext, List.of(root)); - - verifyNoInteractions(attachmentsReader); - verifyNoInteractions(eventFactory); - verifyNoInteractions(attachmentService); - } - - @Test - void inlineReadonlyFieldsPreservedForDraftActivation() { - getEntityAndMockContext(RootTable_.CDS_NAME); - - var data = CdsData.create(); - data.put("ID", UUID.randomUUID().toString()); - // Content key must be present for CdsDataProcessor validator to fire - data.put("profilePicture_content", null); - data.put("profilePicture_contentId", "doc-42"); - data.put("profilePicture_status", "Clean"); - when(storageReader.get()).thenReturn(true); - - cut.processBeforeForDraft(updateContext, List.of(data)); - - // ReadonlyDataContextEnhancer preserves inline readonly fields - var readonlyContext = (CdsData) data.get("profilePicture_DRAFT_READONLY_CONTEXT"); - assertThat(readonlyContext).isNotNull(); - assertThat(readonlyContext).containsEntry("contentId", "doc-42"); - assertThat(readonlyContext).containsEntry("status", "Clean"); - } - - @Test - void inlineReadonlyFieldsClearedForNonDraftActivation() { - getEntityAndMockContext(RootTable_.CDS_NAME); - - var readonlyData = CdsData.create(); - readonlyData.put(Attachments.CONTENT_ID, "old-doc"); - readonlyData.put(Attachments.STATUS, "Infected"); - - var data = CdsData.create(); - data.put("ID", UUID.randomUUID().toString()); - data.put("profilePicture_content", null); - data.put("profilePicture_contentId", "doc-42"); - data.put("profilePicture_DRAFT_READONLY_CONTEXT", readonlyData); - when(storageReader.get()).thenReturn(false); - - cut.processBeforeForDraft(updateContext, List.of(data)); - - // Non-draft: readonly context key is removed - assertThat(data.get("profilePicture_DRAFT_READONLY_CONTEXT")).isNull(); - // contentId stays (it was explicitly set) - assertThat(data).containsEntry("profilePicture_contentId", "doc-42"); - } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java index b4d8a471f..b000fa557 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java @@ -7,9 +7,7 @@ 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.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; @@ -221,60 +219,4 @@ void malformedContentLengthHeader() { assertThat(exception.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST); } - - // --- Inline Attachment Tests --- - - @Test - void inlineContentIdResolvedFromPrefixedField() { - // Use real RootTable entity so that inline detection works - CdsEntity realEntity = - runtime.getCdsModel().findEntity("unit.test.TestService.RootTable").orElseThrow(); - when(target.entity()).thenReturn(realEntity); - - var values = com.sap.cds.CdsData.create(); - values.put("ID", UUID.randomUUID().toString()); - values.put("profilePicture_content", mock(InputStream.class)); - values.put("profilePicture_contentId", "existing-doc-77"); - when(target.values()).thenReturn(values); - when(target.keys()).thenReturn(Map.of("ID", values.get("ID"))); - when(parameterInfo.getHeader("Content-Length")).thenReturn(null); - - var existingAttachments = List.of(); - - // contentId should be resolved from profilePicture_contentId - ModifyApplicationHandlerHelper.handleAttachmentForEntity( - existingAttachments, - eventFactory, - eventContext, - path, - (InputStream) values.get("profilePicture_content"), - ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, - Optional.of("profilePicture")); - - // Verify eventFactory was called with the resolved contentId - verify(eventFactory).getEvent(any(), eq("existing-doc-77"), any()); - } - - @Test - void handleAttachmentForEntitiesProcessesInlineContent() { - CdsEntity realEntity = - runtime.getCdsModel().findEntity("unit.test.TestService.RootTable").orElseThrow(); - - var content = mock(InputStream.class); - var data = com.sap.cds.CdsData.create(); - data.put("ID", UUID.randomUUID().toString()); - data.put("profilePicture_content", content); - when(parameterInfo.getHeader("Content-Length")).thenReturn(null); - - ModifyApplicationHandlerHelper.handleAttachmentForEntities( - realEntity, - List.of(data), - List.of(), - eventFactory, - eventContext, - ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER); - - // eventFactory should be called since inline content was found - verify(eventFactory).getEvent(any(), any(), any()); - } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java index 3f4596210..f56b73476 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java @@ -7,7 +7,6 @@ import com.sap.cds.CdsData; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; -import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Events_; 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.RootTable_; @@ -146,134 +145,4 @@ void preserveReadonlyFields_isNotDraft_noExistingBackup_nothingHappens() { assertThat(attachment.get(DRAFT_READONLY_CONTEXT)).isNull(); } - - // --- Inline attachment preserve/restore tests --- - - @Test - void preserveReadonlyFieldsForDraftInline() { - CdsEntity entity = getRootTableEntity(); - CdsData data = CdsData.create(); - data.put("profilePicture_content", new ByteArrayInputStream(new byte[0])); - data.put("profilePicture_contentId", "cid-inline-456"); - data.put("profilePicture_status", "Unscanned"); - Instant now = Instant.now(); - data.put("profilePicture_scannedAt", now); - - ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), true); - - CdsData backup = (CdsData) data.get("profilePicture_" + DRAFT_READONLY_CONTEXT); - assertThat(backup).isNotNull(); - assertThat(backup.get(Attachments.CONTENT_ID)).isEqualTo("cid-inline-456"); - assertThat(backup.get(Attachments.STATUS)).isEqualTo("Unscanned"); - assertThat(backup.get(Attachments.SCANNED_AT)).isEqualTo(now); - } - - @Test - void preserveReadonlyFieldsNonDraftRemovesInlineContext() { - CdsEntity entity = getRootTableEntity(); - CdsData data = CdsData.create(); - data.put("profilePicture_content", new ByteArrayInputStream(new byte[0])); - data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, Attachments.create()); - - ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), false); - - assertThat(data.containsKey("profilePicture_" + DRAFT_READONLY_CONTEXT)).isFalse(); - } - - @Test - void restoreReadonlyFieldsInline() { - CdsData data = CdsData.create(); - data.put("ID", "123"); - Attachments backup = Attachments.create(); - backup.setContentId("cid-inline-restored"); - backup.setStatus("Clean"); - Instant scannedAt = Instant.now(); - backup.setScannedAt(scannedAt); - data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, backup); - - ReadonlyDataContextEnhancer.restoreReadonlyFields(data); - - assertThat(data.get("profilePicture_contentId")).isEqualTo("cid-inline-restored"); - assertThat(data.get("profilePicture_status")).isEqualTo("Clean"); - assertThat(data.get("profilePicture_scannedAt")).isEqualTo(scannedAt); - assertThat(data.containsKey("profilePicture_" + DRAFT_READONLY_CONTEXT)).isFalse(); - } - - @Test - void restoreReadonlyFieldsBothCompositionAndInline() { - CdsData data = CdsData.create(); - - // Composition backup - Attachments compositionBackup = Attachments.create(); - compositionBackup.setContentId("cid-comp"); - compositionBackup.setStatus("Clean"); - data.put(DRAFT_READONLY_CONTEXT, compositionBackup); - - // Inline backup - Attachments inlineBackup = Attachments.create(); - inlineBackup.setContentId("cid-inline"); - inlineBackup.setStatus("Scanning"); - data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, inlineBackup); - - ReadonlyDataContextEnhancer.restoreReadonlyFields(data); - - // Composition restored - assertThat(data.get(Attachments.CONTENT_ID)).isEqualTo("cid-comp"); - assertThat(data.get(Attachments.STATUS)).isEqualTo("Clean"); - // Inline restored - assertThat(data.get("profilePicture_contentId")).isEqualTo("cid-inline"); - assertThat(data.get("profilePicture_status")).isEqualTo("Scanning"); - // Backup keys removed - assertThat(data.containsKey(DRAFT_READONLY_CONTEXT)).isFalse(); - assertThat(data.containsKey("profilePicture_" + DRAFT_READONLY_CONTEXT)).isFalse(); - } - - @Test - void restoreReadonlyFieldsInlineWithNullBackupDoesNothing() { - CdsData data = CdsData.create(); - data.put("ID", "123"); - // Inline backup key exists but value is null - data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, null); - - ReadonlyDataContextEnhancer.restoreReadonlyFields(data); - - // null backup is skipped, key remains - assertThat(data.get("ID")).isEqualTo("123"); - assertThat(data.containsKey("profilePicture_contentId")).isFalse(); - } - - @Test - void restoreReadonlyFieldsCompositionWithFileName() { - CdsData data = CdsData.create(); - Attachments backup = Attachments.create(); - backup.setContentId("cid-fn"); - backup.setStatus("Clean"); - backup.setFileName("document.pdf"); - data.put(DRAFT_READONLY_CONTEXT, backup); - - ReadonlyDataContextEnhancer.restoreReadonlyFields(data); - - assertThat(data.get(Attachments.CONTENT_ID)).isEqualTo("cid-fn"); - assertThat(data.get(Attachments.STATUS)).isEqualTo("Clean"); - assertThat(data.get(MediaData.FILE_NAME)).isEqualTo("document.pdf"); - assertThat(data.containsKey(DRAFT_READONLY_CONTEXT)).isFalse(); - } - - @Test - void restoreReadonlyFieldsInlineWithFileName() { - CdsData data = CdsData.create(); - data.put("ID", "123"); - Attachments backup = Attachments.create(); - backup.setContentId("cid-inline-fn"); - backup.setStatus("Clean"); - backup.setFileName("avatar.png"); - data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, backup); - - ReadonlyDataContextEnhancer.restoreReadonlyFields(data); - - assertThat(data.get("profilePicture_contentId")).isEqualTo("cid-inline-fn"); - assertThat(data.get("profilePicture_status")).isEqualTo("Clean"); - assertThat(data.get("profilePicture_fileName")).isEqualTo("avatar.png"); - assertThat(data.containsKey("profilePicture_" + DRAFT_READONLY_CONTEXT)).isFalse(); - } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java index b0c01f1da..3daa9ff27 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java @@ -6,21 +6,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -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.RootTable_; import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; -import com.sap.cds.reflect.CdsEntity; -import com.sap.cds.reflect.CdsStructuredType; import com.sap.cds.services.runtime.CdsRuntime; -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.util.List; import java.util.Map; -import java.util.Optional; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -77,219 +67,4 @@ void removeDraftKey() { assertFalse(result.containsKey("IsActiveEntity")); assertTrue(result.containsKey("key1")); } - - // --- Inline attachment tests --- - - private CdsEntity getRootTableEntity() { - return runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); - } - - private CdsEntity getAttachmentEntity() { - return runtime - .getCdsModel() - .findEntity("unit.test.TestService.RootTable.attachments") - .orElseThrow(); - } - - @Test - void hasInlineAttachmentElementsReturnsTrueForEntityWithInlineField() { - var entity = getRootTableEntity(); - assertThat(ApplicationHandlerHelper.hasInlineAttachmentElements(entity)).isTrue(); - } - - @Test - void hasInlineAttachmentElementsReturnsFalseForAttachmentEntity() { - var entity = getAttachmentEntity(); - assertThat(ApplicationHandlerHelper.hasInlineAttachmentElements(entity)).isFalse(); - } - - @Test - void getInlineAttachmentFieldNamesReturnsCorrectPrefixes() { - var entity = getRootTableEntity(); - List prefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); - assertThat(prefixes).containsExactly("profilePicture"); - } - - @Test - void getInlineAttachmentFieldNamesReturnsEmptyForAttachmentEntity() { - var entity = getAttachmentEntity(); - List prefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); - assertThat(prefixes).isEmpty(); - } - - @Test - void isMediaEntityReturnsTrueForEntityWithInlineAttachment() { - var entity = getRootTableEntity(); - assertThat(ApplicationHandlerHelper.isMediaEntity(entity)).isTrue(); - } - - @Test - void isMediaEntityReturnsTrueForDirectMediaEntity() { - var entity = getAttachmentEntity(); - assertThat(ApplicationHandlerHelper.isMediaEntity(entity)).isTrue(); - } - - @Test - void isDirectMediaEntityReturnsFalseForEntityWithOnlyInlineAttachments() { - var entity = getRootTableEntity(); - assertThat(ApplicationHandlerHelper.isDirectMediaEntity(entity)).isFalse(); - } - - @Test - void isDirectMediaEntityReturnsTrueForAttachmentEntity() { - var entity = getAttachmentEntity(); - assertThat(ApplicationHandlerHelper.isDirectMediaEntity(entity)).isTrue(); - } - - @Test - void isInlineAttachmentContentFieldReturnsTrueForPrefixedContent() { - var entity = getRootTableEntity(); - var contentElement = entity.findElement("profilePicture_content").orElseThrow(); - assertThat(ApplicationHandlerHelper.isInlineAttachmentContentField(entity, contentElement)) - .isTrue(); - } - - @Test - void isInlineAttachmentContentFieldReturnsFalseForNonContentField() { - var entity = getRootTableEntity(); - var mimeTypeElement = entity.findElement("profilePicture_mimeType").orElseThrow(); - assertThat(ApplicationHandlerHelper.isInlineAttachmentContentField(entity, mimeTypeElement)) - .isFalse(); - } - - @Test - void isInlineAttachmentContentFieldReturnsFalseForRegularField() { - var entity = getRootTableEntity(); - var titleElement = entity.findElement("title").orElseThrow(); - assertThat(ApplicationHandlerHelper.isInlineAttachmentContentField(entity, titleElement)) - .isFalse(); - } - - @Test - void getInlineAttachmentPrefixReturnsPrefixForFlattenedField() { - var entity = getRootTableEntity(); - Optional prefix = - ApplicationHandlerHelper.getInlineAttachmentPrefix(entity, "profilePicture_content"); - assertThat(prefix).isPresent().contains("profilePicture"); - } - - @Test - void getInlineAttachmentPrefixReturnsPrefixForContentIdField() { - var entity = getRootTableEntity(); - Optional prefix = - ApplicationHandlerHelper.getInlineAttachmentPrefix(entity, "profilePicture_contentId"); - assertThat(prefix).isPresent().contains("profilePicture"); - } - - @Test - void getInlineAttachmentPrefixReturnsEmptyForRegularField() { - var entity = getRootTableEntity(); - Optional prefix = ApplicationHandlerHelper.getInlineAttachmentPrefix(entity, "title"); - assertThat(prefix).isEmpty(); - } - - @Test - void getInlineAttachmentPrefixReturnsEmptyForUnprefixedContentId() { - var entity = getRootTableEntity(); - Optional prefix = - ApplicationHandlerHelper.getInlineAttachmentPrefix(entity, "contentId"); - assertThat(prefix).isEmpty(); - } - - @Test - void extractInlineAttachmentStripsPrefix() { - Map parentValues = - Map.of( - "ID", "123", - "title", "Test", - "profilePicture_contentId", "cid-abc", - "profilePicture_mimeType", "image/png", - "profilePicture_fileName", "photo.png", - "profilePicture_status", "Clean"); - - Attachments result = - ApplicationHandlerHelper.extractInlineAttachment(parentValues, "profilePicture"); - - assertThat(result.getContentId()).isEqualTo("cid-abc"); - assertThat(result.getMimeType()).isEqualTo("image/png"); - assertThat(result.getFileName()).isEqualTo("photo.png"); - assertThat(result.getStatus()).isEqualTo("Clean"); - // Non-prefixed fields should NOT be included - assertThat(result.get("ID")).isNull(); - assertThat(result.get("title")).isNull(); - } - - @Test - void condenseAttachmentsIncludesInlineAttachments() { - var entity = getRootTableEntity(); - var data = CdsData.create(); - data.put("ID", "123"); - data.put("profilePicture_content", new ByteArrayInputStream(new byte[0])); - data.put("profilePicture_contentId", "cid-inline"); - data.put("profilePicture_mimeType", "image/png"); - data.put("profilePicture_status", "Clean"); - - List result = ApplicationHandlerHelper.condenseAttachments(List.of(data), entity); - - assertThat(result).hasSize(1); - assertThat(result.get(0).getContentId()).isEqualTo("cid-inline"); - assertThat(result.get(0).getMimeType()).isEqualTo("image/png"); - } - - @Test - void condenseAttachmentsAvoidsDuplicateInlineEntries() { - var entity = getRootTableEntity(); - var data = CdsData.create(); - data.put("ID", "123"); - data.put("profilePicture_content", new ByteArrayInputStream(new byte[0])); - data.put("profilePicture_contentId", "cid-inline"); - data.put("profilePicture_status", "Clean"); - - // Same data twice — condenseAttachments should deduplicate by contentId - List result = - ApplicationHandlerHelper.condenseAttachments(List.of(data, data), entity); - - long distinctContentIds = result.stream().map(Attachments::getContentId).distinct().count(); - assertThat(distinctContentIds).isEqualTo(1); - } - - @Test - void containsContentFieldReturnsTrueForInlineContent() { - var entity = getRootTableEntity(); - var data = CdsData.create(); - data.put("profilePicture_content", new ByteArrayInputStream(new byte[0])); - - assertThat(ApplicationHandlerHelper.containsContentField(entity, List.of(data))).isTrue(); - } - - @Test - void containsContentFieldReturnsFalseForNoContent() { - var entity = getRootTableEntity(); - var data = CdsData.create(); - data.put("ID", "123"); - data.put("title", "Test"); - - assertThat(ApplicationHandlerHelper.containsContentField(entity, List.of(data))).isFalse(); - } - - @Test - void mediaContentFilterMatchesInlineContentField() { - var entity = getRootTableEntity(); - var data = CdsData.create(); - data.put("profilePicture_content", (InputStream) new ByteArrayInputStream(new byte[0])); - data.put("profilePicture_contentId", "cid-123"); - - // Use containsContentField which internally uses MEDIA_CONTENT_FILTER - assertThat(ApplicationHandlerHelper.containsContentField(entity, List.of(data))).isTrue(); - } - - @Test - void getInlineAttachmentFieldNamesWithNullElementsReturnsEmpty() { - CdsStructuredType entity = mock(CdsStructuredType.class); - when(entity.elements()).thenReturn(null); - - var result = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); - - assertThat(result).isEmpty(); - } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AssociationCascaderTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AssociationCascaderTest.java index ee1a1044a..5abd7471d 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AssociationCascaderTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AssociationCascaderTest.java @@ -164,26 +164,4 @@ private void verifyItemAttachments( .isEqualTo(itemAttachmentNodeName); assertThat(itemAttachmentNode.getChildren()).isNotNull().isEmpty(); } - - @Test - void rootEntityWithInlineAttachmentDoesNotAddExtraTreeChild() { - // Inline attachment fields on the root entity are NOT represented as NodeTree children. - // They're handled directly by AttachmentsReader via CQL select columns. - var serviceEntity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); - - assertThat(ApplicationHandlerHelper.hasInlineAttachmentElements(serviceEntity)) - .as("RootTable should have inline attachment elements (profilePicture)") - .isTrue(); - - var rootNode = cut.findEntityPath(runtime.getCdsModel(), serviceEntity); - var rootChildren = rootNode.getChildren(); - - // Inline fields on root do NOT create extra NodeTree children - // only composition associations (attachments, itemTable) appear - assertThat(rootChildren).hasSize(2); - assertThat(rootChildren.get(0).getIdentifier().associationName()) - .isEqualTo(RootTable.ATTACHMENTS); - assertThat(rootChildren.get(1).getIdentifier().associationName()) - .isEqualTo(RootTable.ITEM_TABLE); - } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReaderTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReaderTest.java index 3ea5531d0..f75383d3b 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReaderTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReaderTest.java @@ -16,7 +16,6 @@ import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.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.handler.helper.RuntimeHelper; import com.sap.cds.feature.attachments.helper.LogObserver; import com.sap.cds.ql.CQL; import com.sap.cds.ql.Delete; @@ -315,59 +314,4 @@ private String getExpectedSelectStatement() { private String removeSpaceInString(String input) { return input.replace("\n", "").replace("\t", "").replace(" ", ""); } - - // --- Inline Attachment Tests --- - - @Test - void selectIncludesInlineColumnsForEntityWithInlineAttachments() { - // Use real RootTable entity so getInlineAttachmentFieldNames returns ["profilePicture"] - CdsEntity realEntity = - RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); - var nodeTree = new NodeTree(new AssociationIdentifier("", RootTable_.CDS_NAME)); - when(cascader.findEntityPath(any(), any(CdsEntity.class))).thenReturn(nodeTree); - List data = List.of(Attachments.create()); - when(result.listOf(Attachments.class)).thenReturn(data); - - CqnDelete delete = Delete.from(RootTable_.CDS_NAME); - cut.readAttachments(model, realEntity, delete); - - verify(persistenceService).run(selectArgumentCaptor.capture()); - var selectStr = selectArgumentCaptor.getValue().toString(); - // Inline columns: profilePicture_contentId and profilePicture_status - assertThat(selectStr).contains("profilePicture_contentId"); - assertThat(selectStr).contains("profilePicture_status"); - } - - @Test - void selectIncludesInlineColumnsForChildEntityWithInlineAttachments() { - // Use real model to test the new functionality of including inline columns in child expands - CdsModel realModel = RuntimeHelper.runtime.getCdsModel(); - CdsEntity realRootEntity = realModel.findEntity(RootTable_.CDS_NAME).orElseThrow(); - - // Build a node tree where RootTable has items composition to Items - // Items entity has inline attachment "profilePicture" - var rootPath = new LinkedList(); - rootPath.add(new AssociationIdentifier("", RootTable_.CDS_NAME)); - rootPath.add(new AssociationIdentifier("items", Items_.CDS_NAME)); - - var nodeTree = new NodeTree(new AssociationIdentifier("", RootTable_.CDS_NAME)); - nodeTree.addPath(rootPath); - - when(cascader.findEntityPath(realModel, realRootEntity)).thenReturn(nodeTree); - List data = List.of(Attachments.create()); - when(result.listOf(Attachments.class)).thenReturn(data); - - CqnDelete delete = Delete.from(RootTable_.CDS_NAME); - cut.readAttachments(realModel, realRootEntity, delete); - - verify(persistenceService).run(selectArgumentCaptor.capture()); - var selectStr = selectArgumentCaptor.getValue().toString(); - - // The items expand should include inline attachment columns from Items entity - // Items entity has profilePicture inline attachment - assertThat(selectStr).contains("items"); - assertThat(selectStr).contains("profilePicture_content"); - assertThat(selectStr).contains("profilePicture_contentId"); - assertThat(selectStr).contains("profilePicture_status"); - } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java index f979f3745..e2e6a7979 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java @@ -288,22 +288,4 @@ private void getEntityAndMockContext(String cdsName) { Optional serviceEntity = runtime.getCdsModel().findEntity(cdsName); when(eventContext.getTarget()).thenReturn(serviceEntity.orElseThrow()); } - - // --- Inline Attachment Tests --- - - @Test - void entityWithInlineAttachmentsIsProcessed() { - // RootTable has profilePicture: Attachment (inline) - // deepSearchForAttachments should detect it via hasInlineAttachmentElements and process - getEntityAndMockContext(RootTable_.CDS_NAME); - CqnDelete delete = Delete.from(RootTable_.class); - when(eventContext.getCqn()).thenReturn(delete); - when(eventContext.getModel()).thenReturn(runtime.getCdsModel()); - when(eventContext.getEvent()).thenReturn("DRAFT_CANCEL"); - - cut.processBeforeDraftCancel(eventContext); - - // Inline attachment on root means attachmentsReader is called - verify(attachmentsReader, atLeastOnce()).readAttachments(any(), any(), any()); - } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java index 7771b8429..f40f618cb 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java @@ -7,11 +7,9 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; 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.Result; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Events; @@ -26,7 +24,6 @@ import com.sap.cds.feature.attachments.handler.applicationservice.readhelper.CountingInputStream; import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.ql.cqn.CqnSelect; -import com.sap.cds.ql.cqn.CqnUpdate; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.draft.DraftPatchEventContext; import com.sap.cds.services.draft.DraftService; @@ -196,197 +193,4 @@ private void getEntityAndMockContext(String cdsName) { private void mockTargetInUpdateContext(CdsEntity serviceEntity) { when(eventContext.getTarget()).thenReturn(serviceEntity); } - - // --- Inline Attachment Tests --- - - @Test - void inlineContentFieldTriggersConverterViaMEDIA_CONTENT_FILTER() { - // RootTable has profilePicture : Attachment (inline). - // MEDIA_CONTENT_FILTER should match profilePicture_content and the converter - // should call persistence + eventFactory. - getEntityAndMockContext(RootTable_.CDS_NAME); - - var data = CdsData.create(); - data.put("ID", UUID.randomUUID().toString()); - data.put("profilePicture_content", mock(InputStream.class)); - - var result = mock(Result.class); - when(persistence.run(any(CqnSelect.class))).thenReturn(result); - - cut.processBeforeDraftPatch(eventContext, List.of(data)); - - // The converter reads from persistence (draft entity) and calls eventFactory - verify(persistence).run(any(CqnSelect.class)); - verify(eventFactory).getEvent(any(), any(), any()); - } - - @Test - void inlineDeleteExtractsExistingContentIdFromFlattenedDbResult() { - // When the user deletes an inline attachment, the PATCH data has - // profilePicture_content: null. The DB result has flattened column names - // (profilePicture_contentId). The handler must extract the existing contentId - // from the flattened DB result so the event factory can return deleteEvent. - getEntityAndMockContext(RootTable_.CDS_NAME); - - String bookId = UUID.randomUUID().toString(); - String existingContentId = UUID.randomUUID().toString(); - - // Incoming data: user deleting the inline attachment (content = null) - var data = CdsData.create(); - data.put("ID", bookId); - data.put("profilePicture_content", null); - - // DB result: existing draft row with flattened inline attachment fields - var dbRow = Attachments.create(); - dbRow.put("ID", bookId); - dbRow.put("profilePicture_contentId", existingContentId); - dbRow.put("profilePicture_status", "Clean"); - dbRow.put("profilePicture_mimeType", "image/png"); - dbRow.put("profilePicture_fileName", "avatar.png"); - - var result = mock(Result.class); - when(persistence.run(any(CqnSelect.class))).thenReturn(result); - when(result.listOf(Attachments.class)).thenReturn(List.of(dbRow)); - - cut.processBeforeDraftPatch(eventContext, List.of(data)); - - // Verify the event factory receives an Attachments with the correctly extracted - // (unprefixed) contentId from the DB data - ArgumentCaptor attachmentCaptor = ArgumentCaptor.forClass(Attachments.class); - verify(eventFactory).getEvent(any(), any(), attachmentCaptor.capture()); - Attachments captured = attachmentCaptor.getValue(); - assertThat(captured.getContentId()).isEqualTo(existingContentId); - } - - // --- persistInlineAttachmentMetadata Tests --- - - @Test - void inlinePatchPersistsMetadataWhenContentIdMimeTypeAndFileNamePresent() { - getEntityAndMockContext(RootTable_.CDS_NAME); - - var data = CdsData.create(); - data.put("ID", UUID.randomUUID().toString()); - data.put("profilePicture_content", mock(InputStream.class)); - - var result = mock(Result.class); - when(persistence.run(any(CqnSelect.class))).thenReturn(result); - - // The event.processEvent simulates CreateAttachmentEvent putting metadata into data - when(event.processEvent(any(), any(), any(), any(), any())) - .thenAnswer( - invocation -> { - data.put("profilePicture_contentId", "cid-123"); - data.put("profilePicture_mimeType", "image/png"); - data.put("profilePicture_fileName", "photo.png"); - return null; - }); - - cut.processBeforeDraftPatch(eventContext, List.of(data)); - - ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); - verify(persistence).run(updateCaptor.capture()); - CqnUpdate update = updateCaptor.getValue(); - assertThat(update.entries()).isNotEmpty(); - assertThat(update.entries().get(0)).containsEntry("profilePicture_mimeType", "image/png"); - assertThat(update.entries().get(0)).containsEntry("profilePicture_fileName", "photo.png"); - } - - @Test - void inlinePatchSkipsWhenContentIdNull() { - getEntityAndMockContext(RootTable_.CDS_NAME); - - var data = CdsData.create(); - data.put("ID", UUID.randomUUID().toString()); - data.put("profilePicture_content", mock(InputStream.class)); - - var result = mock(Result.class); - when(persistence.run(any(CqnSelect.class))).thenReturn(result); - - // processEvent does NOT put profilePicture_contentId → contentId remains null - when(event.processEvent(any(), any(), any(), any(), any())).thenReturn(null); - - cut.processBeforeDraftPatch(eventContext, List.of(data)); - - verify(persistence, never()).run(any(CqnUpdate.class)); - } - - @Test - void inlinePatchSkipsUpdateWhenNoMetadata() { - getEntityAndMockContext(RootTable_.CDS_NAME); - - var data = CdsData.create(); - data.put("ID", UUID.randomUUID().toString()); - data.put("profilePicture_content", mock(InputStream.class)); - - var result = mock(Result.class); - when(persistence.run(any(CqnSelect.class))).thenReturn(result); - - // processEvent puts contentId but no mimeType/fileName - when(event.processEvent(any(), any(), any(), any(), any())) - .thenAnswer( - invocation -> { - data.put("profilePicture_contentId", "cid-456"); - return null; - }); - - cut.processBeforeDraftPatch(eventContext, List.of(data)); - - verify(persistence, never()).run(any(CqnUpdate.class)); - } - - @Test - void inlinePatchPersistsOnlyMimeType() { - getEntityAndMockContext(RootTable_.CDS_NAME); - - var data = CdsData.create(); - data.put("ID", UUID.randomUUID().toString()); - data.put("profilePicture_content", mock(InputStream.class)); - - var result = mock(Result.class); - when(persistence.run(any(CqnSelect.class))).thenReturn(result); - - when(event.processEvent(any(), any(), any(), any(), any())) - .thenAnswer( - invocation -> { - data.put("profilePicture_contentId", "cid-789"); - data.put("profilePicture_mimeType", "text/plain"); - return null; - }); - - cut.processBeforeDraftPatch(eventContext, List.of(data)); - - ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); - verify(persistence).run(updateCaptor.capture()); - CqnUpdate update = updateCaptor.getValue(); - assertThat(update.entries().get(0)).containsEntry("profilePicture_mimeType", "text/plain"); - assertThat(update.entries().get(0)).doesNotContainKey("profilePicture_fileName"); - } - - @Test - void inlinePatchPersistsOnlyFileName() { - getEntityAndMockContext(RootTable_.CDS_NAME); - - var data = CdsData.create(); - data.put("ID", UUID.randomUUID().toString()); - data.put("profilePicture_content", mock(InputStream.class)); - - var result = mock(Result.class); - when(persistence.run(any(CqnSelect.class))).thenReturn(result); - - when(event.processEvent(any(), any(), any(), any(), any())) - .thenAnswer( - invocation -> { - data.put("profilePicture_contentId", "cid-000"); - data.put("profilePicture_fileName", "document.pdf"); - return null; - }); - - cut.processBeforeDraftPatch(eventContext, List.of(data)); - - ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); - verify(persistence).run(updateCaptor.capture()); - CqnUpdate update = updateCaptor.getValue(); - assertThat(update.entries().get(0)).containsEntry("profilePicture_fileName", "document.pdf"); - assertThat(update.entries().get(0)).doesNotContainKey("profilePicture_mimeType"); - } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImplTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImplTest.java index d287bd8dc..4e852cf8b 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImplTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImplTest.java @@ -139,64 +139,6 @@ void createAttachmentExternalCreateNotFilledReturnedFalse() { assertThat(result.isInternalStored()).isFalse(); } - @Test - void createAttachmentWithInlinePrefixPutsItInContext() { - var contextReference = new AtomicReference(); - doAnswer( - input -> { - var context = (AttachmentCreateEventContext) input.getArgument(0); - contextReference.set(context); - context.setCompleted(); - return null; - }) - .when(handler) - .process(any()); - serviceSpi.on(AttachmentService.EVENT_CREATE_ATTACHMENT, "", handler); - Map ids = Map.of("ID1", "value1"); - var input = - new CreateAttachmentInput( - ids, - mock(CdsEntity.class), - "fileName", - "mimeType", - mock(InputStream.class), - Optional.of("profileIcon")); - - cut.createAttachment(input); - - var createContext = contextReference.get(); - assertThat(createContext.get("attachment.inlinePrefix")).isEqualTo("profileIcon"); - } - - @Test - void createAttachmentWithoutInlinePrefixDoesNotSetContext() { - var contextReference = new AtomicReference(); - doAnswer( - input -> { - var context = (AttachmentCreateEventContext) input.getArgument(0); - contextReference.set(context); - context.setCompleted(); - return null; - }) - .when(handler) - .process(any()); - serviceSpi.on(AttachmentService.EVENT_CREATE_ATTACHMENT, "", handler); - Map ids = Map.of("ID1", "value1"); - var input = - new CreateAttachmentInput( - ids, - mock(CdsEntity.class), - "fileName", - "mimeType", - mock(InputStream.class), - Optional.empty()); - - cut.createAttachment(input); - - var createContext = contextReference.get(); - assertThat(createContext.get("attachment.inlinePrefix")).isNull(); - } - @Test void markAsDeleteAttachmentInsertsData() { var contextReference = new AtomicReference(); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java index 4dc81b183..ce8194c92 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java @@ -108,26 +108,6 @@ void malwareScannerRegisteredForEndOfTransaction() { verify(malwareScanProvider).getChangeSetListener(entity, "contentId", Optional.empty()); } - @Test - void malwareScannerRegisteredWithInlinePrefixFromContext() { - var listener = mock(ChangeSetListener.class); - var entity = mock(CdsEntity.class); - when(malwareScanProvider.getChangeSetListener(entity, "contentId", Optional.of("profileIcon"))) - .thenReturn(listener); - var createContext = AttachmentCreateEventContext.create(); - createContext.setAttachmentIds(Map.of(Attachments.ID, "contentId")); - createContext.setData(MediaData.create()); - createContext.setAttachmentEntity(entity); - createContext.put("attachment.inlinePrefix", "profileIcon"); - ChangeSetContextImpl.open(false); - - cut.createAttachment(createContext); - cut.afterCreateAttachment(createContext); - - verify(malwareScanProvider) - .getChangeSetListener(entity, "contentId", Optional.of("profileIcon")); - } - @Test void createAttachment_emptyAttachmentIds_handlesGracefully() { var createContext = AttachmentCreateEventContext.create(); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java index fbf30c3e2..1fc187f22 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java @@ -14,7 +14,6 @@ import static org.mockito.Mockito.when; import com.sap.cds.Result; -import com.sap.cds.Row; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.StatusCode; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Attachment_; @@ -368,41 +367,4 @@ private void verifyKeyWhereCondition(CqnSelect select) { .contains( "[{\"ref\":[\"contentId\"]},\"=\",{\"val\":\"ID\"},\"and\",{\"ref\":[\"status\"]},\"<>\",{\"val\":\"Clean\"}]"); } - - @Test - void resolveColumnWithPrefixConcatenates() { - String result = - DefaultAttachmentMalwareScanner.resolveColumn("contentId", Optional.of("avatar")); - assertThat(result).isEqualTo("avatar_contentId"); - } - - @Test - void resolveColumnWithoutPrefixReturnsFieldName() { - String result = DefaultAttachmentMalwareScanner.resolveColumn("contentId", Optional.empty()); - assertThat(result).isEqualTo("contentId"); - } - - @Test - void scanAttachmentWithInlinePrefixExtractsFromPrefixedColumns() { - var entity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME); - var content = mock(InputStream.class); - - // Build a row with prefixed column names as the inline path expects - var row = mock(Row.class); - when(row.get("avatar_contentId")).thenReturn("cid-inline"); - when(row.get("avatar_content")).thenReturn(content); - when(row.get("avatar_status")).thenReturn("Unscanned"); - - when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); - when(result.rowCount()).thenReturn(1L); - when(result.single()).thenReturn(row); - when(malwareScanClient.scanContent(any())).thenReturn(MalwareScanResultStatus.CLEAN); - - cut.scanAttachment(entity.orElseThrow(), "cid-inline", Optional.of("avatar")); - - verify(malwareScanClient).scanContent(content); - verify(persistenceService).run(updateCaptor.capture()); - CqnUpdate update = updateCaptor.getValue(); - assertThat(update.entries().get(0)).containsEntry("avatar_status", StatusCode.CLEAN); - } } diff --git a/pom.xml b/pom.xml index 561b763dd..86fb0a7ee 100644 --- a/pom.xml +++ b/pom.xml @@ -76,8 +76,8 @@ 9.9.0 - 4.8.0 - 9.7.2 + 4.9.0 + 9.9.0 true From f8e32c01186291a71dbbf9187e2a18acd673753a Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 27 Apr 2026 09:02:39 +0200 Subject: [PATCH 29/45] remove pitest --- .github/actions/build/action.yml | 9 --------- CLAUDE.md | 1 - cds-feature-attachments/pom.xml | 34 -------------------------------- doc/Design.md | 21 +++----------------- pom.xml | 5 ----- 5 files changed, 3 insertions(+), 67 deletions(-) diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml index a6fd34568..f1960655b 100644 --- a/.github/actions/build/action.yml +++ b/.github/actions/build/action.yml @@ -8,10 +8,6 @@ inputs: maven-version: description: The Maven version the build will run with. required: true - mutation-testing: - description: Whether to run mutation testing or not. - default: 'true' - required: false runs: using: composite @@ -33,8 +29,3 @@ runs: with: step-name: mavenBuild docker-image: '' - - - name: Mutation Testing - if: ${{ inputs.mutation-testing == 'true' }} - run: mvn org.pitest:pitest-maven:mutationCoverage -f cds-feature-attachments/pom.xml -ntp -B - shell: bash diff --git a/CLAUDE.md b/CLAUDE.md index 49cf9e133..22734dc62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,7 +122,6 @@ Defined in `cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-featu All enforced in CI: - **JaCoCo:** 95% minimum (instruction, branch, complexity), 0 missed classes -- **Mutation testing (Pitest):** 90% aggregated threshold on `handler.*` and `service.*` - **SpotBugs:** max effort, includes tests - **PMD:** SAP Cloud SDK rules, excludes generated code and tests - **Spotless:** Google Java Format check diff --git a/cds-feature-attachments/pom.xml b/cds-feature-attachments/pom.xml index 44bec84e4..dda78a2d7 100644 --- a/cds-feature-attachments/pom.xml +++ b/cds-feature-attachments/pom.xml @@ -92,40 +92,6 @@ ${project.artifactId} - - org.pitest - pitest-maven - - - com.sap.cds.feature.attachments.handler.* - com.sap.cds.feature.attachments.service.* - - - CONSTRUCTOR_CALLS - VOID_METHOD_CALLS - NON_VOID_METHOD_CALLS - REMOVE_CONDITIONALS_ORDER_ELSE - CONDITIONALS_BOUNDARY - EMPTY_RETURNS - NEGATE_CONDITIONALS - REMOVE_CONDITIONALS_EQUAL_IF - REMOVE_CONDITIONALS_EQUAL_ELSE - REMOVE_CONDITIONALS_ORDER_IF - REMOVE_CONDITIONALS_ORDER_ELSE - - 95 - 90 - - - - - org.pitest - pitest-junit5-plugin - 1.2.3 - - - - maven-clean-plugin diff --git a/doc/Design.md b/doc/Design.md index 1945024c5..afc970ae0 100644 --- a/doc/Design.md +++ b/doc/Design.md @@ -51,7 +51,6 @@ - [Texts](#texts) - [Tests](#tests) - [Unit Tests](#unit-tests) - - [Mutation Tests](#mutation-tests) - [Integration Tests](#integration-tests) - [Quality Tools](#quality-tools) @@ -93,21 +92,19 @@ In folder `.github/workflows` are the GitHub Actions defined. The following tabl | File Name | Description | | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pr.yml` | Builds and tests pull requests for Java 17 and 21. Requires approval for external forks. Each pull request needs green runs from this workflow to be merged. | -| `main.yml` | Builds, tests, and deploys snapshots when commits are merged to main. Runs unit tests, integration tests, and mutation tests for Java 17 and 21. | +| `main.yml` | Builds, tests, and deploys snapshots when commits are merged to main. Runs unit tests and integration tests for Java 17 and 21. | | `release.yml` | Triggered on GitHub releases. Updates version, runs BlackDuck scan, builds, tests, and deploys to Maven Central. See also [Build and Deploy](#build-and-deploy). | | `pipeline.yml` | Reusable workflow containing shared build, test, integration test, SonarQube scan, CodeQL analysis, and snapshot deployment logic. Called by `pr.yml` and `main.yml`. | ### Build Action The build step is implemented in action `.github/actions/build/action.yml` which is used in the workflows via `pipeline.yml`. -As the build action does not only run a build of the project, but also the mutation tests, this action is used in all -the mentioned workflows. Additional reusable actions are defined in `.github/actions/`: | Action | Description | | --------------------- | ----------------------------------------------------------- | -| `build` | Builds the project and runs unit/mutation tests | +| `build` | Builds the project and runs unit tests | | `integration-tests` | Runs integration tests (build-version, latest-version, oss) | | `deploy-release` | Deploys release artifacts to Maven Central | | `newrelease` | Updates version in pom.xml for new releases | @@ -142,7 +139,7 @@ The following steps are executed in the workflow: 1. Update the version in the `pom.xml` files. The tag used in the release is read and git commands are used to update the property `revision` in the parent `pom.xml` file. -2. Build the project and run all unit, integration and mutation tests. Here a reuse action is used which is also +2. Build the project and run all unit and integration tests. Here a reuse action is used which is also executed in the main and pull request build. 3. Deploy the project to maven or artifactory. The deployment is done with the maven command `mvn deploy`. The deployment is done to the repository defined in the `pom.xml` file. So only project parts which have defined the @@ -660,18 +657,6 @@ The following settings are used for this plugin: | Complexity Coverage | 95% | | Class Missed Count | 0 | -#### Mutation Tests - -In addition to this plugin, also mutation tests are executed during the build of the project in the GitHub Actions. -To run the mutation tests the plugin `pitest-maven` is included in the same pom. - -Several mutators are maintained in the plugin and the following settings are used: - -| Setting | Value | -| ----------------------------- | ----- | -| Coverage Threshold | 95% | -| Aggregated Mutation Threshold | 90% | - ### Integration Tests Spring Boot tests are implemented in the `integration-tests` folder. diff --git a/pom.xml b/pom.xml index 86fb0a7ee..f25015323 100644 --- a/pom.xml +++ b/pom.xml @@ -238,11 +238,6 @@ jacoco-maven-plugin 0.8.14 - - org.pitest - pitest-maven - 1.23.0 - com.github.spotbugs spotbugs-maven-plugin From 0c1ad1e84ec19b4fd0e77c1e5a68b6b877294aeb Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 27 Apr 2026 09:02:49 +0200 Subject: [PATCH 30/45] add more tests --- .../ReadAttachmentsHandlerTest.java | 43 +++++++ .../ModifyApplicationHandlerHelperTest.java | 111 ++++++++++++++++++ .../ReadonlyDataContextEnhancerTest.java | 70 +++++++++++ .../common/ApplicationHandlerHelperTest.java | 87 ++++++++++++++ .../DraftCancelAttachmentsHandlerTest.java | 59 ++++++++++ .../DefaultAttachmentMalwareScannerTest.java | 34 ++++++ .../src/test/resources/cds/db-model.cds | 4 + .../SingleAttachmentDraftTest.java | 23 ++++ .../SingleAttachmentNonDraftTest.java | 12 ++ integration-tests/generic/test-service.cds | 4 + 10 files changed, 447 insertions(+) diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java index ff2226801..87fd6a142 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java @@ -19,6 +19,7 @@ import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.EventItems; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.EventItems_; 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.InlineOnly_; 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_; @@ -467,6 +468,48 @@ void scannerNotAvailable_unscannedAttachmentStillFailsValidation() { verifyNoInteractions(persistenceService); } + @Test + void processBeforeWithInlineOnlyEntity() { + var select = Select.from(InlineOnly_.class).columns(InlineOnly_::ID); + mockEventContext(InlineOnly_.CDS_NAME, select); + + cut.processBefore(readEventContext); + } + + @Test + void processBeforeWithBothCompositionAndInlineEntity() { + var select = Select.from(RootTable_.class).columns(RootTable_::ID); + mockEventContext(RootTable_.CDS_NAME, select); + + cut.processBefore(readEventContext); + } + + @Test + void processAfterWithInlineAttachmentData() { + mockEventContext(RootTable_.CDS_NAME, mock(CqnSelect.class)); + var root = RootTable.create(); + root.setProfilePictureContentId("inline-cid"); + root.setProfilePictureContent(mock(InputStream.class)); + root.setProfilePictureStatus(StatusCode.CLEAN); + root.setProfilePictureScannedAt(Instant.now()); + + cut.processAfter(readEventContext, List.of(root)); + + assertThat(root.getProfilePictureContent()).isInstanceOf(LazyProxyInputStream.class); + } + + @Test + void processAfterWithInlineAttachmentWithoutContentIdReturnsOriginalValue() { + mockEventContext(RootTable_.CDS_NAME, mock(CqnSelect.class)); + var root = RootTable.create(); + var originalStream = mock(InputStream.class); + root.setProfilePictureContent(originalStream); + + cut.processAfter(readEventContext, List.of(root)); + + assertThat(root.getProfilePictureContent()).isSameAs(originalStream); + } + private void mockEventContext(String entityName, CqnSelect select) { var serviceEntity = runtime.getCdsModel().findEntity(entityName); when(readEventContext.getTarget()).thenReturn(serviceEntity.orElseThrow()); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java index b000fa557..060a8d12a 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java @@ -219,4 +219,115 @@ void malformedContentLengthHeader() { assertThat(exception.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST); } + + @Test + void inlineAttachmentSizeLimitFromAnnotation() { + String inlineOnlyEntityName = "unit.test.TestService.InlineOnly"; + CdsEntity entity = runtime.getCdsModel().findEntity(inlineOnlyEntityName).orElseThrow(); + + var data = Attachments.create(); + data.setId(UUID.randomUUID().toString()); + data.put("avatar_contentId", "inline-cid"); + data.put("avatar_content", mock(InputStream.class)); + + when(target.entity()).thenReturn(entity); + when(target.values()).thenReturn(data); + when(target.keys()).thenReturn(Map.of("ID", data.getId())); + + when(parameterInfo.getHeader("Content-Length")).thenReturn("20000"); + + var existingAttachments = List.of(); + + var exception = + assertThrows( + ServiceException.class, + () -> + ModifyApplicationHandlerHelper.handleAttachmentForEntity( + existingAttachments, + eventFactory, + eventContext, + path, + (InputStream) data.get("avatar_content"), + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + Optional.of("avatar"))); + + assertThat(exception.getErrorStatus()).isEqualTo(ExtendedErrorStatuses.CONTENT_TOO_LARGE); + } + + @Test + void inlineAttachmentWithinSizeLimitSucceeds() { + String inlineOnlyEntityName = "unit.test.TestService.InlineOnly"; + CdsEntity entity = runtime.getCdsModel().findEntity(inlineOnlyEntityName).orElseThrow(); + + var data = Attachments.create(); + data.setId(UUID.randomUUID().toString()); + data.put("avatar_contentId", "inline-cid"); + var content = mock(InputStream.class); + data.put("avatar_content", content); + + when(target.entity()).thenReturn(entity); + when(target.values()).thenReturn(data); + when(target.keys()).thenReturn(Map.of("ID", data.getId())); + + when(parameterInfo.getHeader("Content-Length")).thenReturn("5000"); + + var existingAttachments = List.of(); + + assertDoesNotThrow( + () -> + ModifyApplicationHandlerHelper.handleAttachmentForEntity( + existingAttachments, + eventFactory, + eventContext, + path, + content, + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + Optional.of("avatar"))); + } + + @Test + void streamingLimitExceededOnInlineAttachment() { + String inlineOnlyEntityName = "unit.test.TestService.InlineOnly"; + CdsEntity entity = runtime.getCdsModel().findEntity(inlineOnlyEntityName).orElseThrow(); + + var data = Attachments.create(); + data.setId(UUID.randomUUID().toString()); + data.put("avatar_contentId", "inline-cid"); + byte[] largeContent = new byte[15000]; // 15KB > 10KB limit + var content = new ByteArrayInputStream(largeContent); + data.put("avatar_content", content); + + when(target.entity()).thenReturn(entity); + when(target.values()).thenReturn(data); + when(target.keys()).thenReturn(Map.of("ID", data.getId())); + when(parameterInfo.getHeader("Content-Length")).thenReturn(null); + + when(event.processEvent(any(), any(), any(), any(), any())) + .thenAnswer( + invocation -> { + InputStream wrappedContent = invocation.getArgument(1); + if (wrappedContent != null) { + byte[] buffer = new byte[1024]; + while (wrappedContent.read(buffer) != -1) {} + } + return null; + }); + + var existingAttachments = List.of(); + + var exception = + assertThrows( + ServiceException.class, + () -> + ModifyApplicationHandlerHelper.handleAttachmentForEntity( + existingAttachments, + eventFactory, + eventContext, + path, + content, + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + Optional.of("avatar"))); + + assertThat(exception.getErrorStatus()).isEqualTo(ExtendedErrorStatuses.CONTENT_TOO_LARGE); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java index f56b73476..e8c452862 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java @@ -7,6 +7,7 @@ import com.sap.cds.CdsData; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Events_; 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.RootTable_; @@ -145,4 +146,73 @@ void preserveReadonlyFields_isNotDraft_noExistingBackup_nothingHappens() { assertThat(attachment.get(DRAFT_READONLY_CONTEXT)).isNull(); } + + // --- Inline attachment preserve/restore tests --- + + @Test + void preserveReadonlyFieldsForDraftInlineAttachment() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + CdsData data = CdsData.create(); + data.put("profilePicture_content", new ByteArrayInputStream(new byte[0])); + data.put("profilePicture_contentId", "inline-cid"); + data.put("profilePicture_status", "Clean"); + Instant now = Instant.now(); + data.put("profilePicture_scannedAt", now); + data.put("profilePicture_fileName", "photo.jpg"); + + ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), true); + + CdsData backup = (CdsData) data.get("profilePicture_" + DRAFT_READONLY_CONTEXT); + assertThat(backup).isNotNull(); + assertThat(backup.get(Attachments.CONTENT_ID)).isEqualTo("inline-cid"); + assertThat(backup.get(Attachments.STATUS)).isEqualTo("Clean"); + assertThat(backup.get(Attachments.SCANNED_AT)).isEqualTo(now); + assertThat(backup.get(MediaData.FILE_NAME)).isEqualTo("photo.jpg"); + } + + @Test + void restoreReadonlyFieldsForInlineAttachment() { + CdsData data = CdsData.create(); + Attachments backup = Attachments.create(); + backup.setContentId("inline-restored-cid"); + backup.setStatus("Scanning"); + Instant scannedAt = Instant.now(); + backup.setScannedAt(scannedAt); + data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, backup); + + ReadonlyDataContextEnhancer.restoreReadonlyFields(data); + + assertThat(data.get("profilePicture_contentId")).isEqualTo("inline-restored-cid"); + assertThat(data.get("profilePicture_status")).isEqualTo("Scanning"); + assertThat(data.get("profilePicture_scannedAt")).isEqualTo(scannedAt); + assertThat(data.containsKey("profilePicture_" + DRAFT_READONLY_CONTEXT)).isFalse(); + } + + @Test + void restoreReadonlyFieldsForInlineAttachmentWithFileName() { + CdsData data = CdsData.create(); + Attachments backup = Attachments.create(); + backup.setContentId("inline-cid"); + backup.setStatus("Clean"); + backup.setFileName("preserved-file.pdf"); + data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, backup); + + ReadonlyDataContextEnhancer.restoreReadonlyFields(data); + + assertThat(data.get("profilePicture_contentId")).isEqualTo("inline-cid"); + assertThat(data.get("profilePicture_fileName")).isEqualTo("preserved-file.pdf"); + assertThat(data.containsKey("profilePicture_" + DRAFT_READONLY_CONTEXT)).isFalse(); + } + + @Test + void preserveReadonlyFieldsNonDraftRemovesInlinePrefixedContext() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + CdsData data = CdsData.create(); + data.put("profilePicture_content", new ByteArrayInputStream(new byte[0])); + data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, Attachments.create()); + + ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), false); + + assertThat(data.containsKey("profilePicture_" + DRAFT_READONLY_CONTEXT)).isFalse(); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java index 3daa9ff27..a0de90824 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java @@ -8,9 +8,17 @@ import static org.junit.jupiter.api.Assertions.assertTrue; 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.EventItems_; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.InlineOnly_; +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.runtime.CdsRuntime; +import java.util.List; import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -67,4 +75,83 @@ void removeDraftKey() { assertFalse(result.containsKey("IsActiveEntity")); assertTrue(result.containsKey("key1")); } + + @Test + void getInlineAttachmentFieldNamesReturnsPrefix() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + List fieldNames = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); + + assertThat(fieldNames).contains("profilePicture"); + } + + @Test + void getInlineAttachmentFieldNamesReturnsEmptyForNonInlineEntity() { + CdsEntity entity = runtime.getCdsModel().findEntity(EventItems_.CDS_NAME).orElseThrow(); + List fieldNames = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); + + assertThat(fieldNames).isEmpty(); + } + + @Test + void getInlineAttachmentPrefixReturnsMatchingPrefix() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + Optional prefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix(entity, "profilePicture_content"); + + assertThat(prefix).isPresent().hasValue("profilePicture"); + } + + @Test + void getInlineAttachmentPrefixReturnsEmptyForNonMatchingElement() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + Optional prefix = ApplicationHandlerHelper.getInlineAttachmentPrefix(entity, "title"); + + assertThat(prefix).isEmpty(); + } + + @Test + void condenseAttachmentsExtractsInlineAttachments() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + var data = CdsData.create(); + data.put(RootTable.PROFILE_PICTURE_CONTENT_ID, "inline-cid-1"); + data.put(RootTable.PROFILE_PICTURE_STATUS, "Clean"); + data.put(RootTable.PROFILE_PICTURE_CONTENT, null); + + List result = ApplicationHandlerHelper.condenseAttachments(List.of(data), entity); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getContentId()).isEqualTo("inline-cid-1"); + assertThat(result.get(0).get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER)) + .isEqualTo("profilePicture"); + } + + @Test + void condenseAttachmentsDedupsByContentId() { + CdsEntity entity = runtime.getCdsModel().findEntity(InlineOnly_.CDS_NAME).orElseThrow(); + var data = CdsData.create(); + data.put("avatar_contentId", "same-cid"); + data.put("avatar_content", null); + + List result = ApplicationHandlerHelper.condenseAttachments(List.of(data), entity); + + assertThat(result).hasSize(1); + } + + @Test + void extractInlineAttachmentExtractsPrefixedFields() { + Map parentValues = + Map.of( + "profilePicture_contentId", "cid-123", + "profilePicture_status", "Clean", + "title", "test root"); + + Attachments attachment = + ApplicationHandlerHelper.extractInlineAttachment(parentValues, "profilePicture"); + + assertThat(attachment.getContentId()).isEqualTo("cid-123"); + assertThat(attachment.getStatus()).isEqualTo("Clean"); + assertThat(attachment.get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER)) + .isEqualTo("profilePicture"); + assertThat(attachment.containsKey("title")).isFalse(); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java index e2e6a7979..181ec13ec 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java @@ -8,9 +8,11 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; +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.Attachment; 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.RootTable; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.MarkAsDeletedAttachmentEvent; import com.sap.cds.feature.attachments.handler.common.AttachmentsReader; @@ -272,6 +274,63 @@ void noMatchingActiveEntryForDraftAttachment() { verifyNoInteractions(deleteContentAttachmentEvent); } + @Test + void inlineAttachmentWithoutActiveEntityDeletesContent() { + getEntityAndMockContext(RootTable_.CDS_NAME); + CqnDelete delete = Delete.from(RootTable_.class); + when(eventContext.getCqn()).thenReturn(delete); + when(eventContext.getModel()).thenReturn(runtime.getCdsModel()); + when(eventContext.getEvent()).thenReturn("DRAFT_CANCEL"); + + var id = UUID.randomUUID().toString(); + + CdsData draftRoot = CdsData.create(); + draftRoot.put(RootTable.ID, id); + draftRoot.put(RootTable.PROFILE_PICTURE_CONTENT_ID, "new-content-id"); + draftRoot.put(Drafts.HAS_ACTIVE_ENTITY, false); + + when(attachmentsReader.readAttachments(any(), any(), any())) + .thenReturn(List.of(Attachments.of(draftRoot))) + .thenReturn(List.of()); + + cut.processBeforeDraftCancel(eventContext); + + verify(deleteContentAttachmentEvent) + .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext), any()); + assertThat(dataArgumentCaptor.getValue().getContentId()).isEqualTo("new-content-id"); + assertThat(dataArgumentCaptor.getValue().get("__inlinePrefix")).isEqualTo("profilePicture"); + } + + @Test + void inlineAttachmentWithActiveEntityAndSameContentIdDoesNotDelete() { + getEntityAndMockContext(RootTable_.CDS_NAME); + CqnDelete delete = Delete.from(RootTable_.class); + when(eventContext.getCqn()).thenReturn(delete); + when(eventContext.getModel()).thenReturn(runtime.getCdsModel()); + when(eventContext.getEvent()).thenReturn("DRAFT_CANCEL"); + + var id = UUID.randomUUID().toString(); + var contentId = UUID.randomUUID().toString(); + + CdsData draftRoot = CdsData.create(); + draftRoot.put(RootTable.ID, id); + draftRoot.put(RootTable.PROFILE_PICTURE_CONTENT_ID, contentId); + draftRoot.put(Drafts.HAS_ACTIVE_ENTITY, true); + + CdsData activeRoot = CdsData.create(); + activeRoot.put(RootTable.ID, id); + activeRoot.put(RootTable.PROFILE_PICTURE_CONTENT_ID, contentId); + activeRoot.put(RootTable.PROFILE_PICTURE_CONTENT, null); + + when(attachmentsReader.readAttachments(any(), any(), any())) + .thenReturn(List.of(Attachments.of(draftRoot))) + .thenReturn(List.of(Attachments.of(activeRoot))); + + cut.processBeforeDraftCancel(eventContext); + + verifyNoInteractions(deleteContentAttachmentEvent); + } + private Attachment buildAttachmentAndReturnByReader( String contentId, CdsEntity target, boolean hasActiveEntity, String id) { Attachment attachment = Attachment.create(); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java index 1fc187f22..6da4ecff9 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java @@ -14,6 +14,7 @@ import static org.mockito.Mockito.when; import com.sap.cds.Result; +import com.sap.cds.Row; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.StatusCode; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Attachment_; @@ -314,6 +315,39 @@ void noScanOrUpdateWhenAttachmentNotFoundInAnyEntity() { verify(persistenceService, times(0)).run(any(CqnUpdate.class)); } + @Test + void scanAttachmentWithInlinePrefixExtractsFromPrefixedColumns() { + var entity = + runtime + .getCdsModel() + .findEntity( + com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice + .InlineOnly_.CDS_NAME) + .orElseThrow(); + var content = mock(InputStream.class); + var row = mock(Row.class); + when(row.get("avatar_contentId")).thenReturn("inline-cid"); + when(row.get("avatar_content")).thenReturn(content); + when(row.get("avatar_status")).thenReturn(StatusCode.UNSCANNED); + when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); + when(result.rowCount()).thenReturn(1L); + when(result.single()).thenReturn(row); + when(malwareScanClient.scanContent(any())).thenReturn(MalwareScanResultStatus.CLEAN); + + cut.scanAttachment(entity, "inline-cid", Optional.of("avatar")); + + verify(malwareScanClient).scanContent(content); + verify(persistenceService, times(2)).run(updateCaptor.capture()); + var updates = updateCaptor.getAllValues(); + assertThat(updates).hasSize(2); + updates.forEach( + update -> { + assertThat(update.entries()).hasSize(1); + assertThat(update.entries().get(0)).containsEntry("avatar_status", StatusCode.CLEAN); + assertThat(update.entries().get(0)).containsKey("avatar_scannedAt"); + }); + } + @Test void mapStatus() { assertEquals( 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 8a51e9127..09bce4ffb 100644 --- a/cds-feature-attachments/src/test/resources/cds/db-model.cds +++ b/cds-feature-attachments/src/test/resources/cds/db-model.cds @@ -58,3 +58,7 @@ annotate EventItems.defaultSizeLimitedAttachments with { content @Validation.Maximum; }; +annotate InlineOnly:avatar with { + content @Validation.Maximum: '10KB'; +}; + diff --git a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java index 8761410e6..4a16f8661 100644 --- a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java +++ b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java @@ -34,6 +34,8 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; @SpringBootTest @AutoConfigureMockMvc @@ -48,6 +50,7 @@ class SingleAttachmentDraftTest { @Autowired private PersistenceService persistenceService; @Autowired private TableDataDeleter dataDeleter; @Autowired private TestPersistenceHandler testPersistenceHandler; + @Autowired private MockMvc mvc; @AfterEach void teardown() { @@ -263,6 +266,26 @@ void errorInTransactionAfterCreateCallsDelete() throws Exception { assertThat(deleteContext.getContentId()).isEqualTo(createContext.getContentId()); } + @Test + void uploadWithContentDispositionHeaderInDraftPersistsFileName() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + + var contentUrl = draftRootUrl + "/avatar_content"; + mvc.perform( + MockMvcRequestBuilders.put(contentUrl) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header("Content-Disposition", "attachment; filename=\"draft-file.png\"") + .content("draft-content".getBytes(StandardCharsets.UTF_8))) + .andExpect(status().isNoContent()); + + prepareAndActivateDraft(draftRootUrl); + + var activeRoot = selectActiveRoot(draft.getId()); + assertThat(activeRoot.getAvatarContentId()).isNotEmpty(); + assertThat(activeRoot.getAvatarFileName()).isEqualTo("draft-file.png"); + } + // Helper methods private DraftRoots createNewDraft() throws Exception { diff --git a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java index 62cb41ef1..fcfc4408d 100644 --- a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java +++ b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java @@ -686,6 +686,18 @@ void malwareScanStatusIsCleanAfterUpload() throws Exception { assertThat(rootAfterPut.getAvatarScannedAt()).isNotNull(); } + @Test + void putOversizedContentToCoverImageReturnsError() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var url = buildRootUrl(selectedRoot.getId()) + "/coverImage_content"; + byte[] oversizedContent = new byte[6 * 1024 * 1024]; // 6MB > 5MB limit + requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher(url, oversizedContent, status().is4xxClientError()); + } + private Roots buildRootWithoutContent() { var root = Roots.create(); root.setTitle("root with inline attachment"); diff --git a/integration-tests/generic/test-service.cds b/integration-tests/generic/test-service.cds index ff68a31ff..163ac0f4d 100644 --- a/integration-tests/generic/test-service.cds +++ b/integration-tests/generic/test-service.cds @@ -16,6 +16,10 @@ annotate db.Roots.mimeValidatedAttachments with { content @(Core.AcceptableMediaTypes: ['application/pdf']); } +annotate db.Roots:coverImage with { + content @Validation.Maximum: '5MB'; +}; + service TestService { entity Roots as projection on db.Roots; entity AttachmentEntity as projection on db.AttachmentEntity; From 73a5f7548aa993c66aa7fa44e1a65af4887689e0 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 27 Apr 2026 10:53:32 +0200 Subject: [PATCH 31/45] improve inline for val max and also draft cancel --- .../ModifyApplicationHandlerHelper.java | 10 +++--- .../DraftCancelAttachmentsHandler.java | 14 ++++++-- .../DraftCancelAttachmentsHandlerTest.java | 35 ++++++++++++++++++- .../SingleAttachmentDraftTest.java | 31 ++++++++++++++++ 4 files changed, 83 insertions(+), 7 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java index 0cd29f65d..3f8ee707f 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java @@ -105,7 +105,7 @@ public static InputStream handleAttachmentForEntity( } String contentLength = eventContext.getParameterInfo().getHeader("Content-Length"); - String maxSizeStr = getValMaxValue(path.target().entity(), defaultMaxSize); + String maxSizeStr = getValMaxValue(path.target().entity(), defaultMaxSize, inlinePrefix); eventContext.put( "attachment.MaxSize", maxSizeStr); // make max size available in context for error handling later @@ -139,13 +139,15 @@ public static InputStream handleAttachmentForEntity( } } - private static String getValMaxValue(CdsEntity entity, String defaultMaxSize) { - // Try direct content element first (composition-based) + private static String getValMaxValue( + CdsEntity entity, String defaultMaxSize, Optional inlinePrefix) { return entity .findElement("content") .or( () -> { - // Try inline attachment content elements (e.g. profilePicture_content) + if (inlinePrefix.isPresent()) { + return entity.findElement(inlinePrefix.get() + "_content"); + } List prefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); for (String prefix : prefixes) { diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java index f785f5c1c..9e6edad2d 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java @@ -26,6 +26,7 @@ import com.sap.cds.services.handler.annotations.ServiceName; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -124,11 +125,20 @@ private Validator buildDeleteContentValidator( Map keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys()); Optional existingEntry = activeCondensedAttachments.stream() - .filter(updatedData -> ApplicationHandlerHelper.areKeysInData(keys, updatedData)) + .filter( + updatedData -> { + if (inlinePrefix.isPresent()) { + return inlinePrefix + .get() + .equals(updatedData.get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER)); + } + return ApplicationHandlerHelper.areKeysInData(keys, updatedData); + }) .findAny(); existingEntry.ifPresent( entry -> { - if (!entry.get(Attachments.CONTENT_ID).equals(attachment.getContentId())) { + Object existingContentId = entry.get(Attachments.CONTENT_ID); + if (!Objects.equals(existingContentId, attachment.getContentId())) { deleteEvent.processEvent(null, null, attachment, context, inlinePrefix); } }); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java index 181ec13ec..492ed078d 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java @@ -15,6 +15,7 @@ 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.modifyevents.MarkAsDeletedAttachmentEvent; +import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; import com.sap.cds.feature.attachments.handler.common.AttachmentsReader; import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.ql.Delete; @@ -298,7 +299,39 @@ void inlineAttachmentWithoutActiveEntityDeletesContent() { verify(deleteContentAttachmentEvent) .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext), any()); assertThat(dataArgumentCaptor.getValue().getContentId()).isEqualTo("new-content-id"); - assertThat(dataArgumentCaptor.getValue().get("__inlinePrefix")).isEqualTo("profilePicture"); + assertThat(dataArgumentCaptor.getValue().get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER)) + .isEqualTo("profilePicture"); + } + + @Test + void inlineAttachmentWithActiveEntityAndChangedContentIdDeletesContent() { + getEntityAndMockContext(RootTable_.CDS_NAME); + CqnDelete delete = Delete.from(RootTable_.class); + when(eventContext.getCqn()).thenReturn(delete); + when(eventContext.getModel()).thenReturn(runtime.getCdsModel()); + when(eventContext.getEvent()).thenReturn("DRAFT_CANCEL"); + + var id = UUID.randomUUID().toString(); + + CdsData draftRoot = CdsData.create(); + draftRoot.put(RootTable.ID, id); + draftRoot.put(RootTable.PROFILE_PICTURE_CONTENT_ID, "new-content-id"); + draftRoot.put(Drafts.HAS_ACTIVE_ENTITY, true); + + CdsData activeRoot = CdsData.create(); + activeRoot.put(RootTable.ID, id); + activeRoot.put(RootTable.PROFILE_PICTURE_CONTENT_ID, "old-content-id"); + activeRoot.put(RootTable.PROFILE_PICTURE_CONTENT, null); + + when(attachmentsReader.readAttachments(any(), any(), any())) + .thenReturn(List.of(Attachments.of(draftRoot))) + .thenReturn(List.of(Attachments.of(activeRoot))); + + cut.processBeforeDraftCancel(eventContext); + + verify(deleteContentAttachmentEvent) + .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext), any()); + assertThat(dataArgumentCaptor.getValue().getContentId()).isEqualTo("new-content-id"); } @Test diff --git a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java index 4a16f8661..487203149 100644 --- a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java +++ b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java @@ -286,6 +286,37 @@ void uploadWithContentDispositionHeaderInDraftPersistsFileName() throws Exceptio assertThat(activeRoot.getAvatarFileName()).isEqualTo("draft-file.png"); } + @Test + void updateInlineAttachmentInDraftAndCancelDeletesNewContent() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + putInlineAttachmentContent(draftRootUrl, "originalContent"); + prepareAndActivateDraft(draftRootUrl); + var activeRootAfterFirstActivation = selectActiveRoot(draft.getId()); + var originalContentId = activeRootAfterFirstActivation.getAvatarContentId(); + assertThat(originalContentId).isNotEmpty(); + serviceHandler.clearEventContext(); + + editExistingRoot(draft.getId()); + var newDraftRootUrl = getDraftRootUrl(draft.getId()); + putInlineAttachmentContent(newDraftRootUrl, "updatedContent"); + cancelDraft(newDraftRootUrl); + + waitTillExpectedHandlerMessageSize(2); + var createEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); + assertThat(createEvents).hasSize(1); + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + assertThat(deleteEvents).hasSize(1); + var createContext = (AttachmentCreateEventContext) createEvents.get(0).context(); + var deleteContext = (AttachmentMarkAsDeletedEventContext) deleteEvents.get(0).context(); + assertThat(deleteContext.getContentId()).isEqualTo(createContext.getContentId()); + + var activeRootAfterCancel = selectActiveRoot(draft.getId()); + assertThat(activeRootAfterCancel.getAvatarContentId()).isEqualTo(originalContentId); + } + // Helper methods private DraftRoots createNewDraft() throws Exception { From 31135131ab8fa7a737af46921ce27c18fc641979 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 27 Apr 2026 11:46:31 +0200 Subject: [PATCH 32/45] make merge ready --- README.md | 3 ++ doc/CHANGELOG.md | 6 ++- .../app/admin-books/fiori-service.cds | 28 -------------- samples/bookshop/pom.xml | 2 +- samples/bookshop/srv/attachments.cds | 37 +++++++++++++++++++ 5 files changed, 46 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 03a368a50..ef30fa094 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,9 @@ The UI Facet can also be added directly after other UI Facets in a `cds` file in ### Single (Inline) Attachments +> [!Important] +> Inline attachments require **cds-services 4.9.0** or higher and are available from **cds-feature-attachments 1.6.0**. + In addition to the composition-based `Attachments` aspect (which supports multiple files), CDS provides the `Attachment` type for **single-file** attachment fields directly on an entity. This is useful when an entity needs exactly one file, for example a profile icon or a cover image. ```cds diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index 48e2afcf9..5e69d1202 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -8,9 +8,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). ## Version 1.6.0 - not yet released -### Changed +### Added +- Added support for single (inline) attachments via the `Attachment` type. Requires cds-services 4.9.0 or higher. (#768) - Added top-level `Attachments` aspect to allow usage without `sap.attachments` namespace (#806), i.e., `using {Attachments} from 'com.sap.cds/cds-feature-attachments'`. + +### Changed + - Extract `fileName` and `mimeType` from HTTP headers (`Content-Disposition`, `Content-Type`, `slug`) when not provided in the request payload (#804) ## Version 1.5.0 - 2026-04-10 diff --git a/samples/bookshop/app/admin-books/fiori-service.cds b/samples/bookshop/app/admin-books/fiori-service.cds index e4dac8980..36fa09086 100644 --- a/samples/bookshop/app/admin-books/fiori-service.cds +++ b/samples/bookshop/app/admin-books/fiori-service.cds @@ -31,22 +31,6 @@ annotate AdminService.Books with @(UI: { $Type : 'UI.ReferenceFacet', Label : '{i18n>Admin}', Target: '@UI.FieldGroup#Admin' - }, - { - $Type : 'UI.ReferenceFacet', - Label : 'Profile Icon', - Target: '@UI.FieldGroup#ProfileIcon' - }, - { - $Type : 'UI.ReferenceFacet', - Label : 'Cover Image', - Target: '@UI.FieldGroup#CoverImage' - }, - { - $Type : 'UI.ReferenceFacet', - ID : 'AttachmentsFacet', - Label : '{i18n>attachments}', - Target: 'attachments/@UI.LineItem' } ], FieldGroup #General: {Data: [ @@ -64,18 +48,6 @@ annotate AdminService.Books with @(UI: { {Value: createdAt}, {Value: modifiedBy}, {Value: modifiedAt} - ]}, - FieldGroup #ProfileIcon: {Data: [ - {Value: profileIcon_content, Label: 'Download'}, - {Value: profileIcon_fileName}, - {Value: profileIcon_status}, - {Value: profileIcon_note} - ]}, - FieldGroup #CoverImage: {Data: [ - {Value: coverImage_content}, - {Value: coverImage_fileName}, - {Value: coverImage_status}, - {Value: coverImage_note} ]} }); diff --git a/samples/bookshop/pom.xml b/samples/bookshop/pom.xml index a86e4ad2a..069719e4a 100644 --- a/samples/bookshop/pom.xml +++ b/samples/bookshop/pom.xml @@ -12,7 +12,7 @@ bookshop parent - 1.6.0-SNAPSHOT + 1.5.0 17 4.9.0 diff --git a/samples/bookshop/srv/attachments.cds b/samples/bookshop/srv/attachments.cds index 6055195f4..920a940d5 100644 --- a/samples/bookshop/srv/attachments.cds +++ b/samples/bookshop/srv/attachments.cds @@ -40,3 +40,40 @@ annotate service.Books with @(UI.Facets: [{ Target: 'attachments/@UI.LineItem' }]); +// Adding the UI Component (a table) to the Administrator App +using {AdminService as adminService} from '../app/services'; + +annotate adminService.Books with @(UI.Facets: [ + { + $Type : 'UI.ReferenceFacet', + ID : 'AttachmentsFacet', + Label : '{i18n>attachments}', + Target: 'attachments/@UI.LineItem' + }, + { + $Type : 'UI.ReferenceFacet', + Label : 'Profile Icon', + Target: '@UI.FieldGroup#ProfileIcon' + }, + { + $Type : 'UI.ReferenceFacet', + Label : 'Cover Image', + Target: '@UI.FieldGroup#CoverImage' + } +]); + +annotate adminService.Books with @(UI: { + FieldGroup #ProfileIcon: {Data: [ + {Value: profileIcon_content, Label: 'Download'}, + {Value: profileIcon_fileName}, + {Value: profileIcon_status}, + {Value: profileIcon_note} + ]}, + FieldGroup #CoverImage: {Data: [ + {Value: coverImage_content}, + {Value: coverImage_fileName}, + {Value: coverImage_status}, + {Value: coverImage_note} + ]} +}); + From 5a1dfe80591c2766e809988c4f0719c4ad3022eb Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 27 Apr 2026 12:42:11 +0200 Subject: [PATCH 33/45] add example annotation to bookshop --- samples/bookshop/srv/attachments.cds | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/samples/bookshop/srv/attachments.cds b/samples/bookshop/srv/attachments.cds index 920a940d5..92df71adf 100644 --- a/samples/bookshop/srv/attachments.cds +++ b/samples/bookshop/srv/attachments.cds @@ -30,6 +30,10 @@ extend my.Books with { coverImage : Attachment; } +annotate my.Books:profileIcon with { + content @Validation.Maximum: '1MB' @Core.AcceptableMediaTypes: ['image/*']; +} + // Add UI component for attachments table to the Browse Books App using {CatalogService as service} from '../app/services'; From 3c8642c8ee4cf1ac8ca3857a27d01ddb32699a6a Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 27 Apr 2026 13:09:28 +0200 Subject: [PATCH 34/45] improvement --- README.md | 117 ++++++------------ .../src/test/resources/cds/db-model.cds | 33 +++-- integration-tests/db/data-model.cds | 2 +- integration-tests/mtx-local/db/schema.cds | 4 +- samples/bookshop/README.md | 5 +- 5 files changed, 58 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index ef30fa094..8cc76885f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -[![Java Build with Maven](https://github.com/cap-java/cds-feature-attachments/actions/workflows/main.yml/badge.svg)](https://github.com/cap-java/cds-feature-attachments/actions/workflows/main.yml) -[![Deploy new Version with Maven](https://github.com/cap-java/cds-feature-attachments/actions/workflows/release.yml/badge.svg)](https://github.com/cap-java/cds-feature-attachments/actions/workflows/release.yml) +[![Java Build with Maven](https://github.com/cap-java/cds-feature-attachments/actions/workflows/main.yml/badge.svg)](https://github.com/cap-java/cds-feature-attachments/actions/workflows/main.yml) +[![Deploy new Version with Maven](https://github.com/cap-java/cds-feature-attachments/actions/workflows/release.yml/badge.svg)](https://github.com/cap-java/cds-feature-attachments/actions/workflows/release.yml) [![REUSE status](https://api.reuse.software/badge/github.com/cap-java/cds-feature-attachments)](https://api.reuse.software/info/github.com/cap-java/cds-feature-attachments) # Attachments Plugin for SAP Cloud Application Programming Model (CAP) @@ -14,31 +14,31 @@ It supports the [AWS, Azure, and Google object stores](storage-targets/cds-featu -* [Quick Start](#quick-start) -* [Usage](#usage) - * [MVN Setup](#mvn-setup) - * [Changes in the CDS Models and for the UI](#changes-in-the-cds-models-and-for-the-UI) - * [Single (Inline) Attachments](#single-inline-attachments) - * [Try the Bookshop Sample](#try-the-bookshop-sample) - * [Storage Targets](#storage-targets) - * [Malware Scanner](#malware-scanner) - * [Specify the maximum file size](#specify-the-maximum-file-size) - * [Restrict allowed MIME types](#restrict-allowed-mime-types) - * [Outbox](#outbox) - * [Restore Endpoint](#restore-endpoint) - * [Motivation](#motivation) - * [HTTP Endpoint](#http-endpoint) - * [Security](#security) -* [Releases: Maven Central and Artifactory](#releases-maven-central-and-artifactory) -* [Minimum UI5 and CAP Java Version](#minimum-ui5-and-cap-java-version) -* [Architecture Overview](#architecture-overview) - * [Design](#design) - * [Multitenancy](#multitenancy) - * [Object Stores](#object-stores) - * [Model Texts](#model-texts) -* [Monitoring \& Logging](#monitoring--logging) -* [Support, Feedback, Contributing](#support-feedback-contributing) -* [References \& Links](#references--links) +- [Quick Start](#quick-start) +- [Usage](#usage) + - [MVN Setup](#mvn-setup) + - [Changes in the CDS Models and for the UI](#changes-in-the-cds-models-and-for-the-UI) + - [Single (Inline) Attachments](#single-inline-attachments) + - [Try the Bookshop Sample](#try-the-bookshop-sample) + - [Storage Targets](#storage-targets) + - [Malware Scanner](#malware-scanner) + - [Specify the maximum file size](#specify-the-maximum-file-size) + - [Restrict allowed MIME types](#restrict-allowed-mime-types) + - [Outbox](#outbox) + - [Restore Endpoint](#restore-endpoint) + - [Motivation](#motivation) + - [HTTP Endpoint](#http-endpoint) + - [Security](#security) +- [Releases: Maven Central and Artifactory](#releases-maven-central-and-artifactory) +- [Minimum UI5 and CAP Java Version](#minimum-ui5-and-cap-java-version) +- [Architecture Overview](#architecture-overview) + - [Design](#design) + - [Multitenancy](#multitenancy) + - [Object Stores](#object-stores) + - [Model Texts](#model-texts) +- [Monitoring \& Logging](#monitoring--logging) +- [Support, Feedback, Contributing](#support-feedback-contributing) +- [References \& Links](#references--links) ## Quick Start @@ -96,7 +96,7 @@ To use this file with the [incidents app](https://github.com/cap-java/incidents- ```cds using { sap.capire.incidents as my } from '../db/schema'; -using { sap.attachments.Attachments } from 'com.sap.cds/cds-feature-attachments'; +using { Attachments } from 'com.sap.cds/cds-feature-attachments'; extend my.Incidents with { attachments: Composition of many Attachments; } @@ -121,10 +121,10 @@ The UI Facet can also be added directly after other UI Facets in a `cds` file in > [!Important] > Inline attachments require **cds-services 4.9.0** or higher and are available from **cds-feature-attachments 1.6.0**. -In addition to the composition-based `Attachments` aspect (which supports multiple files), CDS provides the `Attachment` type for **single-file** attachment fields directly on an entity. This is useful when an entity needs exactly one file, for example a profile icon or a cover image. +In addition to the composition-based `Attachments` aspect (which supports multiple files), `cds-feature-attachments` provides the `Attachment` type for **single-file** attachment fields directly on an entity. This is useful when an entity needs exactly one file, for example a profile icon or a cover image. ```cds -using { sap.attachments.Attachment } from 'com.sap.cds/cds-feature-attachments'; +using { Attachment } from 'com.sap.cds/cds-feature-attachments'; entity Books { key ID : UUID; @@ -142,8 +142,9 @@ CDS flattens inline attachment fields onto the parent entity. For example, `prof - `profileIcon_contentId` (String) - `profileIcon_status` (StatusCode) - `profileIcon_scannedAt` (Timestamp) +- `profileIcon_note` (String) -All plugin features: malware scanning, status tracking, storage targets, maximum file size, and MIME type validation work the same way for inline attachments as for composition-based attachments. +All plugin features: malware scanning, storage targets, maximum file size, and MIME type validation work the same way for inline attachments as for composition-based attachments. #### UI Annotations for Inline Attachments @@ -166,53 +167,6 @@ annotate AdminService.Books with @(UI: { }); ``` -> [!Note] -> For inline attachments, the `content` field is annotated with `@Core.MediaType: 'application/octet-stream'` (a static value) instead of a path reference to the `mimeType` field. This is because CDS flattening rewrites `content` to `profileIcon_content` but does **not** rewrite path references like `mimeType` to `profileIcon_mimeType`, which would result in a broken reference. Static annotations propagate correctly through CDS flattening. - -#### Inline Attachments on Composition Children - -Inline attachments also work on entities that are composition children. For example, if `Items` is a composition of `Orders`, you can add an inline attachment field to `Items`: - -```cds -entity Orders { - key ID : UUID; - items : Composition of many Items; -} -entity Items { - key ID : UUID; - title : String; - receipt : Attachment; -} -``` - -The plugin automatically discovers inline attachment fields at any level of the composition tree. - -#### Combining Inline and Composition-Based Attachments - -An entity can use both inline single attachments and composition-based multiple attachments simultaneously: - -```cds -entity Books { - key ID : UUID; - profileIcon : Attachment; // single file - attachments : Composition of many Attachments; // multiple files -} -``` - -### Try the Bookshop Sample - -The easiest way to get started is with the included [bookshop sample](samples/bookshop/): - -```bash -cd samples/bookshop -mvn compile -mvn spring-boot:run -``` - -Then browse to http://localhost:8080/browse/index.html to see attachments in action. - -For detailed setup instructions and implementation details, see the [bookshop sample README](samples/bookshop/README.md). - ### Storage Targets By default, the plugin operates without a dedicated storage target, storing attachments directly in the [underlying database](cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds#L17). @@ -228,8 +182,7 @@ When using a dedicated storage target, the attachment is not stored in the under ### Malware Scanner -This plugin checks for a binding to -the [SAP Malware Scanning Service](https://help.sap.com/docs/malware-scanning-servce), which needs to have the label `malware-scanner`. The entry in the [mta-file](https://cap.cloud.sap/docs/guides/deployment/to-cf#add-mta-yaml) may look like: +This plugin checks for a binding to the [SAP Malware Scanning Service](https://help.sap.com/docs/malware-scanning-servce), which needs to have the label `malware-scanner`. The entry in the [mta-file](https://cap.cloud.sap/docs/guides/deployment/to-cf#add-mta-yaml) may look like: ``` _schema-version: '0.1' @@ -296,6 +249,7 @@ annotate Books.attachments with { ``` The @Validation.Maximum value is a size string consisting of a number followed by a unit. The following units are supported: + - B (bytes) - KB, MB, GB, TB (decimal units) - KiB, MiB, GiB, TiB (binary units) @@ -333,7 +287,6 @@ annotate Books.attachments with { } ``` - ### Outbox In this plugin the [persistent outbox](https://cap.cloud.sap/docs/java/outbox#persistent) is used to mark attachments as @@ -427,7 +380,7 @@ In the Spring Boot context the `AttachmentService` can be autowired in the handl To secure the endpoint, security annotations can be used. For example: ```cds -using {sap.attachments.Attachments} from `com.sap.cds/cds-feature-attachments`; +using {Attachments} from `com.sap.cds/cds-feature-attachments`; entity Items : cuid { ... 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 09bce4ffb..63e6d28e3 100644 --- a/cds-feature-attachments/src/test/resources/cds/db-model.cds +++ b/cds-feature-attachments/src/test/resources/cds/db-model.cds @@ -1,19 +1,18 @@ namespace unit.test; using {cuid} from '@sap/cds/common'; -using {sap.attachments.Attachments} from '../../../main/resources/cds/com.sap.cds/cds-feature-attachments'; -using {sap.attachments.Attachment as AttachmentType} from '../../../main/resources/cds/com.sap.cds/cds-feature-attachments'; +using {Attachments} from '../../../main/resources/cds/com.sap.cds/cds-feature-attachments'; +using {Attachment as AttachmentType} from '../../../main/resources/cds/com.sap.cds/cds-feature-attachments'; using from '@sap/cds/srv/outbox'; -entity Attachment : Attachments { -} +entity Attachment : Attachments {} entity Roots : cuid { - title : String; - itemTable : Composition of many Items - on itemTable.rootId = $self.ID; - attachments : Composition of many Attachments; - profilePicture : AttachmentType; + title : String; + itemTable : Composition of many Items + on itemTable.rootId = $self.ID; + attachments : Composition of many Attachments; + profilePicture : AttachmentType; } entity Items : cuid { @@ -21,7 +20,8 @@ entity Items : cuid { note : String; events : Composition of many Events on events.id1 = $self.ID; - attachments : Composition of many Attachment on attachments.ID = $self.ID; + attachments : Composition of many Attachment + on attachments.ID = $self.ID; itemAttachments : Composition of many Attachments; } @@ -38,16 +38,16 @@ entity Events { } entity EventItems { - key id1 : UUID; - note : String; - sizeLimitedAttachments : Composition of many Attachments; + key id1 : UUID; + note : String; + sizeLimitedAttachments : Composition of many Attachments; defaultSizeLimitedAttachments : Composition of many Attachments; } // Entity with only inline attachment (no composition) entity InlineOnly : cuid { - title : String; - avatar : AttachmentType; + title : String; + avatar : AttachmentType; } annotate EventItems.sizeLimitedAttachments with { @@ -58,7 +58,6 @@ annotate EventItems.defaultSizeLimitedAttachments with { content @Validation.Maximum; }; -annotate InlineOnly:avatar with { +annotate InlineOnly : avatar with { content @Validation.Maximum: '10KB'; }; - diff --git a/integration-tests/db/data-model.cds b/integration-tests/db/data-model.cds index 52378becb..c1a1cf7ef 100644 --- a/integration-tests/db/data-model.cds +++ b/integration-tests/db/data-model.cds @@ -1,7 +1,7 @@ namespace test.data.model; using {cuid} from '@sap/cds/common'; -using {sap.attachments.Attachments} from 'com.sap.cds/cds-feature-attachments'; +using {Attachments} from 'com.sap.cds/cds-feature-attachments'; using {Attachment} from 'com.sap.cds/cds-feature-attachments'; entity AttachmentEntity : Attachments { diff --git a/integration-tests/mtx-local/db/schema.cds b/integration-tests/mtx-local/db/schema.cds index 9edbfde50..fe1a4ef97 100644 --- a/integration-tests/mtx-local/db/schema.cds +++ b/integration-tests/mtx-local/db/schema.cds @@ -1,7 +1,7 @@ namespace mt.test.data; -using { cuid } from '@sap/cds/common'; -using { sap.attachments.Attachments } from 'com.sap.cds/cds-feature-attachments'; +using {cuid} from '@sap/cds/common'; +using {Attachments} from 'com.sap.cds/cds-feature-attachments'; entity Documents : cuid { title : String; diff --git a/samples/bookshop/README.md b/samples/bookshop/README.md index 7fe10868d..023c7d593 100644 --- a/samples/bookshop/README.md +++ b/samples/bookshop/README.md @@ -19,16 +19,19 @@ This sample demonstrates how to use the `cds-feature-attachments` plugin in a CA ## Getting Started 1. **Clone and navigate to the sample**: + ```bash cd samples/bookshop ``` 2. **Install dependencies**: + ```bash mvn clean compile ``` 3. **Run the application**: + ```bash mvn spring-boot:run ``` @@ -87,7 +90,7 @@ The `srv/attachments.cds` file extends the Books entity with attachments: ```cds using { sap.capire.bookshop as my } from '../db/schema'; -using { sap.attachments.Attachments } from 'com.sap.cds/cds-feature-attachments'; +using { Attachments } from 'com.sap.cds/cds-feature-attachments'; extend my.Books with { attachments: Composition of many Attachments; From 7d321a1fb707694c178031b995a9597ba3421ec9 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 27 Apr 2026 13:11:39 +0200 Subject: [PATCH 35/45] cosmetics --- .../src/test/resources/cds/db-model.cds | 6 ++++-- integration-tests/db/data-model.cds | 6 ++++-- samples/bookshop/srv/attachments.cds | 18 +++++++++++------- 3 files changed, 19 insertions(+), 11 deletions(-) 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 63e6d28e3..0c7e5934b 100644 --- a/cds-feature-attachments/src/test/resources/cds/db-model.cds +++ b/cds-feature-attachments/src/test/resources/cds/db-model.cds @@ -1,8 +1,10 @@ namespace unit.test; using {cuid} from '@sap/cds/common'; -using {Attachments} from '../../../main/resources/cds/com.sap.cds/cds-feature-attachments'; -using {Attachment as AttachmentType} from '../../../main/resources/cds/com.sap.cds/cds-feature-attachments'; +using { + Attachments, + Attachment as AttachmentType +} from '../../../main/resources/cds/com.sap.cds/cds-feature-attachments'; using from '@sap/cds/srv/outbox'; entity Attachment : Attachments {} diff --git a/integration-tests/db/data-model.cds b/integration-tests/db/data-model.cds index c1a1cf7ef..4655a71cb 100644 --- a/integration-tests/db/data-model.cds +++ b/integration-tests/db/data-model.cds @@ -1,8 +1,10 @@ namespace test.data.model; using {cuid} from '@sap/cds/common'; -using {Attachments} from 'com.sap.cds/cds-feature-attachments'; -using {Attachment} from 'com.sap.cds/cds-feature-attachments'; +using { + Attachments, + Attachment +} from 'com.sap.cds/cds-feature-attachments'; entity AttachmentEntity : Attachments { parentKey : UUID; diff --git a/samples/bookshop/srv/attachments.cds b/samples/bookshop/srv/attachments.cds index 92df71adf..917c43a87 100644 --- a/samples/bookshop/srv/attachments.cds +++ b/samples/bookshop/srv/attachments.cds @@ -1,6 +1,8 @@ using {sap.capire.bookshop as my} from '../db/schema'; -using {Attachments} from 'com.sap.cds/cds-feature-attachments'; -using {Attachment} from 'com.sap.cds/cds-feature-attachments'; +using { + Attachments, + Attachment +} from 'com.sap.cds/cds-feature-attachments'; // Extend Books entity to support file attachments (images, PDFs, documents) // Each book can have multiple attachments via composition relationship @@ -30,8 +32,8 @@ extend my.Books with { coverImage : Attachment; } -annotate my.Books:profileIcon with { - content @Validation.Maximum: '1MB' @Core.AcceptableMediaTypes: ['image/*']; +annotate my.Books : profileIcon with { + content @Validation.Maximum: '1MB' @Core.AcceptableMediaTypes: ['image/*']; } // Add UI component for attachments table to the Browse Books App @@ -68,16 +70,18 @@ annotate adminService.Books with @(UI.Facets: [ annotate adminService.Books with @(UI: { FieldGroup #ProfileIcon: {Data: [ - {Value: profileIcon_content, Label: 'Download'}, + { + Value: profileIcon_content, + Label: 'Download' + }, {Value: profileIcon_fileName}, {Value: profileIcon_status}, {Value: profileIcon_note} ]}, - FieldGroup #CoverImage: {Data: [ + FieldGroup #CoverImage : {Data: [ {Value: coverImage_content}, {Value: coverImage_fileName}, {Value: coverImage_status}, {Value: coverImage_note} ]} }); - From 5534cdd122cb7fd0f317ce11f9844d5d330ea885 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 27 Apr 2026 13:44:00 +0200 Subject: [PATCH 36/45] revert import --- integration-tests/db/data-model.cds | 4 ++-- integration-tests/mtx-local/db/schema.cds | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/integration-tests/db/data-model.cds b/integration-tests/db/data-model.cds index 4655a71cb..900618b97 100644 --- a/integration-tests/db/data-model.cds +++ b/integration-tests/db/data-model.cds @@ -2,8 +2,8 @@ namespace test.data.model; using {cuid} from '@sap/cds/common'; using { - Attachments, - Attachment + sap.attachments.Attachments, + sap.attachments.Attachment } from 'com.sap.cds/cds-feature-attachments'; entity AttachmentEntity : Attachments { diff --git a/integration-tests/mtx-local/db/schema.cds b/integration-tests/mtx-local/db/schema.cds index fe1a4ef97..eb2e45d95 100644 --- a/integration-tests/mtx-local/db/schema.cds +++ b/integration-tests/mtx-local/db/schema.cds @@ -1,7 +1,7 @@ namespace mt.test.data; using {cuid} from '@sap/cds/common'; -using {Attachments} from 'com.sap.cds/cds-feature-attachments'; +using {sap.attachments.Attachments} from 'com.sap.cds/cds-feature-attachments'; entity Documents : cuid { title : String; From 1d6747233170ed15950b2709103b5abffe76f34d Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 30 Apr 2026 15:06:14 +0200 Subject: [PATCH 37/45] simplify function declaration --- .../DeleteAttachmentsHandler.java | 3 +- .../ReadAttachmentsHandler.java | 6 +- .../ModifyApplicationHandlerHelper.java | 8 +- .../mimeTypeValidation/MediaTypeService.java | 16 ++- .../modifyevents/CreateAttachmentEvent.java | 9 +- .../DoNothingAttachmentEvent.java | 7 +- .../MarkAsDeletedAttachmentEvent.java | 15 +-- .../modifyevents/ModifyAttachmentEvent.java | 9 +- .../modifyevents/UpdateAttachmentEvent.java | 11 +- .../DraftCancelAttachmentsHandler.java | 4 +- .../CreateAttachmentsHandlerTest.java | 2 +- .../DeleteAttachmentsHandlerTest.java | 7 +- .../UpdateAttachmentsHandlerTest.java | 8 +- .../ModifyApplicationHandlerHelperTest.java | 4 +- .../MediaTypeServiceTest.java | 16 +-- .../CreateAttachmentEventTest.java | 111 ++++++------------ .../DoNothingAttachmentEventTest.java | 4 +- .../MarkAsDeletedAttachmentEventTest.java | 18 +-- .../UpdateAttachmentEventTest.java | 9 +- .../DraftCancelAttachmentsHandlerTest.java | 8 +- .../DraftPatchAttachmentsHandlerTest.java | 2 +- 21 files changed, 110 insertions(+), 167 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandler.java index 0deb5d237..8d944a3f5 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandler.java @@ -66,8 +66,7 @@ void processBefore(CdsDeleteEventContext context) { } else { attachment = Attachments.of(path.target().values()); } - return deleteEvent.processEvent( - path, (InputStream) value, attachment, context, inlinePrefix); + return deleteEvent.processEvent(path, (InputStream) value, attachment, context); }; CdsDataProcessor.create() diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java index cc92492b2..2638c9ca9 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java @@ -132,7 +132,7 @@ void processAfter(CdsReadEventContext context, List data) { } InputStream content = attachment.getContent(); if (nonNull(attachment.getContentId())) { - verifyStatus(path, attachment, inlinePrefix); + verifyStatus(path, attachment); Supplier supplier = nonNull(content) ? () -> content @@ -149,7 +149,9 @@ void processAfter(CdsReadEventContext context, List data) { } } - private void verifyStatus(Path path, Attachments attachment, Optional inlinePrefix) { + private void verifyStatus(Path path, Attachments attachment) { + Optional inlinePrefix = + Optional.ofNullable((String) attachment.get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER)); if (areKeysEmpty(path.target().keys())) { String currentStatus = attachment.getStatus(); logger.debug( diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java index 3f8ee707f..d719e49d5 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java @@ -129,8 +129,12 @@ public static InputStream handleAttachmentForEntity( ModifyAttachmentEvent eventToProcess = eventFactory.getEvent(wrappedContent, contentId, attachment); try { - return eventToProcess.processEvent( - path, wrappedContent, attachment, eventContext, inlinePrefix); + // Ensure the attachment carries the inline prefix marker for processEvent implementations + if (inlinePrefix.isPresent() + && attachment.get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER) == null) { + attachment.put(ApplicationHandlerHelper.INLINE_PREFIX_MARKER, inlinePrefix.get()); + } + return eventToProcess.processEvent(path, wrappedContent, attachment, eventContext); } catch (Exception e) { if (wrappedContent != null && wrappedContent.isLimitExceeded()) { throw tooLargeException; diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeService.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeService.java index 629556667..a647ad240 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeService.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeService.java @@ -13,12 +13,13 @@ public final class MediaTypeService { private static final Logger logger = LoggerFactory.getLogger(MediaTypeService.class); + public static final String DEFAULT_MEDIA_TYPE = "application/octet-stream"; /** * Resolves the MIME type of a file based on its filename (specifically its extension). * * @param fileName the name of the file (including extension) - * @return the resolved MIME type, or {@code null} if it cannot be determined + * @return the resolved MIME type, or a default MIME type if it cannot be determined * @throws ServiceException if the filename is null or blank */ public static String resolveMimeType(String fileName) { @@ -28,14 +29,14 @@ public static String resolveMimeType(String fileName) { int lastDotIndex = fileName.lastIndexOf('.'); if (lastDotIndex == -1 || lastDotIndex == fileName.length() - 1) { - return logUnresolvableMimeType(fileName); + return fallbackToDefaultMimeType(fileName); } FileNameMap fileNameMap = URLConnection.getFileNameMap(); String actualMimeType = fileNameMap.getContentTypeFor(fileName); if (actualMimeType == null) { - return logUnresolvableMimeType(fileName); + return fallbackToDefaultMimeType(fileName); } return actualMimeType; } @@ -73,9 +74,12 @@ public static boolean isMimeTypeAllowed( : baseMimeType.equals(type)); } - private static String logUnresolvableMimeType(String fileName) { - logger.warn("Could not determine mime type for file: {}", fileName); - return null; + private static String fallbackToDefaultMimeType(String fileName) { + logger.warn( + "Could not determine mime type for file: {}. Setting mime type to default: {}", + fileName, + DEFAULT_MEDIA_TYPE); + return DEFAULT_MEDIA_TYPE; } private MediaTypeService() { diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java index f76c278a4..fe421d191 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java @@ -51,11 +51,10 @@ public CreateAttachmentEvent( @Override public InputStream processEvent( - Path path, - InputStream content, - Attachments attachment, - EventContext eventContext, - Optional inlinePrefix) { + Path path, InputStream content, Attachments attachment, EventContext eventContext) { + Optional inlinePrefix = + Optional.ofNullable((String) attachment.get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER)); + logger.debug( "Calling attachment service with create event for entity {}", path.target().entity().getQualifiedName()); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEvent.java index 4d56753b0..b409d274a 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEvent.java @@ -7,7 +7,6 @@ import com.sap.cds.ql.cqn.Path; import com.sap.cds.services.EventContext; import java.io.InputStream; -import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -21,11 +20,7 @@ public class DoNothingAttachmentEvent implements ModifyAttachmentEvent { @Override public InputStream processEvent( - Path path, - InputStream content, - Attachments attachment, - EventContext eventContext, - Optional inlinePrefix) { + Path path, InputStream content, Attachments attachment, EventContext eventContext) { logger.debug("Do nothing event for entity {}", path.target().entity().getQualifiedName()); return content; diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java index 0f889ddaf..4ccc3beb0 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java @@ -8,6 +8,7 @@ import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; +import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.model.service.MarkAsDeletedInput; import com.sap.cds.ql.cqn.Path; @@ -35,11 +36,10 @@ public MarkAsDeletedAttachmentEvent(AttachmentService attachmentService) { @Override public InputStream processEvent( - Path path, - InputStream content, - Attachments attachment, - EventContext eventContext, - Optional inlinePrefix) { + Path path, InputStream content, Attachments attachment, EventContext eventContext) { + Optional inlinePrefix = + Optional.ofNullable((String) attachment.get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER)); + String qualifiedName = eventContext.getTarget().getQualifiedName(); logger.debug( "Processing the event for calling attachment service with mark as delete event for entity {}", @@ -69,11 +69,6 @@ public InputStream processEvent( path.target().values().put(contentIdField, null); path.target().values().put(statusField, null); path.target().values().put(scannedAtField, null); - // For inline attachments, also clear mimeType/fileName on the parent entity. - // For composition-based attachments these live on the attachment entity itself and must NOT - // be cleared here. - // Otherwise UpdateAttachmentEvent's delete step would destroy them before the subsequent - // create step can use them. if (inlinePrefix.isPresent()) { path.target().values().put(mimeTypeField, null); path.target().values().put(fileNameField, null); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/ModifyAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/ModifyAttachmentEvent.java index 9497ad91d..1a830b5c2 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/ModifyAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/ModifyAttachmentEvent.java @@ -8,7 +8,6 @@ import com.sap.cds.ql.cqn.Path; import com.sap.cds.services.EventContext; import java.io.InputStream; -import java.util.Optional; /** * The interface {@link ModifyAttachmentEvent} provides a method to process an event on the {@link @@ -23,14 +22,8 @@ public interface ModifyAttachmentEvent { * @param content the content of the attachment * @param attachment existing attachment data * @param eventContext the current event context - * @param inlinePrefix the inline attachment field prefix (e.g. "coverImage"), or empty for - * composition-based attachments * @return the processed content */ InputStream processEvent( - Path path, - InputStream content, - Attachments attachment, - EventContext eventContext, - Optional inlinePrefix); + Path path, InputStream content, Attachments attachment, EventContext eventContext); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEvent.java index 49acdb96b..a178be89d 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEvent.java @@ -10,7 +10,6 @@ import com.sap.cds.ql.cqn.Path; import com.sap.cds.services.EventContext; import java.io.InputStream; -import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,16 +34,12 @@ public UpdateAttachmentEvent( @Override public InputStream processEvent( - Path path, - InputStream content, - Attachments attachment, - EventContext eventContext, - Optional inlinePrefix) { + Path path, InputStream content, Attachments attachment, EventContext eventContext) { logger.debug( "Processing UPDATE event by calling attachment service with create and delete event for entity {}", path.target().entity().getQualifiedName()); - deleteEvent.processEvent(path, content, attachment, eventContext, inlinePrefix); - return createEvent.processEvent(path, content, attachment, eventContext, inlinePrefix); + deleteEvent.processEvent(path, content, attachment, eventContext); + return createEvent.processEvent(path, content, attachment, eventContext); } } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java index 9e6edad2d..9f4078cbc 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java @@ -119,7 +119,7 @@ private Validator buildDeleteContentValidator( } if (Boolean.FALSE.equals(attachment.get(Drafts.HAS_ACTIVE_ENTITY))) { - deleteEvent.processEvent(path, null, attachment, context, inlinePrefix); + deleteEvent.processEvent(path, null, attachment, context); return; } Map keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys()); @@ -139,7 +139,7 @@ private Validator buildDeleteContentValidator( entry -> { Object existingContentId = entry.get(Attachments.CONTENT_ID); if (!Objects.equals(existingContentId, attachment.getContentId())) { - deleteEvent.processEvent(null, null, attachment, context, inlinePrefix); + deleteEvent.processEvent(null, null, attachment, context); } }); }; diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java index 746b07ab8..37ed28c8a 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java @@ -219,7 +219,7 @@ void attachmentAccessExceptionCorrectHandledForCreate() { attachment.setFileName("test.txt"); attachment.setContent(null); when(eventFactory.getEvent(any(), any(), any())).thenReturn(event); - when(event.processEvent(any(), any(), any(), any(), any())).thenThrow(new ServiceException("")); + when(event.processEvent(any(), any(), any(), any())).thenThrow(new ServiceException("")); List input = List.of(attachment); assertThrows(ServiceException.class, () -> cut.processBefore(createContext, input)); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java index 9d0020ac0..f1187fa47 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java @@ -80,8 +80,7 @@ void attachmentDataExistsServiceIsCalled() { cut.processBefore(context); - verify(modifyAttachmentEvent) - .processEvent(any(), eq(inputStream), eq(data), eq(context), any()); + verify(modifyAttachmentEvent).processEvent(any(), eq(inputStream), eq(data), eq(context)); assertThat(data.getContent()).isNull(); } @@ -105,10 +104,10 @@ void attachmentDataExistsAsExpandServiceIsCalled() { verify(modifyAttachmentEvent) .processEvent( - any(Path.class), eq(inputStream), eq(Attachments.of(attachment1)), eq(context), any()); + any(Path.class), eq(inputStream), eq(Attachments.of(attachment1)), eq(context)); verify(modifyAttachmentEvent) .processEvent( - any(Path.class), eq(inputStream), eq(Attachments.of(attachment2)), eq(context), any()); + any(Path.class), eq(inputStream), eq(Attachments.of(attachment2)), eq(context)); assertThat(attachment1.getContent()).isNull(); assertThat(attachment2.getContent()).isNull(); } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java index d1f32eb46..8ea36c180 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java @@ -247,7 +247,7 @@ void attachmentAccessExceptionCorrectHandledForUpdate() { attachment.setFileName("test.txt"); attachment.setContent(null); attachment.setId(id); - when(event.processEvent(any(), any(), any(), any(), any())).thenThrow(new ServiceException("")); + when(event.processEvent(any(), any(), any(), any())).thenThrow(new ServiceException("")); when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) .thenReturn(List.of(attachment)); @@ -284,11 +284,7 @@ void existingDataFoundAndUsed() { ArgumentCaptor eventStreamCaptor = ArgumentCaptor.forClass(InputStream.class); verify(event) .processEvent( - any(), - eventStreamCaptor.capture(), - cdsDataArgumentCaptor.capture(), - eq(updateContext), - any()); + any(), eventStreamCaptor.capture(), cdsDataArgumentCaptor.capture(), eq(updateContext)); InputStream eventCaptured = eventStreamCaptor.getValue(); assertThat(eventCaptured).isInstanceOf(CountingInputStream.class); assertThat(((CountingInputStream) eventCaptured).getDelegate()).isSameAs(testStream); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java index 060a8d12a..71ec481e6 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java @@ -121,7 +121,7 @@ void serviceExceptionDueToLimitExceeded() { when(parameterInfo.getHeader("Content-Length")).thenReturn(null); // Make event.processEvent() read from the stream, triggering the limit check - when(event.processEvent(any(), any(), any(), any(), any())) + when(event.processEvent(any(), any(), any(), any())) .thenAnswer( invocation -> { InputStream wrappedContent = invocation.getArgument(1); @@ -302,7 +302,7 @@ void streamingLimitExceededOnInlineAttachment() { when(target.keys()).thenReturn(Map.of("ID", data.getId())); when(parameterInfo.getHeader("Content-Length")).thenReturn(null); - when(event.processEvent(any(), any(), any(), any(), any())) + when(event.processEvent(any(), any(), any(), any())) .thenAnswer( invocation -> { InputStream wrappedContent = invocation.getArgument(1); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeServiceTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeServiceTest.java index 2f0d1b4e2..9dc898b1e 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeServiceTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeServiceTest.java @@ -26,17 +26,17 @@ void returnsCorrectMimeType_caseInsensitive() { } @Test - void returnsNull_forUnknownExtension() { + void returnsDefaultMimeType_forUnknownExtension() { String result = MediaTypeService.resolveMimeType("file.unknown"); - assertNull(result); + assertEquals(MediaTypeService.DEFAULT_MEDIA_TYPE, result); } @Test - void returnsNull_whenNoExtensionPresent() { + void returnsDefaultMimeType_whenNoExtensionPresent() { String result = MediaTypeService.resolveMimeType("file"); - assertNull(result); + assertEquals(MediaTypeService.DEFAULT_MEDIA_TYPE, result); } @Test @@ -57,28 +57,28 @@ void handlesDoubleDotFiles() { void handlesTrailingDotFile() { String result = MediaTypeService.resolveMimeType("file."); - assertNull(result); + assertEquals(MediaTypeService.DEFAULT_MEDIA_TYPE, result); } @Test void handlesHiddenDotFile() { String result = MediaTypeService.resolveMimeType(".gitignore"); - assertNull(result); + assertEquals(MediaTypeService.DEFAULT_MEDIA_TYPE, result); } @Test void handlesOnlyDotsFile() { String result = MediaTypeService.resolveMimeType("..."); - assertNull(result); + assertEquals(MediaTypeService.DEFAULT_MEDIA_TYPE, result); } @Test void handlesWeirdFilename() { String result = MediaTypeService.resolveMimeType("file..unknown"); - assertNull(result); + assertEquals(MediaTypeService.DEFAULT_MEDIA_TYPE, result); } @Test diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java index 9d124b5e5..aeda67d46 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java @@ -14,6 +14,7 @@ import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; import com.sap.cds.feature.attachments.handler.applicationservice.transaction.ListenerProvider; +import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.model.service.AttachmentModificationResult; @@ -32,7 +33,6 @@ import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; -import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -106,7 +106,7 @@ void storageCalledWithAllFieldsFilledFromExistingData() { existingData.setFileName("some file name"); existingData.setMimeType("some mime type"); - cut.processEvent(path, attachment.getContent(), existingData, eventContext, Optional.empty()); + cut.processEvent(path, attachment.getContent(), existingData, eventContext); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); var createInput = contextArgumentCaptor.getValue(); @@ -129,8 +129,7 @@ void resultFromServiceStoredInPath() { when(attachmentService.createAttachment(any())).thenReturn(attachmentServiceResult); when(target.values()).thenReturn(attachment); - cut.processEvent( - path, attachment.getContent(), Attachments.create(), eventContext, Optional.empty()); + cut.processEvent(path, attachment.getContent(), Attachments.create(), eventContext); assertThat(attachment.getContentId()).isEqualTo(attachmentServiceResult.contentId()); assertThat(attachment.getStatus()).isEqualTo(attachmentServiceResult.status()); @@ -146,7 +145,7 @@ void changesetIstRegistered() { when(attachmentService.createAttachment(any())) .thenReturn(new AttachmentModificationResult(false, contentId, "test", null)); - cut.processEvent(path, null, Attachments.create(), eventContext, Optional.empty()); + cut.processEvent(path, null, Attachments.create(), eventContext); verify(changeSetContext).register(listener); } @@ -167,8 +166,7 @@ void contentIsReturnedIfNotExternalStored(boolean isExternalStored) throws IOExc .thenReturn(new AttachmentModificationResult(isExternalStored, "id", "test", null)); var result = - cut.processEvent( - path, attachment.getContent(), Attachments.create(), eventContext, Optional.empty()); + cut.processEvent(path, attachment.getContent(), Attachments.create(), eventContext); var expectedContent = isExternalStored ? attachment.getContent() : null; assertThat(result).isEqualTo(expectedContent); @@ -187,8 +185,7 @@ private Attachments prepareAndExecuteEventWithData() { when(attachmentService.createAttachment(any())) .thenReturn(new AttachmentModificationResult(false, "id", "test", null)); - cut.processEvent( - path, attachment.getContent(), Attachments.create(), eventContext, Optional.empty()); + cut.processEvent(path, attachment.getContent(), Attachments.create(), eventContext); return attachment; } @@ -203,7 +200,7 @@ void fileNameFromRfc5987Header() { when(parameterInfo.getHeader("Content-Disposition")) .thenReturn("attachment; filename*=UTF-8''my%20file%20name.pdf"); - cut.processEvent(path, null, Attachments.create(), eventContext, Optional.empty()); + cut.processEvent(path, null, Attachments.create(), eventContext); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("my file name.pdf"); @@ -221,7 +218,7 @@ void fileNameFromRfc5987HeaderWithTrailingParams() { when(parameterInfo.getHeader("Content-Disposition")) .thenReturn("attachment; filename*=UTF-8''my%20file.pdf; size=1234"); - cut.processEvent(path, null, Attachments.create(), eventContext, Optional.empty()); + cut.processEvent(path, null, Attachments.create(), eventContext); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("my file.pdf"); @@ -239,7 +236,7 @@ void fileNameFromPlainHeader() { when(parameterInfo.getHeader("Content-Disposition")) .thenReturn("attachment; filename=\"report.pdf\""); - cut.processEvent(path, null, Attachments.create(), eventContext, Optional.empty()); + cut.processEvent(path, null, Attachments.create(), eventContext); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("report.pdf"); @@ -256,7 +253,7 @@ void fileNameFromPlainHeaderWithoutQuotes() { when(parameterInfo.getHeader("Content-Disposition")) .thenReturn("attachment; filename=report.pdf"); - cut.processEvent(path, null, Attachments.create(), eventContext, Optional.empty()); + cut.processEvent(path, null, Attachments.create(), eventContext); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("report.pdf"); @@ -273,7 +270,7 @@ void fileNameFromSlugHeader() { when(parameterInfo.getHeader("Content-Disposition")).thenReturn(null); when(parameterInfo.getHeader("slug")).thenReturn("document.docx"); - cut.processEvent(path, null, Attachments.create(), eventContext, Optional.empty()); + cut.processEvent(path, null, Attachments.create(), eventContext); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("document.docx"); @@ -291,7 +288,7 @@ void fileNamePayloadPrecedesHeader() { when(parameterInfo.getHeader("Content-Disposition")) .thenReturn("attachment; filename=\"header-name.pdf\""); - cut.processEvent(path, null, Attachments.create(), eventContext, Optional.empty()); + cut.processEvent(path, null, Attachments.create(), eventContext); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("payload-name.pdf"); @@ -307,7 +304,7 @@ void mimeTypeFromContentTypeHeader() { .thenReturn(new AttachmentModificationResult(false, "id", "test", null)); when(parameterInfo.getHeader("Content-Type")).thenReturn("image/jpeg; charset=utf-8"); - cut.processEvent(path, null, Attachments.create(), eventContext, Optional.empty()); + cut.processEvent(path, null, Attachments.create(), eventContext); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().mimeType()).isEqualTo("image/jpeg"); @@ -325,7 +322,7 @@ void mimeTypePayloadPrecedesHeader() { .thenReturn(new AttachmentModificationResult(false, "id", "test", null)); when(parameterInfo.getHeader("Content-Type")).thenReturn("application/pdf"); - cut.processEvent(path, null, Attachments.create(), eventContext, Optional.empty()); + cut.processEvent(path, null, Attachments.create(), eventContext); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().mimeType()).isEqualTo("text/plain"); @@ -342,7 +339,7 @@ void fileNameIgnoredForInvalidHeader() { when(parameterInfo.getHeader("Content-Disposition")).thenReturn("inline"); when(parameterInfo.getHeader("slug")).thenReturn(null); - cut.processEvent(path, null, Attachments.create(), eventContext, Optional.empty()); + cut.processEvent(path, null, Attachments.create(), eventContext); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().fileName()).isNull(); @@ -358,7 +355,7 @@ void mimeTypeFromHeaderWhenEmpty() { .thenReturn(new AttachmentModificationResult(false, "id", "test", null)); when(parameterInfo.getHeader("Content-Type")).thenReturn("text/csv"); - cut.processEvent(path, null, Attachments.create(), eventContext, Optional.empty()); + cut.processEvent(path, null, Attachments.create(), eventContext); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().mimeType()).isEqualTo("text/csv"); @@ -374,7 +371,7 @@ void headersSkippedWhenParameterInfoIsNull() { .thenReturn(new AttachmentModificationResult(false, "id", "test", null)); when(eventContext.getParameterInfo()).thenReturn(null); - cut.processEvent(path, null, Attachments.create(), eventContext, Optional.empty()); + cut.processEvent(path, null, Attachments.create(), eventContext); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().fileName()).isNull(); @@ -400,8 +397,7 @@ void inlineContentIdAndStatusWrittenWithPrefix() { when(attachmentService.createAttachment(any())) .thenReturn(new AttachmentModificationResult(false, "doc-123", "Clean", null)); - cut.processEvent( - path, content, Attachments.create(), eventContext, Optional.of("profilePicture")); + cut.processEvent(path, content, inlineAttachment("profilePicture"), eventContext); assertThat(values).containsEntry("profilePicture_contentId", "doc-123"); assertThat(values).containsEntry("profilePicture_status", "Clean"); @@ -424,8 +420,7 @@ void inlinePrefixedFieldValuesPassedToService() { when(attachmentService.createAttachment(any())) .thenReturn(new AttachmentModificationResult(false, "id", "ok", null)); - cut.processEvent( - path, content, Attachments.create(), eventContext, Optional.of("profilePicture")); + cut.processEvent(path, content, inlineAttachment("profilePicture"), eventContext); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); var input = contextArgumentCaptor.getValue(); @@ -452,8 +447,9 @@ void inlineFallsBackToAttachmentObjectWhenPrefixedFieldMissing() { var existingData = Attachments.create(); existingData.setFileName("fallback.txt"); existingData.setMimeType("text/plain"); + existingData.put(ApplicationHandlerHelper.INLINE_PREFIX_MARKER, "profilePicture"); - cut.processEvent(path, content, existingData, eventContext, Optional.of("profilePicture")); + cut.processEvent(path, content, existingData, eventContext); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); var input = contextArgumentCaptor.getValue(); @@ -476,7 +472,7 @@ void nonInlineEntityDoesNotUsePrefixedFields() { when(attachmentService.createAttachment(any())) .thenReturn(new AttachmentModificationResult(false, "doc-999", "ok", null)); - cut.processEvent(path, content, Attachments.create(), eventContext, Optional.empty()); + cut.processEvent(path, content, Attachments.create(), eventContext); assertThat(values).containsEntry(Attachments.CONTENT_ID, "doc-999"); assertThat(values).containsEntry(Attachments.STATUS, "ok"); @@ -500,8 +496,7 @@ void processEventWritesScannedAtWhenNonNull() { when(attachmentService.createAttachment(any())) .thenReturn(new AttachmentModificationResult(false, "doc-scan", "Clean", scannedAt)); - cut.processEvent( - path, content, Attachments.create(), eventContext, Optional.of("profilePicture")); + cut.processEvent(path, content, inlineAttachment("profilePicture"), eventContext); assertThat(values).containsEntry("profilePicture_contentId", "doc-scan"); assertThat(values).containsEntry("profilePicture_status", "Clean"); @@ -531,11 +526,7 @@ void inlineExtractsFileNameFromRfc5987Header() { .thenReturn("attachment; filename*=UTF-8''my%20file.txt"); cut.processEvent( - path, - mock(InputStream.class), - Attachments.create(), - eventContext, - Optional.of("profilePicture")); + path, mock(InputStream.class), inlineAttachment("profilePicture"), eventContext); assertThat(values).containsEntry("profilePicture_fileName", "my file.txt"); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); @@ -549,11 +540,7 @@ void inlineExtractsFileNameFromPlainHeader() { .thenReturn("attachment; filename=\"report.pdf\""); cut.processEvent( - path, - mock(InputStream.class), - Attachments.create(), - eventContext, - Optional.of("profilePicture")); + path, mock(InputStream.class), inlineAttachment("profilePicture"), eventContext); assertThat(values).containsEntry("profilePicture_fileName", "report.pdf"); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); @@ -567,11 +554,7 @@ void inlineExtractsFileNameFromSlugHeader() { when(parameterInfo.getHeader("slug")).thenReturn("slug-file.png"); cut.processEvent( - path, - mock(InputStream.class), - Attachments.create(), - eventContext, - Optional.of("profilePicture")); + path, mock(InputStream.class), inlineAttachment("profilePicture"), eventContext); assertThat(values).containsEntry("profilePicture_fileName", "slug-file.png"); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); @@ -585,11 +568,7 @@ void inlineBothHeadersNullReturnsEmptyFileName() { when(parameterInfo.getHeader("slug")).thenReturn(null); cut.processEvent( - path, - mock(InputStream.class), - Attachments.create(), - eventContext, - Optional.of("profilePicture")); + path, mock(InputStream.class), inlineAttachment("profilePicture"), eventContext); assertThat(values).doesNotContainKey("profilePicture_fileName"); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); @@ -602,11 +581,7 @@ void inlineExtractsMimeTypeFromContentTypeHeader() { when(parameterInfo.getHeader("Content-Type")).thenReturn("image/jpeg; charset=utf-8"); cut.processEvent( - path, - mock(InputStream.class), - Attachments.create(), - eventContext, - Optional.of("profilePicture")); + path, mock(InputStream.class), inlineAttachment("profilePicture"), eventContext); assertThat(values).containsEntry("profilePicture_mimeType", "image/jpeg"); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); @@ -620,11 +595,7 @@ void inlineMimeTypeOctetStreamKeptWhenExplicitlySet() { when(parameterInfo.getHeader("Content-Type")).thenReturn("image/png"); cut.processEvent( - path, - mock(InputStream.class), - Attachments.create(), - eventContext, - Optional.of("profilePicture")); + path, mock(InputStream.class), inlineAttachment("profilePicture"), eventContext); assertThat(values).containsEntry("profilePicture_mimeType", "application/octet-stream"); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); @@ -637,11 +608,7 @@ void inlineMimeTypeNullContentTypeReturnsEmpty() { when(parameterInfo.getHeader("Content-Type")).thenReturn(null); cut.processEvent( - path, - mock(InputStream.class), - Attachments.create(), - eventContext, - Optional.of("profilePicture")); + path, mock(InputStream.class), inlineAttachment("profilePicture"), eventContext); assertThat(values).doesNotContainKey("profilePicture_mimeType"); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); @@ -654,11 +621,7 @@ void inlineMimeTypeOctetStreamFromContentTypeHeaderIsUsed() { when(parameterInfo.getHeader("Content-Type")).thenReturn("application/octet-stream"); cut.processEvent( - path, - mock(InputStream.class), - Attachments.create(), - eventContext, - Optional.of("profilePicture")); + path, mock(InputStream.class), inlineAttachment("profilePicture"), eventContext); assertThat(values).containsEntry("profilePicture_mimeType", "application/octet-stream"); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); @@ -671,13 +634,15 @@ void inlineFileNameAlreadyPresentSkipsHeaderExtraction() { values.put("profilePicture_fileName", "already-set.pdf"); cut.processEvent( - path, - mock(InputStream.class), - Attachments.create(), - eventContext, - Optional.of("profilePicture")); + path, mock(InputStream.class), inlineAttachment("profilePicture"), eventContext); verify(parameterInfo, never()).getHeader("Content-Disposition"); assertThat(values).containsEntry("profilePicture_fileName", "already-set.pdf"); } + + private static Attachments inlineAttachment(String prefix) { + Attachments attachment = Attachments.create(); + attachment.put(ApplicationHandlerHelper.INLINE_PREFIX_MARKER, prefix); + return attachment; + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEventTest.java index 349bbac05..2a0cecb82 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEventTest.java @@ -17,7 +17,6 @@ import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; import java.util.Objects; -import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EmptySource; @@ -50,8 +49,7 @@ void contentReturned(String input) { when(target.entity()).thenReturn(entity); when(entity.getQualifiedName()).thenReturn("some.qualified.name"); - var result = - cut.processEvent(path, streamInput, data, mock(EventContext.class), Optional.empty()); + var result = cut.processEvent(path, streamInput, data, mock(EventContext.class)); assertThat(result).isEqualTo(streamInput); verifyNoInteractions(element, data); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java index 99d7c5b5d..e701d967f 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java @@ -12,6 +12,7 @@ import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; +import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.model.service.MarkAsDeletedInput; @@ -25,7 +26,6 @@ import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; -import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -69,7 +69,7 @@ void documentIsExternallyDeleted() { var data = Attachments.create(); data.setContentId(contentId); - var expectedValue = cut.processEvent(path, value, data, context, Optional.empty()); + var expectedValue = cut.processEvent(path, value, data, context); assertThat(expectedValue).isEqualTo(value); assertThat(data.getContentId()).isEqualTo(contentId); @@ -90,7 +90,7 @@ void documentIsNotExternallyDeletedBecauseDoesNotExistBefore() { var value = new ByteArrayInputStream("test".getBytes(StandardCharsets.UTF_8)); var data = Attachments.create(); - var expectedValue = cut.processEvent(path, value, data, context, Optional.empty()); + var expectedValue = cut.processEvent(path, value, data, context); assertThat(expectedValue).isEqualTo(value); assertThat(data.getContentId()).isNull(); @@ -109,7 +109,7 @@ void documentIsNotExternallyDeletedBecauseItIsDraftChangeEvent() { data.setContentId(contentId); when(context.getEvent()).thenReturn(DraftService.EVENT_DRAFT_PATCH); - var expectedValue = cut.processEvent(path, value, data, context, Optional.empty()); + var expectedValue = cut.processEvent(path, value, data, context); assertThat(expectedValue).isEqualTo(value); assertThat(data.getContentId()).isEqualTo(contentId); @@ -127,7 +127,7 @@ void processEvent_withNullPath_doesNotModifyPathValues() { var data = Attachments.create(); data.setContentId(contentId); - var expectedValue = cut.processEvent(null, value, data, context, Optional.empty()); + var expectedValue = cut.processEvent(null, value, data, context); assertThat(expectedValue).isEqualTo(value); // Attachment service should still be called to mark as deleted @@ -148,7 +148,7 @@ void processEvent_withDifferentNewContentId_doesNotClearContentId() { // Set a different contentId in the path values currentData.put(Attachments.CONTENT_ID, newContentId); - var expectedValue = cut.processEvent(path, value, data, context, Optional.empty()); + var expectedValue = cut.processEvent(path, value, data, context); assertThat(expectedValue).isEqualTo(value); // Attachment service should be called to mark old content as deleted @@ -179,9 +179,10 @@ void inlineDelete_clearsPrefixedFields() { var data = Attachments.create(); data.setContentId("old-content-id"); + data.put(ApplicationHandlerHelper.INLINE_PREFIX_MARKER, "profilePicture"); when(context.getEvent()).thenReturn(DraftService.EVENT_DRAFT_PATCH); - cut.processEvent(path, null, data, context, Optional.of("profilePicture")); + cut.processEvent(path, null, data, context); // All prefixed fields should be cleared assertThat(values) @@ -208,8 +209,9 @@ void inlineDelete_withDifferentNewContentId_doesNotClearPrefixedFields() { var data = Attachments.create(); data.setContentId("old-content-id"); + data.put(ApplicationHandlerHelper.INLINE_PREFIX_MARKER, "profilePicture"); - cut.processEvent(path, null, data, context, Optional.of("profilePicture")); + cut.processEvent(path, null, data, context); // contentId differs from attachment's contentId, so fields should NOT be cleared assertThat(values).containsEntry("profilePicture_contentId", "different-new-content-id"); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEventTest.java index f18029b91..640a304a3 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEventTest.java @@ -13,7 +13,6 @@ import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.EventContext; import java.io.InputStream; -import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -45,11 +44,9 @@ void eventsCorrectCalled() { var existingData = Attachments.create(); var eventContext = mock(EventContext.class); - cut.processEvent(path, testContentStream, existingData, eventContext, Optional.empty()); + cut.processEvent(path, testContentStream, existingData, eventContext); - verify(createEvent) - .processEvent(path, testContentStream, existingData, eventContext, Optional.empty()); - verify(deleteEvent) - .processEvent(path, testContentStream, existingData, eventContext, Optional.empty()); + verify(createEvent).processEvent(path, testContentStream, existingData, eventContext); + verify(deleteEvent).processEvent(path, testContentStream, existingData, eventContext); } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java index 492ed078d..2210cc701 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java @@ -186,7 +186,7 @@ void createdEntityNeedsToBeDeleted() { cut.processBeforeDraftCancel(eventContext); verify(deleteContentAttachmentEvent) - .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext), any()); + .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext)); assertThat(dataArgumentCaptor.getValue()).isEqualTo(attachment); } @@ -205,7 +205,7 @@ void updatedEntityNeedsToBeDeleted() { cut.processBeforeDraftCancel(eventContext); verify(deleteContentAttachmentEvent) - .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext), any()); + .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext)); assertThat(dataArgumentCaptor.getValue()).isEqualTo(draftAttachment); } @@ -297,7 +297,7 @@ void inlineAttachmentWithoutActiveEntityDeletesContent() { cut.processBeforeDraftCancel(eventContext); verify(deleteContentAttachmentEvent) - .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext), any()); + .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext)); assertThat(dataArgumentCaptor.getValue().getContentId()).isEqualTo("new-content-id"); assertThat(dataArgumentCaptor.getValue().get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER)) .isEqualTo("profilePicture"); @@ -330,7 +330,7 @@ void inlineAttachmentWithActiveEntityAndChangedContentIdDeletesContent() { cut.processBeforeDraftCancel(eventContext); verify(deleteContentAttachmentEvent) - .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext), any()); + .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext)); assertThat(dataArgumentCaptor.getValue().getContentId()).isEqualTo("new-content-id"); } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java index f40f618cb..035dd766b 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java @@ -141,7 +141,7 @@ void contentIdUsedForEventFactory() { InputStream captured = streamCaptor.getValue(); assertThat(captured).isInstanceOf(CountingInputStream.class); assertThat(((CountingInputStream) captured).getDelegate()).isSameAs(content); - verify(event).processEvent(any(), eq(captured), eq(attachment), eq(eventContext), any()); + verify(event).processEvent(any(), eq(captured), eq(attachment), eq(eventContext)); } @Test From 76c190526a2fc5d3461ba266a26cee5ff150f933 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 30 Apr 2026 15:50:33 +0200 Subject: [PATCH 38/45] fix: include entity keys in persistInlineAttachmentMetadata WHERE clause The UPDATE in persistInlineAttachmentMetadata only filtered by contentId, which could match rows from other entities. Add the parent entity's key fields to the WHERE predicate to prevent cross-entity data corruption. --- .../DraftPatchAttachmentsHandler.java | 15 +++++--- .../DraftPatchAttachmentsHandlerTest.java | 35 +++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java index 76021113c..addc213d6 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java @@ -14,10 +14,13 @@ import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper; import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.ModifyAttachmentEventFactory; import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; +import com.sap.cds.ql.CQL; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; +import com.sap.cds.ql.cqn.CqnPredicate; import com.sap.cds.ql.cqn.CqnSelect; import com.sap.cds.ql.cqn.CqnUpdate; +import com.sap.cds.reflect.CdsElement; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.draft.DraftPatchEventContext; import com.sap.cds.services.draft.DraftService; @@ -149,10 +152,14 @@ private void persistInlineAttachmentMetadata(CdsEntity target, List entry.get(contentIdField).eq(contentId)); + CqnPredicate predicate = CQL.get(contentIdField).eq(contentId); + for (CdsElement key : target.keyElements().toList()) { + Object keyValue = d.get(key.getName()); + if (keyValue != null) { + predicate = CQL.and(predicate, CQL.get(key.getName()).eq(keyValue)); + } + } + CqnUpdate update = Update.entity(draftEntity).data(updateData).where(predicate); persistence.run(update); } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java index 035dd766b..81f9dd42c 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java @@ -10,11 +10,15 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.sap.cds.CdsData; import com.sap.cds.Result; +import com.sap.cds.Struct; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Events; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Events_; 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.InlineOnly; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.InlineOnly_; 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_; @@ -24,6 +28,7 @@ import com.sap.cds.feature.attachments.handler.applicationservice.readhelper.CountingInputStream; import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.ql.cqn.CqnUpdate; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.draft.DraftPatchEventContext; import com.sap.cds.services.draft.DraftService; @@ -34,7 +39,9 @@ import com.sap.cds.services.request.ParameterInfo; import com.sap.cds.services.runtime.CdsRuntime; import java.io.InputStream; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.UUID; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -155,6 +162,34 @@ void contentIdIsNotSetForNonMediaEntity() { assertThat(events).doesNotContainKey(Attachments.CONTENT_ID); } + @Test + void inlineMetadataUpdateIncludesEntityKeysInWhereClause() { + getEntityAndMockContext(InlineOnly_.CDS_NAME); + var entityId = UUID.randomUUID().toString(); + var contentId = UUID.randomUUID().toString(); + + // Build data simulating post-converter state where contentId and mimeType have been set + Map data = new HashMap<>(); + data.put(InlineOnly.ID, entityId); + data.put(InlineOnly.AVATAR_CONTENT_ID, contentId); + data.put(InlineOnly.AVATAR_MIME_TYPE, "image/png"); + + when(persistence.run(any(CqnSelect.class))).thenReturn(mock(Result.class)); + when(persistence.run(any(CqnUpdate.class))).thenReturn(mock(Result.class)); + + cut.processBeforeDraftPatch(eventContext, List.of(Struct.access(data).as(CdsData.class))); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); + verify(persistence).run(updateCaptor.capture()); + var update = updateCaptor.getValue(); + assertThat(update.where()).isPresent(); + var where = update.where().get().toString(); + assertThat(where).contains("avatar_contentId"); + assertThat(where).contains(contentId); + assertThat(where).contains("ID"); + assertThat(where).contains(entityId); + } + @Test void classHasCorrectAnnotations() { var serviceAnnotation = cut.getClass().getAnnotation(ServiceName.class); From 162def31a92c0bb1c11c059faafc231558611a9c Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 30 Apr 2026 15:48:56 +0200 Subject: [PATCH 39/45] Add integration tests for inline attachment isolation and draft size validation T5: Verify that activating one draft entity does not affect another entity's inline attachment content (multi-entity isolation). T6: Verify that uploading oversized content (>5MB) to coverImage inline attachment in a draft returns an HTTP 4xx error. --- .../SingleAttachmentDraftTest.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java index 487203149..e1f24c618 100644 --- a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java +++ b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java @@ -286,6 +286,53 @@ void uploadWithContentDispositionHeaderInDraftPersistsFileName() throws Exceptio assertThat(activeRoot.getAvatarFileName()).isEqualTo("draft-file.png"); } + @Test + void multiEntityIsolation_activatingOneEntityDoesNotAffectOther() throws Exception { + var draftA = createNewDraft(); + var draftAUrl = getDraftRootUrl(draftA.getId()); + putInlineAttachmentContent(draftAUrl, "contentA"); + + var draftB = createNewDraft(); + var draftBUrl = getDraftRootUrl(draftB.getId()); + putInlineAttachmentContent(draftBUrl, "contentB"); + + prepareAndActivateDraft(draftAUrl); + + var activeRootA = selectActiveRoot(draftA.getId()); + assertThat(activeRootA.getAvatarContentId()).isNotEmpty(); + + prepareAndActivateDraft(draftBUrl); + + var activeRootB = selectActiveRoot(draftB.getId()); + assertThat(activeRootB.getAvatarContentId()).isNotEmpty(); + assertThat(activeRootB.getAvatarContentId()).isNotEqualTo(activeRootA.getAvatarContentId()); + + var contentBUrl = getActiveRootUrl(draftB.getId()) + "/avatar_content"; + Awaitility.await() + .atMost(60, TimeUnit.SECONDS) + .pollDelay(1, TimeUnit.SECONDS) + .pollInterval(2, TimeUnit.SECONDS) + .until( + () -> { + var response = requestHelper.executeGet(contentBUrl); + return response.getResponse().getContentAsString().equals("contentB"); + }); + + var response = requestHelper.executeGet(contentBUrl); + assertThat(response.getResponse().getContentAsString()).isEqualTo("contentB"); + } + + @Test + void putOversizedContentToCoverImageInDraftReturnsError() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + + var url = draftRootUrl + "/coverImage_content"; + byte[] oversizedContent = new byte[6 * 1024 * 1024]; // 6MB > 5MB limit + requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher(url, oversizedContent, status().is4xxClientError()); + } + @Test void updateInlineAttachmentInDraftAndCancelDeletesNewContent() throws Exception { var draft = createNewDraft(); From 80c779a15be7db964284fcfb5aa02c2bf1a09126 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 30 Apr 2026 15:53:45 +0200 Subject: [PATCH 40/45] refactor: extract shared resolveFieldName utility to ApplicationHandlerHelper Removes duplicate resolveColumn/resolveField methods from ReadAttachmentsHandler, DefaultAttachmentMalwareScanner, and MarkAsDeletedAttachmentEvent. All now use the single shared ApplicationHandlerHelper.resolveFieldName() method. --- .../ReadAttachmentsHandler.java | 9 ++---- .../MarkAsDeletedAttachmentEvent.java | 19 ++++++------ .../common/ApplicationHandlerHelper.java | 4 +++ .../DefaultAttachmentMalwareScanner.java | 30 +++++++++++-------- 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java index 2638c9ca9..06c8d697f 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java @@ -192,8 +192,9 @@ private void transitionToScanning( attachment.getContentId(), attachment.getScannedAt()); - String contentIdCol = resolveColumn(Attachments.CONTENT_ID, inlinePrefix); - String statusCol = resolveColumn(Attachments.STATUS, inlinePrefix); + String contentIdCol = + ApplicationHandlerHelper.resolveFieldName(Attachments.CONTENT_ID, inlinePrefix); + String statusCol = ApplicationHandlerHelper.resolveFieldName(Attachments.STATUS, inlinePrefix); Attachments updateData = Attachments.create(); updateData.put(statusCol, StatusCode.SCANNING); @@ -209,10 +210,6 @@ private void transitionToScanning( attachment.setStatus(StatusCode.SCANNING); } - private static String resolveColumn(String fieldName, Optional inlinePrefix) { - return inlinePrefix.map(p -> p + "_" + fieldName).orElse(fieldName); - } - private boolean areKeysEmpty(Map keys) { return keys.values().stream().allMatch(Objects::isNull); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java index 4ccc3beb0..554e01c66 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java @@ -57,11 +57,16 @@ public InputStream processEvent( qualifiedName); } if (nonNull(path)) { - String contentIdField = resolveField(Attachments.CONTENT_ID, inlinePrefix); - String statusField = resolveField(Attachments.STATUS, inlinePrefix); - String scannedAtField = resolveField(Attachments.SCANNED_AT, inlinePrefix); - String mimeTypeField = resolveField(MediaData.MIME_TYPE, inlinePrefix); - String fileNameField = resolveField(MediaData.FILE_NAME, inlinePrefix); + String contentIdField = + ApplicationHandlerHelper.resolveFieldName(Attachments.CONTENT_ID, inlinePrefix); + String statusField = + ApplicationHandlerHelper.resolveFieldName(Attachments.STATUS, inlinePrefix); + String scannedAtField = + ApplicationHandlerHelper.resolveFieldName(Attachments.SCANNED_AT, inlinePrefix); + String mimeTypeField = + ApplicationHandlerHelper.resolveFieldName(MediaData.MIME_TYPE, inlinePrefix); + String fileNameField = + ApplicationHandlerHelper.resolveFieldName(MediaData.FILE_NAME, inlinePrefix); String newContentId = (String) path.target().values().get(contentIdField); if (nonNull(newContentId) && newContentId.equals(attachment.getContentId()) @@ -77,8 +82,4 @@ public InputStream processEvent( } return content; } - - private static String resolveField(String fieldName, Optional inlinePrefix) { - return inlinePrefix.map(p -> p + "_" + fieldName).orElse(fieldName); - } } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java index 668c93573..730660269 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java @@ -282,6 +282,10 @@ public static Map removeDraftKey(Map keys) { return keyMap; } + public static String resolveFieldName(String fieldName, Optional inlinePrefix) { + return inlinePrefix.map(p -> p + "_" + fieldName).orElse(fieldName); + } + private ApplicationHandlerHelper() { // avoid instantiation } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java index 4d7c06402..121b949a3 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java @@ -9,6 +9,7 @@ import com.sap.cds.Result; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.StatusCode; +import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.malware.client.MalwareScanClient; import com.sap.cds.feature.attachments.service.malware.client.MalwareScanResultStatus; @@ -138,11 +139,6 @@ private Attachments extractAttachment(Result queryResult, Optional inlin return attachment; } - @VisibleForTesting - static String resolveColumn(String fieldName, Optional inlinePrefix) { - return inlinePrefix.map(p -> p + "_" + fieldName).orElse(fieldName); - } - private List selectData( CdsEntity attachmentEntity, String contentId, Optional inlinePrefix) { List result = new ArrayList<>(); @@ -160,13 +156,21 @@ private List selectData( } private Result readData(String contentId, CdsEntity entity, Optional inlinePrefix) { - String contentIdCol = resolveColumn(Attachments.CONTENT_ID, inlinePrefix); - String contentCol = resolveColumn(Attachments.CONTENT, inlinePrefix); - String statusCol = resolveColumn(Attachments.STATUS, inlinePrefix); + String contentIdCol = + ApplicationHandlerHelper.resolveFieldName(Attachments.CONTENT_ID, inlinePrefix); + String contentCol = + ApplicationHandlerHelper.resolveFieldName(Attachments.CONTENT, inlinePrefix); + String statusCol = ApplicationHandlerHelper.resolveFieldName(Attachments.STATUS, inlinePrefix); + + List columns = new ArrayList<>(); + columns.add(contentIdCol); + columns.add(contentCol); + columns.add(statusCol); + entity.keyElements().forEach(keyElement -> columns.add(keyElement.getName())); CqnSelect select = Select.from(entity) - .columns(contentIdCol, contentCol, statusCol) + .columns(columns.toArray(String[]::new)) .where( e -> e.get(contentIdCol).eq(contentId).and(e.get(statusCol).ne(StatusCode.CLEAN))); @@ -208,9 +212,11 @@ private void updateData( String contentId, MalwareScanResultStatus status, Optional inlinePrefix) { - String contentIdCol = resolveColumn(Attachments.CONTENT_ID, inlinePrefix); - String statusCol = resolveColumn(Attachments.STATUS, inlinePrefix); - String scannedAtCol = resolveColumn(Attachments.SCANNED_AT, inlinePrefix); + String contentIdCol = + ApplicationHandlerHelper.resolveFieldName(Attachments.CONTENT_ID, inlinePrefix); + String statusCol = ApplicationHandlerHelper.resolveFieldName(Attachments.STATUS, inlinePrefix); + String scannedAtCol = + ApplicationHandlerHelper.resolveFieldName(Attachments.SCANNED_AT, inlinePrefix); String mappedStatus = mapStatus(status); Instant scannedAt = Instant.now(); From c7ef2d4d25a82f1f821dff4fd5daef927ce09419 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 30 Apr 2026 15:55:33 +0200 Subject: [PATCH 41/45] fix: consolidate double fileName/mimeType header extraction into single pass The processEvent() method had a double fallback chain for inline attachments: first extracting fileName/mimeType to prefixed fields, then a second pass that would incorrectly store to unprefixed fields. Consolidate into a single pass that determines the correct field name (prefixed for inline, unprefixed for composition) upfront, then extracts from HTTP headers only once. Also extract a `prefixed()` helper to eliminate the repeated `inlinePrefix.map(p -> p + "_" + X).orElse(X)` pattern. --- .../modifyevents/CreateAttachmentEvent.java | 53 ++++++------------- .../CreateAttachmentEventTest.java | 24 +++------ 2 files changed, 24 insertions(+), 53 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java index fe421d191..b5deaea83 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java @@ -61,41 +61,23 @@ public InputStream processEvent( Map values = path.target().values(); Map keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys()); - Optional mimeTypeOptional = - getFieldValue(MediaData.MIME_TYPE, values, attachment, inlinePrefix); + String fileNameField = prefixed(inlinePrefix, MediaData.FILE_NAME); + String mimeTypeField = prefixed(inlinePrefix, MediaData.MIME_TYPE); + Optional fileNameOptional = getFieldValue(MediaData.FILE_NAME, values, attachment, inlinePrefix); - - // For inline attachments, CDS flattening breaks the Core.ContentDisposition.Filename and - // Core.MediaType path references, so the framework won't extract fileName/mimeType from the - // upload request headers. Extract them manually and persist into the prefixed fields. - if (inlinePrefix.isPresent() && fileNameOptional.isEmpty()) { - fileNameOptional = extractFileNameFromHeader(eventContext); - fileNameOptional.ifPresent( - fn -> values.put(inlinePrefix.get() + "_" + MediaData.FILE_NAME, fn)); - } - - // Extract mimeType from Content-Type header for ALL attachment types (inline and composition) - // when mimeType is not already set - if (mimeTypeOptional.isEmpty() && eventContext.getParameterInfo() != null) { - Optional headerMimeType = extractMimeTypeFromHeader(eventContext); - if (headerMimeType.isPresent()) { - mimeTypeOptional = headerMimeType; - String mimeTypeField = - inlinePrefix.map(p -> p + "_" + MediaData.MIME_TYPE).orElse(MediaData.MIME_TYPE); - values.put(mimeTypeField, mimeTypeOptional.get()); - } - } + Optional mimeTypeOptional = + getFieldValue(MediaData.MIME_TYPE, values, attachment, inlinePrefix); // Fall back to HTTP headers when values are not set in payload if (eventContext.getParameterInfo() != null) { if (fileNameOptional.isEmpty()) { fileNameOptional = extractFileNameFromHeader(eventContext); - fileNameOptional.ifPresent(fn -> values.put(MediaData.FILE_NAME, fn)); + fileNameOptional.ifPresent(fn -> values.put(fileNameField, fn)); } if (mimeTypeOptional.isEmpty()) { mimeTypeOptional = extractMimeTypeFromHeader(eventContext); - mimeTypeOptional.ifPresent(mt -> values.put(MediaData.MIME_TYPE, mt)); + mimeTypeOptional.ifPresent(mt -> values.put(mimeTypeField, mt)); } } @@ -112,32 +94,31 @@ public InputStream processEvent( listenerProvider.provideListener(result.contentId(), eventContext.getCdsRuntime()); eventContext.getChangeSetContext().register(createListener); - // Set contentId and status using correct field names (prefixed for inline) - String contentIdField = - inlinePrefix.map(p -> p + "_" + Attachments.CONTENT_ID).orElse(Attachments.CONTENT_ID); - String statusField = - inlinePrefix.map(p -> p + "_" + Attachments.STATUS).orElse(Attachments.STATUS); + String contentIdField = prefixed(inlinePrefix, Attachments.CONTENT_ID); + String statusField = prefixed(inlinePrefix, Attachments.STATUS); path.target().values().put(contentIdField, result.contentId()); path.target().values().put(statusField, result.status()); if (nonNull(result.scannedAt())) { - String scannedAtField = - inlinePrefix.map(p -> p + "_" + Attachments.SCANNED_AT).orElse(Attachments.SCANNED_AT); - path.target().values().put(scannedAtField, result.scannedAt()); + path.target() + .values() + .put(prefixed(inlinePrefix, Attachments.SCANNED_AT), result.scannedAt()); } return result.isInternalStored() ? content : null; } + private static String prefixed(Optional inlinePrefix, String fieldName) { + return inlinePrefix.map(p -> p + "_" + fieldName).orElse(fieldName); + } + private static Optional getFieldValue( String fieldName, Map values, Attachments attachment, Optional inlinePrefix) { - // Try prefixed field name first (for inline types) if (inlinePrefix.isPresent()) { - Object prefixedValue = values.get(inlinePrefix.get() + "_" + fieldName); + Object prefixedValue = values.get(prefixed(inlinePrefix, fieldName)); if (nonNull(prefixedValue)) return Optional.of((String) prefixedValue); } - // Fall back to direct field name Object annotationValue = values.get(fieldName); Object value = nonNull(annotationValue) ? annotationValue : attachment.get(fieldName); return Optional.ofNullable((String) value); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java index aeda67d46..126246203 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java @@ -12,10 +12,8 @@ import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; -import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; import com.sap.cds.feature.attachments.handler.applicationservice.transaction.ListenerProvider; import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; -import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.model.service.AttachmentModificationResult; import com.sap.cds.feature.attachments.service.model.service.CreateAttachmentInput; @@ -382,9 +380,7 @@ void headersSkippedWhenParameterInfoIsNull() { @Test void inlineContentIdAndStatusWrittenWithPrefix() { - CdsEntity realEntity = - RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); - when(target.entity()).thenReturn(realEntity); + when(entity.getQualifiedName()).thenReturn(TEST_FULL_NAME); Map values = new HashMap<>(); values.put("ID", UUID.randomUUID().toString()); @@ -405,9 +401,7 @@ void inlineContentIdAndStatusWrittenWithPrefix() { @Test void inlinePrefixedFieldValuesPassedToService() { - CdsEntity realEntity = - RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); - when(target.entity()).thenReturn(realEntity); + when(entity.getQualifiedName()).thenReturn(TEST_FULL_NAME); Map values = new HashMap<>(); values.put("ID", UUID.randomUUID().toString()); @@ -431,9 +425,7 @@ void inlinePrefixedFieldValuesPassedToService() { @Test void inlineFallsBackToAttachmentObjectWhenPrefixedFieldMissing() { - CdsEntity realEntity = - RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); - when(target.entity()).thenReturn(realEntity); + when(entity.getQualifiedName()).thenReturn(TEST_FULL_NAME); Map values = new HashMap<>(); values.put("ID", UUID.randomUUID().toString()); @@ -480,9 +472,7 @@ void nonInlineEntityDoesNotUsePrefixedFields() { @Test void processEventWritesScannedAtWhenNonNull() { - CdsEntity realEntity = - RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); - when(target.entity()).thenReturn(realEntity); + when(entity.getQualifiedName()).thenReturn(TEST_FULL_NAME); Map values = new HashMap<>(); values.put("ID", UUID.randomUUID().toString()); @@ -506,9 +496,7 @@ void processEventWritesScannedAtWhenNonNull() { // --- Inline Header Extraction Tests --- private Map prepareInlineValuesWithoutMetadata() { - CdsEntity realEntity = - RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); - when(target.entity()).thenReturn(realEntity); + when(entity.getQualifiedName()).thenReturn(TEST_FULL_NAME); Map values = new HashMap<>(); values.put("ID", UUID.randomUUID().toString()); @@ -529,6 +517,7 @@ void inlineExtractsFileNameFromRfc5987Header() { path, mock(InputStream.class), inlineAttachment("profilePicture"), eventContext); assertThat(values).containsEntry("profilePicture_fileName", "my file.txt"); + assertThat(values).doesNotContainKey(MediaData.FILE_NAME); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("my file.txt"); } @@ -584,6 +573,7 @@ void inlineExtractsMimeTypeFromContentTypeHeader() { path, mock(InputStream.class), inlineAttachment("profilePicture"), eventContext); assertThat(values).containsEntry("profilePicture_mimeType", "image/jpeg"); + assertThat(values).doesNotContainKey(MediaData.MIME_TYPE); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().mimeType()).isEqualTo("image/jpeg"); } From 89310b5e8189dc024167436565aaf7ea70cb9d58 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 30 Apr 2026 15:56:05 +0200 Subject: [PATCH 42/45] refactor: clarify operator precedence in MarkAsDeletedAttachmentEvent Extract compound condition into named booleans (replacedBySameContent, noNewContentSupplied) for readability. No behavior change. --- .../modifyevents/MarkAsDeletedAttachmentEvent.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java index 554e01c66..2e8a4c2ab 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java @@ -69,8 +69,10 @@ public InputStream processEvent( ApplicationHandlerHelper.resolveFieldName(MediaData.FILE_NAME, inlinePrefix); String newContentId = (String) path.target().values().get(contentIdField); - if (nonNull(newContentId) && newContentId.equals(attachment.getContentId()) - || !path.target().values().containsKey(contentIdField)) { + boolean replacedBySameContent = + nonNull(newContentId) && newContentId.equals(attachment.getContentId()); + boolean noNewContentSupplied = !path.target().values().containsKey(contentIdField); + if (replacedBySameContent || noNewContentSupplied) { path.target().values().put(contentIdField, null); path.target().values().put(statusField, null); path.target().values().put(scannedAtField, null); From 85787a446047734fa619d13e4e011135c1332c73 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 30 Apr 2026 16:01:57 +0200 Subject: [PATCH 43/45] fix: use entity keys in malware scanner update to prevent cross-entity corruption The updateData method previously used only contentId in the WHERE clause, which could update the wrong row if contentId was not unique across entities. Now extracts entity keys from the SELECT result and uses matching(keys) for precise updates. --- .../DefaultAttachmentMalwareScanner.java | 42 +++++++++++++++---- .../DefaultAttachmentMalwareScannerTest.java | 38 +++++++++++++++-- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java index 121b949a3..ba4848aac 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java @@ -24,7 +24,9 @@ import java.io.InputStream; import java.time.Instant; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import org.slf4j.Logger; @@ -88,8 +90,9 @@ public void scanAttachment( // Update ALL candidate entities. This ensures the scan result is persisted // even if the attachment moved between draft and active tables during the scan. - for (SelectionResult result : selectionResults) { - updateData(result.entity(), contentId, status, inlinePrefix); + for (SelectionResult selectionResult : selectionResults) { + Map keys = extractKeys(selectionResult.result(), selectionResult.entity()); + updateData(selectionResult.entity(), contentId, status, inlinePrefix, keys); } } @@ -155,6 +158,25 @@ private List selectData( return result; } + private Map extractKeys(Result result, CdsEntity entity) { + if (result.rowCount() != 1) { + return Map.of(); + } + var row = result.single(); + Map keys = new HashMap<>(); + entity + .keyElements() + .forEach( + keyElement -> { + String keyName = keyElement.getName(); + Object value = row.get(keyName); + if (value != null) { + keys.put(keyName, value); + } + }); + return keys; + } + private Result readData(String contentId, CdsEntity entity, Optional inlinePrefix) { String contentIdCol = ApplicationHandlerHelper.resolveFieldName(Attachments.CONTENT_ID, inlinePrefix); @@ -211,7 +233,8 @@ private void updateData( CdsEntity attachmentEntity, String contentId, MalwareScanResultStatus status, - Optional inlinePrefix) { + Optional inlinePrefix, + Map entityKeys) { String contentIdCol = ApplicationHandlerHelper.resolveFieldName(Attachments.CONTENT_ID, inlinePrefix); String statusCol = ApplicationHandlerHelper.resolveFieldName(Attachments.STATUS, inlinePrefix); @@ -225,10 +248,15 @@ private void updateData( updateData.put(statusCol, mappedStatus); updateData.put(scannedAtCol, scannedAt); - CqnUpdate update = - Update.entity(attachmentEntity) - .data(updateData) - .where(entry -> entry.get(contentIdCol).eq(contentId)); + CqnUpdate update; + if (entityKeys.isEmpty()) { + update = + Update.entity(attachmentEntity) + .data(updateData) + .where(entry -> entry.get(contentIdCol).eq(contentId)); + } else { + update = Update.entity(attachmentEntity).data(updateData).matching(entityKeys); + } Result result = persistenceService.run(update); logger.debug( diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java index 6da4ecff9..43ed758e9 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java @@ -109,7 +109,11 @@ void fallbackToActiveEntityIfDraftHasNoData() { var content = mock(InputStream.class); var cdsData = Attachments.create(); cdsData.setContent(content); + cdsData.setId("test-key-id"); when(result.single(Attachments.class)).thenReturn(cdsData); + var row = mock(Row.class); + when(row.get("ID")).thenReturn("test-key-id"); + when(result.single()).thenReturn(row); when(malwareScanClient.scanContent(any())).thenReturn(MalwareScanResultStatus.CLEAN); cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); @@ -151,7 +155,12 @@ void dataAreUpdatedWithStatusFromFailingScanClient() { var entity = runtime.getCdsModel().findEntity(getTestServiceAttachmentName()); when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); when(result.rowCount()).thenReturn(1L); - when(result.single(Attachments.class)).thenReturn(Attachments.create()); + var data = Attachments.create(); + data.setId("test-key-id"); + when(result.single(Attachments.class)).thenReturn(data); + var row = mock(Row.class); + when(row.get("ID")).thenReturn("test-key-id"); + when(result.single()).thenReturn(row); when(malwareScanClient.scanContent(any())) .thenThrow(new ServiceException("Error reading attachment")); @@ -165,7 +174,12 @@ void dataAreUpdatedWithStatusFromFailingAttachmentService() { var entity = runtime.getCdsModel().findEntity(getTestServiceAttachmentName()); when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); when(result.rowCount()).thenReturn(1L); - when(result.single(Attachments.class)).thenReturn(Attachments.create()); + var data = Attachments.create(); + data.setId("test-key-id"); + when(result.single(Attachments.class)).thenReturn(data); + var row = mock(Row.class); + when(row.get("ID")).thenReturn("test-key-id"); + when(result.single()).thenReturn(row); when(attachmentService.readAttachment(any())) .thenThrow(new ServiceException("Error reading attachment")); @@ -228,9 +242,13 @@ void updateAttemptedForAllEntitiesEvenWhenActiveHasNoData() { var originSelectionData = Attachments.create(); originSelectionData.setContentId("first contentId"); originSelectionData.setContent(mock(InputStream.class)); + originSelectionData.setId("test-key-id"); when(result.single(Attachments.class)) .thenReturn(originSelectionData) .thenReturn(Attachments.create()); + var row = mock(Row.class); + when(row.get("ID")).thenReturn("test-key-id"); + when(result.single()).thenReturn(row); when(malwareScanClient.scanContent(any())).thenReturn(MalwareScanResultStatus.CLEAN); cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); @@ -247,10 +265,14 @@ void clientNotCalledIfNoInstanceBound() { var entity = runtime.getCdsModel().findEntity(getTestServiceAttachmentName()); var secondResult = mock(Result.class); when(secondResult.rowCount()).thenReturn(0L); - when(secondResult.single(Attachments.class)).thenReturn(Attachments.create()); when(persistenceService.run(any(CqnSelect.class))).thenReturn(result).thenReturn(secondResult); when(result.rowCount()).thenReturn(1L); - when(result.single(Attachments.class)).thenReturn(Attachments.create()); + var data = Attachments.create(); + data.setId("test-key-id"); + when(result.single(Attachments.class)).thenReturn(data); + var row = mock(Row.class); + when(row.get("ID")).thenReturn("test-key-id"); + when(result.single()).thenReturn(row); cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); @@ -273,10 +295,14 @@ void scanResultWrittenToAllEntitiesEvenIfDraftDeletedDuringScanning() { var content = mock(InputStream.class); var draftData = Attachments.create(); draftData.setContent(content); + draftData.setId("test-key-id"); // Phase 1: draft has the row, active does not when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); when(result.rowCount()).thenReturn(1L).thenReturn(0L); when(result.single(Attachments.class)).thenReturn(draftData); + var row = mock(Row.class); + when(row.get("ID")).thenReturn("test-key-id"); + when(result.single()).thenReturn(row); when(malwareScanClient.scanContent(any())).thenReturn(MalwareScanResultStatus.CLEAN); // Phase 2: simulate draft deleted (0 rows updated), active now has the row (1 row updated) var draftUpdateResult = mock(Result.class); @@ -388,9 +414,13 @@ private String getTestServiceAttachmentName() { } private void mockSelectResult(Attachments cdsData, MalwareScanResultStatus status) { + cdsData.setId("test-key-id"); when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); when(result.rowCount()).thenReturn(1L); when(result.single(Attachments.class)).thenReturn(cdsData); + var row = mock(Row.class); + when(row.get("ID")).thenReturn("test-key-id"); + when(result.single()).thenReturn(row); when(malwareScanClient.scanContent(any())).thenReturn(status); } From 1b4f1575eb9c17dbc0c91b5be01d28ac6eb1b20a Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 30 Apr 2026 16:04:36 +0200 Subject: [PATCH 44/45] refactor: simplify DraftCancel validator and prevent orphan attachments (C5+S5) Extract extractAttachmentFromPath() helper to reduce nesting in buildDeleteContentValidator(). Add defensive orphan deletion: when a draft attachment has a contentId but no matching active entry, delete it to prevent orphaned content in the attachment service. --- .../DraftCancelAttachmentsHandler.java | 61 ++++++++++++------- .../DraftCancelAttachmentsHandlerTest.java | 5 +- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java index 9f4078cbc..bedcd3b5d 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java @@ -15,6 +15,8 @@ import com.sap.cds.feature.attachments.handler.common.AttachmentsReader; import com.sap.cds.ql.CQL; import com.sap.cds.ql.cqn.CqnDelete; +import com.sap.cds.ql.cqn.Path; +import com.sap.cds.reflect.CdsElement; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.reflect.CdsStructuredType; import com.sap.cds.services.draft.DraftCancelEventContext; @@ -101,27 +103,16 @@ void processBeforeDraftCancel(DraftCancelEventContext context) { private Validator buildDeleteContentValidator( DraftCancelEventContext context, List activeCondensedAttachments) { return (path, element, value) -> { - Optional inlinePrefix = - ApplicationHandlerHelper.getInlineAttachmentPrefix( - path.target().entity(), element.getName()); - - Attachments attachment; - if (inlinePrefix.isPresent()) { - attachment = - ApplicationHandlerHelper.extractInlineAttachment( - path.target().values(), inlinePrefix.get()); - Object hasActiveEntity = path.target().values().get(Drafts.HAS_ACTIVE_ENTITY); - if (hasActiveEntity != null) { - attachment.put(Drafts.HAS_ACTIVE_ENTITY, hasActiveEntity); - } - } else { - attachment = Attachments.of(path.target().values()); - } + Attachments attachment = extractAttachmentFromPath(path, element); if (Boolean.FALSE.equals(attachment.get(Drafts.HAS_ACTIVE_ENTITY))) { deleteEvent.processEvent(path, null, attachment, context); return; } + + Optional inlinePrefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix( + path.target().entity(), element.getName()); Map keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys()); Optional existingEntry = activeCondensedAttachments.stream() @@ -135,16 +126,40 @@ private Validator buildDeleteContentValidator( return ApplicationHandlerHelper.areKeysInData(keys, updatedData); }) .findAny(); - existingEntry.ifPresent( - entry -> { - Object existingContentId = entry.get(Attachments.CONTENT_ID); - if (!Objects.equals(existingContentId, attachment.getContentId())) { - deleteEvent.processEvent(null, null, attachment, context); - } - }); + + if (existingEntry.isPresent()) { + Object existingContentId = existingEntry.get().get(Attachments.CONTENT_ID); + if (!Objects.equals(existingContentId, attachment.getContentId())) { + deleteEvent.processEvent(null, null, attachment, context); + } + } else if (attachment.getContentId() != null) { + logger.warn( + "Draft attachment with contentId {} has no matching active entry. Deleting to prevent orphan.", + attachment.getContentId()); + deleteEvent.processEvent(null, null, attachment, context); + } }; } + private Attachments extractAttachmentFromPath(Path path, CdsElement element) { + Optional inlinePrefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix( + path.target().entity(), element.getName()); + Attachments attachment; + if (inlinePrefix.isPresent()) { + attachment = + ApplicationHandlerHelper.extractInlineAttachment( + path.target().values(), inlinePrefix.get()); + Object hasActiveEntity = path.target().values().get(Drafts.HAS_ACTIVE_ENTITY); + if (hasActiveEntity != null) { + attachment.put(Drafts.HAS_ACTIVE_ENTITY, hasActiveEntity); + } + } else { + attachment = Attachments.of(path.target().values()); + } + return attachment; + } + private List readAttachments( DraftCancelEventContext context, CdsStructuredType entity, boolean isActiveEntity) { logger.debug( diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java index 2210cc701..22c4bc159 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java @@ -6,6 +6,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.*; import com.sap.cds.CdsData; @@ -271,8 +272,8 @@ void noMatchingActiveEntryForDraftAttachment() { cut.processBeforeDraftCancel(eventContext); - // Should not call deleteEvent since keys don't match - verifyNoInteractions(deleteContentAttachmentEvent); + // Orphan prevention: draft has contentId but no matching active entry, so delete it + verify(deleteContentAttachmentEvent).processEvent(isNull(), isNull(), any(), eq(eventContext)); } @Test From 406a168444990c03fcf6a4b8b434600db50708d8 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 4 May 2026 11:37:06 +0200 Subject: [PATCH 45/45] updates --- .../ReadAttachmentsHandler.java | 2 +- .../modifyevents/CreateAttachmentEvent.java | 23 +++++++++++-------- .../common/ApplicationHandlerHelper.java | 2 +- .../handler/common/AttachmentsReader.java | 7 ++++-- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java index 06c8d697f..c5601e2c2 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java @@ -152,7 +152,7 @@ void processAfter(CdsReadEventContext context, List data) { private void verifyStatus(Path path, Attachments attachment) { Optional inlinePrefix = Optional.ofNullable((String) attachment.get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER)); - if (areKeysEmpty(path.target().keys())) { + if (areKeysEmpty(path.target().keys()) || inlinePrefix.isPresent()) { String currentStatus = attachment.getStatus(); logger.debug( "In verify status for content id {} and status {}", diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java index b5deaea83..85435bf92 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java @@ -61,8 +61,10 @@ public InputStream processEvent( Map values = path.target().values(); Map keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys()); - String fileNameField = prefixed(inlinePrefix, MediaData.FILE_NAME); - String mimeTypeField = prefixed(inlinePrefix, MediaData.MIME_TYPE); + String fileNameField = + ApplicationHandlerHelper.resolveFieldName(MediaData.FILE_NAME, inlinePrefix); + String mimeTypeField = + ApplicationHandlerHelper.resolveFieldName(MediaData.MIME_TYPE, inlinePrefix); Optional fileNameOptional = getFieldValue(MediaData.FILE_NAME, values, attachment, inlinePrefix); @@ -94,29 +96,30 @@ public InputStream processEvent( listenerProvider.provideListener(result.contentId(), eventContext.getCdsRuntime()); eventContext.getChangeSetContext().register(createListener); - String contentIdField = prefixed(inlinePrefix, Attachments.CONTENT_ID); - String statusField = prefixed(inlinePrefix, Attachments.STATUS); + String contentIdField = + ApplicationHandlerHelper.resolveFieldName(Attachments.CONTENT_ID, inlinePrefix); + String statusField = + ApplicationHandlerHelper.resolveFieldName(Attachments.STATUS, inlinePrefix); path.target().values().put(contentIdField, result.contentId()); path.target().values().put(statusField, result.status()); if (nonNull(result.scannedAt())) { path.target() .values() - .put(prefixed(inlinePrefix, Attachments.SCANNED_AT), result.scannedAt()); + .put( + ApplicationHandlerHelper.resolveFieldName(Attachments.SCANNED_AT, inlinePrefix), + result.scannedAt()); } return result.isInternalStored() ? content : null; } - private static String prefixed(Optional inlinePrefix, String fieldName) { - return inlinePrefix.map(p -> p + "_" + fieldName).orElse(fieldName); - } - private static Optional getFieldValue( String fieldName, Map values, Attachments attachment, Optional inlinePrefix) { if (inlinePrefix.isPresent()) { - Object prefixedValue = values.get(prefixed(inlinePrefix, fieldName)); + Object prefixedValue = + values.get(ApplicationHandlerHelper.resolveFieldName(fieldName, inlinePrefix)); if (nonNull(prefixedValue)) return Optional.of((String) prefixedValue); } Object annotationValue = values.get(fieldName); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java index 730660269..2ba79a2c6 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java @@ -154,7 +154,7 @@ public static boolean isInlineAttachmentContentField( return false; // This is a composition-based attachment entity, not inline } String elementName = element.getName(); - return elementName.contains("_") + return elementName.endsWith("_content") && element.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false) && element.findAnnotation(ANNOTATION_CORE_MEDIA_TYPE).isPresent(); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java index 9fadf551e..2a1a06594 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java @@ -82,8 +82,11 @@ private List buildInlineAttachmentColumns(CdsEntity entity) { columns.add(CQL.get(fieldName + "_" + Attachments.CONTENT_ID)); columns.add(CQL.get(fieldName + "_" + Attachments.STATUS)); } - if (!columns.isEmpty() && entity.findElement(Drafts.HAS_ACTIVE_ENTITY).isPresent()) { - columns.add(CQL.get(Drafts.HAS_ACTIVE_ENTITY)); + if (!columns.isEmpty()) { + entity.keyElements().forEach(keyElement -> columns.add(CQL.get(keyElement.getName()))); + if (entity.findElement(Drafts.HAS_ACTIVE_ENTITY).isPresent()) { + columns.add(CQL.get(Drafts.HAS_ACTIVE_ENTITY)); + } } return columns; }