diff --git a/README.md b/README.md index d0fbc1af5..e2bb94059 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This plugin can be consumed by the CAP application deployed on BTP to store thei - Draft functionality : Provides the capability of working with draft attachments. - Display attachments specific to repository: Lists attachments contained in the repository that is configured with the CAP application. - Custom properties : Provides the capability to define custom properties for attachments. -- Maximum allowed uploads: Provides the capability to define the maximum number of uploads allowed for the user. +- Maximum allowed uploads: Provides the capability to define the maximum number of uploads allowed for the user. Automatically disables the upload button in the UI when the limit is reached. - Maximum file size: Provides the capability to specify the maximum file size for attachments. - Multiple attachment facets: Provides the capability to define multiple attachment facets/sections in the CAP Entity. - Technical user support: Provides the capability to consume the plugin using technical user. @@ -37,6 +37,7 @@ This plugin can be consumed by the CAP application deployed on BTP to store thei - [Support for Multitenancy](#support-for-multitenancy) - [Support for Custom Properties](#support-for-custom-properties) - [Support for Maximum allowed uploads](#support-for-maximum-allowed-uploads) +- [Upload Button Auto-Disable in the UI](#upload-button-auto-disable-in-the-ui) - [Support for Maximum File Size](#support-for-maximum-file-size) - [Support for Multiple attachment facets](#support-for-multiple-attachment-facets) - [Support for Technical user](#support-for-technical-user) @@ -494,9 +495,90 @@ Example for German language in `messages_de.properties`: SDM.maxCountErrorMessage = Maximale Anzahl von Anhängen erreicht ``` -> **Note** -> -> Once the maxCount is configured, it is recommended not to alter it. If the maxCount is altered, the previously uploaded documents will still be visible. +#### Upload Button Auto-Disable in the UI + +When `maxCount` is configured, the plugin automatically computes a virtual boolean field (e.g. `isAttachmentsUploadable`) on the parent entity at read time and sets it to `false` when the limit is reached. To wire this up in your Fiori UI so the **Upload button is automatically disabled**, follow the steps below. + +**1. Declare the virtual field on the parent entity** + +Add a `virtual` boolean field for each `maxCount` annotated composition directly on the parent entity in your CDS schema. The field name must follow the pattern `is` + capitalised composition name + `Uploadable`: + +```cds +entity Books : managed, cuid { + // ... other fields ... + virtual isAttachmentsUploadable : Boolean; + virtual isReferencesUploadable : Boolean; + + attachments : Composition of many Attachments @SDM.Attachments:{maxCount: 4}; + references : Composition of many Attachments @SDM.Attachments:{maxCount: 2}; +} +``` + +- For a composition named `attachments` declare `virtual isAttachmentsUploadable : Boolean`. +- For `references` declare `virtual isReferencesUploadable : Boolean`, and so on. +- Virtual fields are never stored in the database; the plugin populates them at read time. + +**2. Disable the Upload button via `InsertRestrictions`** + +Annotate each attachment entity in your service to bind its insertability to the virtual field on the parent: + +```cds +annotate MyService.Books.attachments with @( + Capabilities: {InsertRestrictions: {Insertable: up_.isAttachmentsUploadable}} +); +``` + +- Replace `MyService.Books.attachments` with your service and entity path. +- Repeat for every composition facet that has a `maxCount`. + +**3. Refresh the parent after attachment changes via `SideEffects`** + +After an upload or deletion, Fiori must re-read the parent entity to pick up the updated virtual field and reflect the new button state. Add a named `Common.SideEffects` annotation on the parent entity: + +```cds +annotate MyService.Books with @( + Common.SideEffects #attachmentsUploadable: { + SourceEntities: ['attachments'], + TargetProperties: [''] + } +); +``` + +- `SourceEntities: ['attachments']` — triggers the refresh when the `attachments` list changes. +- `TargetEntities: ['']` — re-fetches the parent entity (`Books`) so the updated `isAttachmentsUploadable` value is returned to the UI. +- The qualifier (e.g. `#attachmentsUploadable`) can be any unique name; it is only needed to distinguish multiple `SideEffects` annotations on the same entity. + +**Example with multiple facets** + +If an entity has several `maxCount`-annotated compositions, add one `virtual field` declaration, one `InsertRestrictions`, and one `SideEffects` per facet: + + +```cds +annotate MyService.Books.attachments with @( + Capabilities: {InsertRestrictions: {Insertable: up_.isAttachmentsUploadable}} +); + +annotate MyService.Books.references with @( + Capabilities: {InsertRestrictions: {Insertable: up_.isReferencesUploadable}} +); + +annotate MyService.Books with @( + Common.SideEffects #attachmentsUploadable: { + SourceEntities: ['attachments'], + TargetProperties: ['isAttachmentsUploadable'] + }, + Common.SideEffects #referencesUploadable: { + SourceEntities: ['references'], + TargetProperties: ['isAttachmentsUploadable'] + } +); +``` +See this [example](https://github.com/cap-java/sdm/blob/cc537c5c855ad59fa14100a397536ab22f4b1aa7/cap-notebook/demoapp/db/schema.cds#L19) of `virtual field` declaration from a sample Bookshop app. + +See this [example](https://github.com/cap-java/sdm/blob/cc537c5c855ad59fa14100a397536ab22f4b1aa7/cap-notebook/demoapp/srv/admin-service.cds#L46) of `InsertRestriction` annotation from a sample Bookshop app. + +See this [example](https://github.com/cap-java/sdm/blob/cc537c5c855ad59fa14100a397536ab22f4b1aa7/cap-notebook/demoapp/srv/admin-service.cds#L279) of `SideEffects` annotation from a sample Bookshop app. + ## Support for Maximum File Size diff --git a/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMReadAttachmentsHandler.java b/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMReadAttachmentsHandler.java index d8b543292..776b12f92 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMReadAttachmentsHandler.java +++ b/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMReadAttachmentsHandler.java @@ -1,10 +1,14 @@ package com.sap.cds.sdm.handler.applicationservice; +import com.sap.cds.CdsData; +import com.sap.cds.Result; import com.sap.cds.ql.CQL; import com.sap.cds.ql.Predicate; import com.sap.cds.ql.cqn.CqnSelect; import com.sap.cds.ql.cqn.Modifier; +import com.sap.cds.reflect.CdsAnnotation; import com.sap.cds.reflect.CdsAssociationType; +import com.sap.cds.reflect.CdsElement; import com.sap.cds.reflect.CdsElementDefinition; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.reflect.CdsModel; @@ -26,12 +30,14 @@ import com.sap.cds.services.cds.CdsReadEventContext; import com.sap.cds.services.draft.Drafts; import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.After; import com.sap.cds.services.handler.annotations.Before; import com.sap.cds.services.handler.annotations.HandlerOrder; import com.sap.cds.services.handler.annotations.ServiceName; import com.sap.cds.services.persistence.PersistenceService; import java.io.IOException; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; @@ -405,4 +411,306 @@ private RepoValue checkRepositoryTypeWithFallback( return null; } } + + /** + * After reading a parent entity, counts its attachments per composition facet and sets the + * corresponding virtual uploadable flag (e.g. {@code isAttachmentsUploadable}) in each result + * row. Values are computed at read time so no flag is ever written to the consumer's database. + */ + @After + @HandlerOrder(HandlerOrder.LATE) + public void populateUploadableFlags(CdsReadEventContext context, List data) { + if (data == null || data.isEmpty()) return; + + CdsEntity target = context.getTarget(); + logger.info( + "populateUploadableFlags: entity={} rows={}", target.getQualifiedName(), data.size()); + + List facets = findFacetsWithMaxCount(target); + if (!facets.isEmpty()) { + logger.debug( + "populateUploadableFlags Path1: entity={} facets={}", + target.getQualifiedName(), + facets.size()); + + String keyField = + target + .elements() + .filter(CdsElement::isKey) + .filter(e -> !"IsActiveEntity".equals(e.getName())) + .map(CdsElement::getName) + .findFirst() + .orElse(null); + if (keyField == null) return; + + long keyFieldCount = + target + .elements() + .filter(CdsElement::isKey) + .filter(e -> !"IsActiveEntity".equals(e.getName())) + .count(); + if (keyFieldCount > 1) { + logger.warn( + "populateUploadableFlags Path1: entity={} has {} key fields; only '{}' is used for parentId lookup", + target.getQualifiedName(), + keyFieldCount, + keyField); + } + + CdsModel model = context.getModel(); + // Cache keyed by "facetName|parentId|isDraft" to avoid one DB query per row per facet. + Map uploadableCache = new HashMap<>(); + for (CdsData row : data) { + // Determine draft state per row — a single result set can mix active and draft records. + boolean rowIsDraft = Boolean.FALSE.equals(row.get("IsActiveEntity")); + Object keyVal = row.get(keyField); + if (keyVal == null) { + logger.debug("populateUploadableFlags Path1: skipping row with null keyVal"); + continue; + } + String parentId = keyVal.toString(); + + for (FacetInfo facet : facets) { + String attachmentEntityBase = target.getQualifiedName() + "." + facet.facetName; + CdsEntity attachmentEntity = + resolveAttachmentEntityForCount(model, attachmentEntityBase, rowIsDraft); + if (attachmentEntity == null) { + logger.debug( + "populateUploadableFlags Path1: entity not found, skipping facet={}", + facet.facetName); + continue; + } + + String upIdKey = SDMUtils.getUpIdKey(attachmentEntity); + if (upIdKey.isEmpty()) continue; + + String cacheKey = facet.facetName + "|" + parentId + "|" + rowIsDraft; + boolean isUploadable = + uploadableCache.computeIfAbsent( + cacheKey, + k -> + dbQuery + .getAttachmentsForUPID( + attachmentEntity, persistenceService, parentId, upIdKey) + .rowCount() + < facet.maxCount); + logger.debug( + "Path1: entity={} parentId={} facet={} uploadable={}", + target.getQualifiedName(), + parentId, + facet.facetName, + isUploadable); + row.put(facet.virtualFieldName, isUploadable); + } + } + return; + } + + logger.info( + "populateUploadableFlags Path2: entity={} checking for up_ expansion", + target.getQualifiedName()); + populateUploadableFlagsViaUp(context, target, data); + } + + /** + * Populates {@code up_.isXxxUploadable} on attachment entity result rows that carry an expanded + * {@code up_} navigation property. Called when the target entity is an attachment (not a parent) + * and Fiori requested {@code $expand=up_} to evaluate the Insert button state. + */ + private void populateUploadableFlagsViaUp( + CdsReadEventContext context, CdsEntity attachmentEntity, List data) { + String entityQName = attachmentEntity.getQualifiedName(); + boolean hasUpData = data.stream().anyMatch(row -> row.get("up_") != null); + logger.info( + "populateUploadableFlagsViaUp: entity={} rows={} hasUpData={}", + entityQName, + data.size(), + hasUpData); + if (!hasUpData) return; + + // CAP names draft sibling tables with a "_drafts" suffix — a stable framework convention. + boolean isDraft = entityQName.endsWith("_drafts"); + logger.debug("populateUploadableFlagsViaUp: isDraft={}", isDraft); + String baseEntityName = + isDraft ? entityQName.substring(0, entityQName.length() - 7) : entityQName; + + int lastDot = baseEntityName.lastIndexOf('.'); + if (lastDot < 0) { + logger.debug( + "populateUploadableFlagsViaUp: no dot in entity name={}, skipping", baseEntityName); + return; + } + String facetName = baseEntityName.substring(lastDot + 1); + String parentBaseEntityName = baseEntityName.substring(0, lastDot); + logger.info( + "populateUploadableFlagsViaUp: facetName={} parentEntity={}", + facetName, + parentBaseEntityName); + + CdsModel model = context.getModel(); + CdsEntity baseParentEntity = model.findEntity(parentBaseEntityName).orElse(null); + if (baseParentEntity == null) { + logger.debug( + "populateUploadableFlagsViaUp: parent entity not found={}", parentBaseEntityName); + return; + } + + Optional> maxCountAnnotation = + baseParentEntity + .compositions() + .filter(c -> facetName.equals(c.getName())) + .findFirst() + .flatMap(c -> c.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT)); + if (!maxCountAnnotation.isPresent()) { + logger.info( + "populateUploadableFlagsViaUp: no maxCount for facet={} on entity={}", + facetName, + parentBaseEntityName); + return; + } + + long maxCount; + try { + maxCount = Long.parseLong(String.valueOf(maxCountAnnotation.get().getValue())); + } catch (NumberFormatException e) { + logger.debug( + "populateUploadableFlagsViaUp: invalid maxCount value={} for facet={}", + maxCountAnnotation.get().getValue(), + facetName); + return; + } + if (maxCount <= 0) { + logger.debug( + "populateUploadableFlagsViaUp: maxCount={} is non-positive for facet={}, skipping", + maxCount, + facetName); + return; + } + logger.debug("populateUploadableFlagsViaUp: maxCount={} facet={}", maxCount, facetName); + + String virtualFieldName = toVirtualFieldName(facetName); + logger.debug("populateUploadableFlagsViaUp: virtualField={}", virtualFieldName); + + String upIdKey = SDMUtils.getUpIdKey(attachmentEntity); + logger.debug("populateUploadableFlagsViaUp: upIdKey={}", upIdKey); + if (upIdKey.isEmpty()) return; + + Map uploadableCache = new HashMap<>(); + for (CdsData row : data) { + Object upDataObj = row.get("up_"); + if (!(upDataObj instanceof Map)) continue; + + @SuppressWarnings("unchecked") + Map upMap = (Map) upDataObj; + + Object parentIdObj = row.get(upIdKey); + if (parentIdObj == null) continue; + String parentId = parentIdObj.toString(); + + boolean isUploadable = + uploadableCache.computeIfAbsent( + parentId, + id -> { + Result countResult = + dbQuery.getAttachmentsForUPID( + attachmentEntity, persistenceService, id, upIdKey); + return countResult.rowCount() < maxCount; + }); + + logger.debug( + "up_ expansion: entity={} parentId={} facet={} virtualField={} uploadable={}", + entityQName, + parentId, + facetName, + virtualFieldName, + isUploadable); + // Written into the up_ map, not into row: Fiori evaluates the Insert button state from + // up_.isXxxUploadable via the $expand=up_ response, not from the attachment row itself. + upMap.put(virtualFieldName, isUploadable); + } + } + + private List findFacetsWithMaxCount(CdsEntity target) { + List result = new ArrayList<>(); + List compositions = target.compositions().collect(Collectors.toList()); + for (CdsElementDefinition composition : compositions) { + String facetName = composition.getName(); + logger.debug("findFacetsWithMaxCount: checking composition={}", facetName); + Optional> maxCountAnnotation = + composition.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT); + if (!maxCountAnnotation.isPresent()) { + logger.debug( + "findFacetsWithMaxCount: no maxCount annotation for composition={}", facetName); + continue; + } + + long maxCount; + try { + maxCount = Long.parseLong(String.valueOf(maxCountAnnotation.get().getValue())); + } catch (NumberFormatException e) { + logger.debug( + "findFacetsWithMaxCount: invalid maxCount value for composition={}", facetName); + continue; + } + if (maxCount <= 0) { + logger.debug( + "findFacetsWithMaxCount: maxCount={} is non-positive for composition={}, skipping", + maxCount, + facetName); + continue; + } + + String virtualFieldName = toVirtualFieldName(facetName); + logger.debug( + "findFacetsWithMaxCount: facet={} virtualField={} maxCount={}", + facetName, + virtualFieldName, + maxCount); + result.add(new FacetInfo(facetName, virtualFieldName, maxCount)); + } + logger.debug("findFacetsWithMaxCount: found {} facet(s) with maxCount", result.size()); + return result; + } + + private CdsEntity resolveAttachmentEntityForCount( + CdsModel model, String baseEntityName, boolean isDraft) { + logger.debug("resolveAttachmentEntityForCount: base={} isDraft={}", baseEntityName, isDraft); + if (isDraft) { + Optional draftOpt = model.findEntity(baseEntityName + "_drafts"); + if (draftOpt.isPresent()) { + logger.debug( + "resolveAttachmentEntityForCount: resolved to draft entity={}", + baseEntityName + "_drafts"); + return draftOpt.get(); + } + logger.warn( + "resolveAttachmentEntityForCount: _drafts entity not found for '{}', falling back to active entity", + baseEntityName); + } + CdsEntity active = model.findEntity(baseEntityName).orElse(null); + logger.debug( + "resolveAttachmentEntityForCount: resolved to active entity={} found={}", + baseEntityName, + active != null); + return active; + } + + private static String toVirtualFieldName(String facetName) { + return "is" + + Character.toUpperCase(facetName.charAt(0)) + + facetName.substring(1) + + "Uploadable"; + } + + private static final class FacetInfo { + final String facetName; + final String virtualFieldName; + final long maxCount; + + FacetInfo(String facetName, String virtualFieldName, long maxCount) { + this.facetName = facetName; + this.virtualFieldName = virtualFieldName; + this.maxCount = maxCount; + } + } } diff --git a/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMReadAttachmentsHandlerPopulateUploadableFlagsTest.java b/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMReadAttachmentsHandlerPopulateUploadableFlagsTest.java new file mode 100644 index 000000000..b53c7261a --- /dev/null +++ b/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMReadAttachmentsHandlerPopulateUploadableFlagsTest.java @@ -0,0 +1,901 @@ +/* + * © 2024 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package unit.com.sap.cds.sdm.handler.applicationservice; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import com.sap.cds.CdsData; +import com.sap.cds.Result; +import com.sap.cds.reflect.CdsAnnotation; +import com.sap.cds.reflect.CdsElement; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.sdm.constants.SDMConstants; +import com.sap.cds.sdm.handler.TokenHandler; +import com.sap.cds.sdm.handler.applicationservice.SDMReadAttachmentsHandler; +import com.sap.cds.sdm.persistence.DBQuery; +import com.sap.cds.sdm.service.SDMService; +import com.sap.cds.sdm.utilities.SDMUtils; +import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.persistence.PersistenceService; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class SDMReadAttachmentsHandlerPopulateUploadableFlagsTest { + + @Mock private CdsReadEventContext context; + @Mock private PersistenceService persistenceService; + @Mock private SDMService sdmService; + @Mock private TokenHandler tokenHandler; + @Mock private DBQuery dbQuery; + + private SDMReadAttachmentsHandler cut; + + @BeforeEach + void setUp() { + cut = new SDMReadAttachmentsHandler(persistenceService, sdmService, tokenHandler, dbQuery); + } + + // ── Early returns ───────────────────────────────────────────────────────── + + @Test + void testPopulateUploadableFlags_nullData() { + cut.populateUploadableFlags(context, null); + verifyNoInteractions(context); + } + + @Test + void testPopulateUploadableFlags_emptyData() { + cut.populateUploadableFlags(context, List.of()); + verifyNoInteractions(context); + } + + // ── Path 1: parent entity with maxCount compositions ────────────────────── + + @Test + void testPopulateUploadableFlags_path1_belowMaxCount_setsTrue() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity attachmentEntity = mock(CdsEntity.class); + Result countResult = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.getQualifiedName()).thenReturn("sap.capire.Books"); + when(target.compositions()).thenAnswer(inv -> Stream.of(buildComposition("attachments", "2"))); + when(target.elements()).thenAnswer(inv -> Stream.of(buildKeyElement("ID"))); + when(model.findEntity("sap.capire.Books.attachments")) + .thenReturn(Optional.of(attachmentEntity)); + when(countResult.rowCount()).thenReturn(1L); + when(dbQuery.getAttachmentsForUPID( + eq(attachmentEntity), eq(persistenceService), eq("p1"), eq("up__ID"))) + .thenReturn(countResult); + + Map rowMap = new HashMap<>(); + rowMap.put("ID", "p1"); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(attachmentEntity)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row)); + } + + assertThat(row.get("isAttachmentsUploadable")).isEqualTo(true); + } + + @Test + void testPopulateUploadableFlags_path1_atMaxCount_setsFalse() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity attachmentEntity = mock(CdsEntity.class); + Result countResult = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.getQualifiedName()).thenReturn("sap.capire.Books"); + when(target.compositions()).thenAnswer(inv -> Stream.of(buildComposition("attachments", "2"))); + when(target.elements()).thenAnswer(inv -> Stream.of(buildKeyElement("ID"))); + when(model.findEntity("sap.capire.Books.attachments")) + .thenReturn(Optional.of(attachmentEntity)); + when(countResult.rowCount()).thenReturn(2L); + when(dbQuery.getAttachmentsForUPID( + eq(attachmentEntity), eq(persistenceService), eq("p1"), eq("up__ID"))) + .thenReturn(countResult); + + Map rowMap = new HashMap<>(); + rowMap.put("ID", "p1"); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(attachmentEntity)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row)); + } + + assertThat(row.get("isAttachmentsUploadable")).isEqualTo(false); + } + + @Test + void testPopulateUploadableFlags_path1_keyFieldNull_returnsEarly() { + CdsEntity target = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + when(target.compositions()).thenAnswer(inv -> Stream.of(buildComposition("attachments", "2"))); + when(target.elements()).thenAnswer(inv -> Stream.empty()); + + Map rowMap = new HashMap<>(); + rowMap.put("ID", "p1"); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + assertThat(row.get("isAttachmentsUploadable")).isNull(); + } + + @Test + void testPopulateUploadableFlags_path1_keyValNull_rowSkipped() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.getQualifiedName()).thenReturn("sap.capire.Books"); + when(target.compositions()).thenAnswer(inv -> Stream.of(buildComposition("attachments", "2"))); + when(target.elements()).thenAnswer(inv -> Stream.of(buildKeyElement("ID"))); + + Map rowMap = new HashMap<>(); + // "ID" key absent — row.get("ID") returns null + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_path1_attachmentEntityNull_facetSkipped() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.getQualifiedName()).thenReturn("sap.capire.Books"); + when(target.compositions()).thenAnswer(inv -> Stream.of(buildComposition("attachments", "2"))); + when(target.elements()).thenAnswer(inv -> Stream.of(buildKeyElement("ID"))); + when(model.findEntity("sap.capire.Books.attachments")).thenReturn(Optional.empty()); + + Map rowMap = new HashMap<>(); + rowMap.put("ID", "p1"); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + assertThat(row.get("isAttachmentsUploadable")).isNull(); + } + + @Test + void testPopulateUploadableFlags_path1_upIdKeyEmpty_facetSkipped() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity attachmentEntity = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.getQualifiedName()).thenReturn("sap.capire.Books"); + when(target.compositions()).thenAnswer(inv -> Stream.of(buildComposition("attachments", "2"))); + when(target.elements()).thenAnswer(inv -> Stream.of(buildKeyElement("ID"))); + when(model.findEntity("sap.capire.Books.attachments")) + .thenReturn(Optional.of(attachmentEntity)); + + Map rowMap = new HashMap<>(); + rowMap.put("ID", "p1"); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(attachmentEntity)).thenReturn(""); + cut.populateUploadableFlags(context, List.of(row)); + } + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + assertThat(row.get("isAttachmentsUploadable")).isNull(); + } + + @Test + void testPopulateUploadableFlags_path1_isDraft_draftEntityFound() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity draftAttachmentEntity = mock(CdsEntity.class); + Result countResult = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.getQualifiedName()).thenReturn("sap.capire.Books"); + when(target.compositions()).thenAnswer(inv -> Stream.of(buildComposition("attachments", "3"))); + when(target.elements()).thenAnswer(inv -> Stream.of(buildKeyElement("ID"))); + // isDraft=true because IsActiveEntity=false + when(model.findEntity("sap.capire.Books.attachments_drafts")) + .thenReturn(Optional.of(draftAttachmentEntity)); + when(countResult.rowCount()).thenReturn(1L); + when(dbQuery.getAttachmentsForUPID( + eq(draftAttachmentEntity), eq(persistenceService), eq("p1"), eq("up__ID"))) + .thenReturn(countResult); + + Map rowMap = new HashMap<>(); + rowMap.put("ID", "p1"); + rowMap.put("IsActiveEntity", false); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(draftAttachmentEntity)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row)); + } + + verify(model, never()).findEntity("sap.capire.Books.attachments"); + assertThat(row.get("isAttachmentsUploadable")).isEqualTo(true); + } + + @Test + void testPopulateUploadableFlags_path1_isDraft_draftEntityNotFound_fallbackActive() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity activeAttachmentEntity = mock(CdsEntity.class); + Result countResult = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.getQualifiedName()).thenReturn("sap.capire.Books"); + when(target.compositions()).thenAnswer(inv -> Stream.of(buildComposition("attachments", "2"))); + when(target.elements()).thenAnswer(inv -> Stream.of(buildKeyElement("ID"))); + when(model.findEntity("sap.capire.Books.attachments_drafts")).thenReturn(Optional.empty()); + when(model.findEntity("sap.capire.Books.attachments")) + .thenReturn(Optional.of(activeAttachmentEntity)); + when(countResult.rowCount()).thenReturn(0L); + when(dbQuery.getAttachmentsForUPID( + eq(activeAttachmentEntity), eq(persistenceService), eq("p1"), eq("up__ID"))) + .thenReturn(countResult); + + Map rowMap = new HashMap<>(); + rowMap.put("ID", "p1"); + rowMap.put("IsActiveEntity", false); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(activeAttachmentEntity)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row)); + } + + assertThat(row.get("isAttachmentsUploadable")).isEqualTo(true); + } + + @Test + void testPopulateUploadableFlags_path1_multipleCompositions_bothFlagsSet() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity attachmentsEntity = mock(CdsEntity.class); + CdsEntity referencesEntity = mock(CdsEntity.class); + Result attachResult = mock(Result.class); + Result refResult = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.getQualifiedName()).thenReturn("sap.capire.Books"); + when(target.compositions()) + .thenAnswer( + inv -> + Stream.of( + buildComposition("attachments", "2"), buildComposition("references", "3"))); + when(target.elements()).thenAnswer(inv -> Stream.of(buildKeyElement("ID"))); + when(model.findEntity("sap.capire.Books.attachments")) + .thenReturn(Optional.of(attachmentsEntity)); + when(model.findEntity("sap.capire.Books.references")).thenReturn(Optional.of(referencesEntity)); + when(attachResult.rowCount()).thenReturn(2L); // at limit → false + when(refResult.rowCount()).thenReturn(1L); // below limit → true + when(dbQuery.getAttachmentsForUPID(eq(attachmentsEntity), any(), eq("p1"), eq("up__ID"))) + .thenReturn(attachResult); + when(dbQuery.getAttachmentsForUPID(eq(referencesEntity), any(), eq("p1"), eq("refKey"))) + .thenReturn(refResult); + + Map rowMap = new HashMap<>(); + rowMap.put("ID", "p1"); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(attachmentsEntity)).thenReturn("up__ID"); + sdmUtils.when(() -> SDMUtils.getUpIdKey(referencesEntity)).thenReturn("refKey"); + cut.populateUploadableFlags(context, List.of(row)); + } + + assertThat(row.get("isAttachmentsUploadable")).isEqualTo(false); + assertThat(row.get("isReferencesUploadable")).isEqualTo(true); + } + + @Test + void testPopulateUploadableFlags_path1_multipleRows_dbCalledForEach() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity attachmentEntity = mock(CdsEntity.class); + Result countResult1 = mock(Result.class); + Result countResult2 = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.getQualifiedName()).thenReturn("sap.capire.Books"); + when(target.compositions()).thenAnswer(inv -> Stream.of(buildComposition("attachments", "2"))); + when(target.elements()).thenAnswer(inv -> Stream.of(buildKeyElement("ID"))); + when(model.findEntity("sap.capire.Books.attachments")) + .thenReturn(Optional.of(attachmentEntity)); + when(countResult1.rowCount()).thenReturn(1L); + when(countResult2.rowCount()).thenReturn(2L); + when(dbQuery.getAttachmentsForUPID(eq(attachmentEntity), any(), eq("p1"), eq("up__ID"))) + .thenReturn(countResult1); + when(dbQuery.getAttachmentsForUPID(eq(attachmentEntity), any(), eq("p2"), eq("up__ID"))) + .thenReturn(countResult2); + + Map rowMap1 = new HashMap<>(); + rowMap1.put("ID", "p1"); + Map rowMap2 = new HashMap<>(); + rowMap2.put("ID", "p2"); + CdsData row1 = CdsData.create(rowMap1); + CdsData row2 = CdsData.create(rowMap2); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(attachmentEntity)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row1, row2)); + } + + assertThat(row1.get("isAttachmentsUploadable")).isEqualTo(true); + assertThat(row2.get("isAttachmentsUploadable")).isEqualTo(false); + verify(dbQuery, times(2)).getAttachmentsForUPID(eq(attachmentEntity), any(), any(), any()); + } + + // ── findFacetsWithMaxCount edge cases (tested via routing to Path 2) ────── + + @Test + void testPopulateUploadableFlags_noMaxCountAnnotation_routesToPath2() { + CdsEntity target = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + // Composition present but no maxCount annotation → empty facet list → Path 2 + CdsElement comp = mock(CdsElement.class); + when(comp.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT)).thenReturn(Optional.empty()); + when(target.compositions()).thenAnswer(inv -> Stream.of(comp)); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + + // No "up_" in data → Path 2 returns early + Map rowMap = new HashMap<>(); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + // Path 1 DB call never made (facets empty), Path 2 returns early (no up_) + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_maxCountInvalidNumber_facetSkipped_routesToPath2() { + CdsEntity target = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + CdsElement comp = mock(CdsElement.class); + CdsAnnotation anno = buildAnnotation("not-a-number"); + when(comp.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT)).thenReturn(Optional.of(anno)); + when(target.compositions()).thenAnswer(inv -> Stream.of(comp)); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + + Map rowMap = new HashMap<>(); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_maxCountZero_facetSkipped_routesToPath2() { + CdsEntity target = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + CdsElement comp = mock(CdsElement.class); + CdsAnnotation anno = buildAnnotation("0"); + when(comp.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT)).thenReturn(Optional.of(anno)); + when(target.compositions()).thenAnswer(inv -> Stream.of(comp)); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + + Map rowMap = new HashMap<>(); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_maxCountNegative_facetSkipped_routesToPath2() { + CdsEntity target = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + CdsElement comp = mock(CdsElement.class); + CdsAnnotation anno = buildAnnotation("-1"); + when(comp.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT)).thenReturn(Optional.of(anno)); + when(target.compositions()).thenAnswer(inv -> Stream.of(comp)); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + + Map rowMap = new HashMap<>(); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + // ── Path 2: populateUploadableFlagsViaUp ────────────────────────────────── + + @Test + void testPopulateUploadableFlags_path2_noUpData_returnsEarly() { + CdsEntity target = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + + // Row has no "up_" key + Map rowMap = new HashMap<>(); + rowMap.put("up__ID", "p1"); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_path2_entityNameNoDot_returnsEarly() { + CdsEntity target = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + // No dot in entity name → lastDot < 0 → early return + when(target.getQualifiedName()).thenReturn("attachments"); + + Map rowMap = new HashMap<>(); + rowMap.put("up_", new HashMap<>()); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_path2_parentEntityNotFound_returnsEarly() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.empty()); + + Map rowMap = new HashMap<>(); + rowMap.put("up_", new HashMap<>()); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_path2_noMaxCountOnParentComposition_returnsEarly() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + CdsElement comp = mock(CdsElement.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + when(comp.getName()).thenReturn("attachments"); + when(comp.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT)).thenReturn(Optional.empty()); + when(parentEntity.compositions()).thenAnswer(inv -> Stream.of(comp)); + + Map rowMap = new HashMap<>(); + rowMap.put("up_", new HashMap<>()); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_path2_maxCountInvalid_returnsEarly() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "attachments", "not-a-number"); + + Map rowMap = new HashMap<>(); + rowMap.put("up_", new HashMap<>()); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_path2_maxCountZero_returnsEarly() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "attachments", "0"); + + Map rowMap = new HashMap<>(); + rowMap.put("up_", new HashMap<>()); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_path2_upIdKeyEmpty_returnsEarly() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "attachments", "2"); + + Map rowMap = new HashMap<>(); + rowMap.put("up_", new HashMap<>()); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(target)).thenReturn(""); + cut.populateUploadableFlags(context, List.of(row)); + } + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_path2_upDataNotMap_rowSkipped() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "attachments", "2"); + + Map rowMap = new HashMap<>(); + rowMap.put("up_", "not-a-map"); // not a Map + rowMap.put("up__ID", "p1"); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(target)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row)); + } + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_path2_parentIdNull_rowSkipped() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "attachments", "2"); + + Map upMap = new HashMap<>(); + Map rowMap = new HashMap<>(); + rowMap.put("up_", upMap); + // "up__ID" absent → parentIdObj is null → row skipped + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(target)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row)); + } + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + assertThat(upMap.get("isAttachmentsUploadable")).isNull(); + } + + @Test + void testPopulateUploadableFlags_path2_belowMaxCount_setsTrueOnUpMap() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + Result countResult = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "attachments", "2"); + when(countResult.rowCount()).thenReturn(1L); + when(dbQuery.getAttachmentsForUPID(eq(target), eq(persistenceService), eq("p1"), eq("up__ID"))) + .thenReturn(countResult); + + Map upMap = new HashMap<>(); + Map rowMap = new HashMap<>(); + rowMap.put("up_", upMap); + rowMap.put("up__ID", "p1"); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(target)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row)); + } + + assertThat(upMap.get("isAttachmentsUploadable")).isEqualTo(true); + } + + @Test + void testPopulateUploadableFlags_path2_atMaxCount_setsFalseOnUpMap() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + Result countResult = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "attachments", "2"); + when(countResult.rowCount()).thenReturn(2L); + when(dbQuery.getAttachmentsForUPID(eq(target), eq(persistenceService), eq("p1"), eq("up__ID"))) + .thenReturn(countResult); + + Map upMap = new HashMap<>(); + Map rowMap = new HashMap<>(); + rowMap.put("up_", upMap); + rowMap.put("up__ID", "p1"); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(target)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row)); + } + + assertThat(upMap.get("isAttachmentsUploadable")).isEqualTo(false); + } + + @Test + void testPopulateUploadableFlags_path2_isDraftEntity_stripsSuffix() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + Result countResult = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + // Entity name ends with _drafts + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments_drafts"); + // After stripping suffix: "sap.capire.Books.attachments" → facet="attachments", + // parent="sap.capire.Books" + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "attachments", "2"); + when(countResult.rowCount()).thenReturn(0L); + when(dbQuery.getAttachmentsForUPID(eq(target), any(), eq("p1"), eq("up__ID"))) + .thenReturn(countResult); + + Map upMap = new HashMap<>(); + Map rowMap = new HashMap<>(); + rowMap.put("up_", upMap); + rowMap.put("up__ID", "p1"); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(target)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row)); + } + + assertThat(upMap.get("isAttachmentsUploadable")).isEqualTo(true); + } + + @Test + void testPopulateUploadableFlags_path2_caching_sameParent_dbCalledOnce() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + Result countResult = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "attachments", "2"); + when(countResult.rowCount()).thenReturn(1L); + when(dbQuery.getAttachmentsForUPID(eq(target), eq(persistenceService), eq("p1"), eq("up__ID"))) + .thenReturn(countResult); + + // Two rows, same parent ID — DB should be called only once + Map upMap1 = new HashMap<>(); + Map rowMap1 = new HashMap<>(); + rowMap1.put("up_", upMap1); + rowMap1.put("up__ID", "p1"); + + Map upMap2 = new HashMap<>(); + Map rowMap2 = new HashMap<>(); + rowMap2.put("up_", upMap2); + rowMap2.put("up__ID", "p1"); + + CdsData row1 = CdsData.create(rowMap1); + CdsData row2 = CdsData.create(rowMap2); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(target)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row1, row2)); + } + + verify(dbQuery, times(1)).getAttachmentsForUPID(any(), any(), any(), any()); + assertThat(upMap1.get("isAttachmentsUploadable")).isEqualTo(true); + assertThat(upMap2.get("isAttachmentsUploadable")).isEqualTo(true); + } + + @Test + void testPopulateUploadableFlags_path2_caching_differentParents_dbCalledForEach() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + Result countResult1 = mock(Result.class); + Result countResult2 = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "attachments", "2"); + when(countResult1.rowCount()).thenReturn(1L); + when(countResult2.rowCount()).thenReturn(2L); + when(dbQuery.getAttachmentsForUPID(eq(target), eq(persistenceService), eq("p1"), eq("up__ID"))) + .thenReturn(countResult1); + when(dbQuery.getAttachmentsForUPID(eq(target), eq(persistenceService), eq("p2"), eq("up__ID"))) + .thenReturn(countResult2); + + Map upMap1 = new HashMap<>(); + Map rowMap1 = new HashMap<>(); + rowMap1.put("up_", upMap1); + rowMap1.put("up__ID", "p1"); + + Map upMap2 = new HashMap<>(); + Map rowMap2 = new HashMap<>(); + rowMap2.put("up_", upMap2); + rowMap2.put("up__ID", "p2"); + + CdsData row1 = CdsData.create(rowMap1); + CdsData row2 = CdsData.create(rowMap2); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(target)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row1, row2)); + } + + verify(dbQuery, times(2)).getAttachmentsForUPID(any(), any(), any(), any()); + assertThat(upMap1.get("isAttachmentsUploadable")).isEqualTo(true); + assertThat(upMap2.get("isAttachmentsUploadable")).isEqualTo(false); + } + + @Test + void testPopulateUploadableFlags_path2_facetNameFootnotes_virtualFieldCorrect() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + Result countResult = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + // facet = "footnotes" → virtualField = "isFootnotesUploadable" + when(target.getQualifiedName()).thenReturn("sap.capire.Chapters.footnotes"); + when(model.findEntity("sap.capire.Chapters")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "footnotes", "1"); + when(countResult.rowCount()).thenReturn(0L); + when(dbQuery.getAttachmentsForUPID(eq(target), any(), eq("c1"), eq("up__ID"))) + .thenReturn(countResult); + + Map upMap = new HashMap<>(); + Map rowMap = new HashMap<>(); + rowMap.put("up_", upMap); + rowMap.put("up__ID", "c1"); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(target)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row)); + } + + assertThat(upMap.get("isFootnotesUploadable")).isEqualTo(true); + assertThat(upMap.get("isAttachmentsUploadable")).isNull(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + private CdsElement buildComposition(String facetName, String maxCountValue) { + CdsElement comp = mock(CdsElement.class); + CdsAnnotation anno = buildAnnotation(maxCountValue); + when(comp.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT)).thenReturn(Optional.of(anno)); + when(comp.getName()).thenReturn(facetName); + return comp; + } + + private CdsElement buildKeyElement(String name) { + CdsElement el = mock(CdsElement.class); + when(el.isKey()).thenReturn(true); + when(el.getName()).thenReturn(name); + return el; + } + + @SuppressWarnings("unchecked") + private CdsAnnotation buildAnnotation(String value) { + CdsAnnotation anno = mock(CdsAnnotation.class); + when(anno.getValue()).thenReturn(value); + return anno; + } + + @SuppressWarnings("unchecked") + private void setupParentCompositionWithMaxCount( + CdsEntity parentEntity, String facetName, String maxCountValue) { + CdsElement comp = mock(CdsElement.class); + CdsAnnotation anno = buildAnnotation(maxCountValue); + when(comp.getName()).thenReturn(facetName); + when(comp.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT)).thenReturn(Optional.of(anno)); + when(parentEntity.compositions()).thenAnswer(inv -> Stream.of(comp)); + } +}