diff --git a/.github/actions/deploy-central-snapshot/action.yml b/.github/actions/deploy-central-snapshot/action.yml new file mode 100644 index 000000000..0d9bbd755 --- /dev/null +++ b/.github/actions/deploy-central-snapshot/action.yml @@ -0,0 +1,86 @@ +name: Deploy Snapshot to Central Portal +description: "Deploys a Maven SNAPSHOT package to Sonatype Central Portal Snapshots repository." + +inputs: + user: + description: "Sonatype Central Portal username (same as Maven Central)" + required: true + password: + description: "Sonatype Central Portal password (same as Maven Central)" + required: true + pgp-pub-key: + description: "The public pgp key ID (optional for snapshots but recommended)" + required: false + pgp-private-key: + description: "The private pgp key (optional for snapshots but recommended)" + required: false + pgp-passphrase: + description: "The passphrase for pgp (optional for snapshots but recommended)" + required: false + +runs: + using: composite + steps: + - name: "Setup Java" + uses: actions/setup-java@v4 + with: + distribution: 'sapmachine' + java-version: '21' + cache: maven + server-id: central + server-username: CENTRAL_USER + server-password: CENTRAL_PASSWORD + + - name: "Import GPG Key (if provided)" + if: ${{ inputs.pgp-private-key != '' }} + run: | + set +x + echo "::add-mask::$PGP_PRIVATE_KEY" + echo "::add-mask::$PASSPHRASE" + echo "$PGP_PRIVATE_KEY" | gpg --batch --passphrase "$PASSPHRASE" --import + shell: bash + env: + PGP_PRIVATE_KEY: ${{ inputs.pgp-private-key }} + PASSPHRASE: ${{ inputs.pgp-passphrase }} + + - name: "Verify SNAPSHOT version" + run: | + VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout) + echo "Current version: $VERSION" + if [[ ! "$VERSION" == *-SNAPSHOT ]]; then + echo "Error: Version $VERSION is not a SNAPSHOT version!" + echo "Central Portal Snapshots repository only accepts SNAPSHOT versions." + exit 1 + fi + echo "✅ Version $VERSION is a valid SNAPSHOT" + shell: bash + + - name: "Deploy Snapshot to Central Portal" + run: | + set +x + echo "::add-mask::$CENTRAL_USER" + echo "::add-mask::$CENTRAL_PASSWORD" + [ -n "$GPG_PASSPHRASE" ] && echo "::add-mask::$GPG_PASSPHRASE" + [ -n "$GPG_PUB_KEY" ] && echo "::add-mask::$GPG_PUB_KEY" + echo "🚀 Deploying SNAPSHOT to Sonatype Central Portal..." + if [ -n "$GPG_PASSPHRASE" ] && [ -n "$GPG_PUB_KEY" ]; then + mvn -B -ntp --show-version \ + -Dmaven.install.skip=true \ + -Dmaven.test.skip=true \ + -Dgpg.passphrase="$GPG_PASSPHRASE" \ + -Dgpg.keyname="$GPG_PUB_KEY" \ + clean deploy -P deploy-central-snapshot + else + mvn -B -ntp --show-version \ + -Dmaven.install.skip=true \ + -Dmaven.test.skip=true \ + -Dgpg.skip=true \ + clean deploy -P deploy-central-snapshot + fi + echo "✅ Snapshot deployed successfully!" + shell: bash + env: + CENTRAL_USER: ${{ inputs.user }} + CENTRAL_PASSWORD: ${{ inputs.password }} + GPG_PASSPHRASE: ${{ inputs.pgp-passphrase }} + GPG_PUB_KEY: ${{ inputs.pgp-pub-key }} diff --git a/.github/workflows/deploy-central-snapshot.yml b/.github/workflows/deploy-central-snapshot.yml new file mode 100644 index 000000000..606a33d7c --- /dev/null +++ b/.github/workflows/deploy-central-snapshot.yml @@ -0,0 +1,125 @@ +name: Deploy Snapshot to Central Portal + +env: + JAVA_VERSION: '21' + +on: + # Manual trigger - select any branch from GitHub UI + workflow_dispatch: + inputs: + sign_artifacts: + description: 'Sign artifacts with GPG' + required: false + default: 'true' + type: choice + options: + - 'true' + - 'false' + + # Auto-trigger on push to testing branch + push: + branches: + - RBSDMS-NoCqnSnapshot-feature + + +permissions: + contents: read + packages: read + +jobs: + verify-snapshot: + runs-on: ubuntu-latest + outputs: + is_snapshot: ${{ steps.check.outputs.is_snapshot }} + version: ${{ steps.check.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: ${{ env.JAVA_VERSION }} + cache: maven + + - name: Check version is SNAPSHOT + id: check + run: | + VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout) + echo "version=$VERSION" >> $GITHUB_OUTPUT + if [[ "$VERSION" == *-SNAPSHOT ]]; then + echo "is_snapshot=true" >> $GITHUB_OUTPUT + echo "✅ Version $VERSION is a SNAPSHOT" + else + echo "is_snapshot=false" >> $GITHUB_OUTPUT + echo "❌ Version $VERSION is NOT a SNAPSHOT - deployment will be skipped" + fi + + build: + runs-on: ubuntu-latest + needs: verify-snapshot + if: needs.verify-snapshot.outputs.is_snapshot == 'true' + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: ${{ env.JAVA_VERSION }} + cache: maven + + - name: Build + run: | + echo "🔨 Building SNAPSHOT version: ${{ needs.verify-snapshot.outputs.version }}" + mvn clean install -P unit-tests -DskipIntegrationTests + echo "✅ Build completed successfully!" + + deploy: + name: Deploy Snapshot to Central Portal + runs-on: ubuntu-latest + needs: [verify-snapshot, build] + if: needs.verify-snapshot.outputs.is_snapshot == 'true' && needs.build.result == 'success' + environment: maven-central + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Deploy Snapshot (with GPG signing) + if: github.event.inputs.sign_artifacts != 'false' + uses: ./.github/actions/deploy-central-snapshot + with: + user: ${{ secrets.CENTRAL_REPOSITORY_USER }} + password: ${{ secrets.CENTRAL_REPOSITORY_PASS }} + pgp-pub-key: ${{ secrets.PGP_PUB_KEY }} + pgp-private-key: ${{ secrets.PGP_PRIVATE_KEY }} + pgp-passphrase: ${{ secrets.PGP_PASSPHRASE }} + + - name: Deploy Snapshot (without GPG signing) + if: github.event.inputs.sign_artifacts == 'false' + uses: ./.github/actions/deploy-central-snapshot + with: + user: ${{ secrets.CENTRAL_REPOSITORY_USER }} + password: ${{ secrets.CENTRAL_REPOSITORY_PASS }} + + - name: Summary + if: success() + run: | + echo "## 🚀 Snapshot Deployed to Central Portal" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ needs.verify-snapshot.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Repository:** https://central.sonatype.com/repository/maven-snapshots/" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Usage" >> $GITHUB_STEP_SUMMARY + echo '```xml' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + echo ' ' >> $GITHUB_STEP_SUMMARY + echo ' central-snapshots' >> $GITHUB_STEP_SUMMARY + echo ' https://central.sonatype.com/repository/maven-snapshots/' >> $GITHUB_STEP_SUMMARY + echo ' true' >> $GITHUB_STEP_SUMMARY + echo ' ' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY diff --git a/pom.xml b/pom.xml index 250ac7fe1..c319e0129 100644 --- a/pom.xml +++ b/pom.xml @@ -23,7 +23,7 @@ - 1.9.2 + 1.9.3-SNAPSHOT 17 ${java.version} ${java.version} @@ -346,6 +346,34 @@ + + deploy-central-snapshot + + + + central + Sonatype Central Portal Snapshots + https://central.sonatype.com/repository/maven-snapshots/ + + + + disabled-release + file:///dev/null + + + + + + org.sonatype.central + central-publishing-maven-plugin + false + + true + + + + + diff --git a/sdm/pom.xml b/sdm/pom.xml index 8210bbc7d..58ceff0fd 100644 --- a/sdm/pom.xml +++ b/sdm/pom.xml @@ -97,6 +97,20 @@ + + deploy-central-snapshot + + + central + Sonatype Central Portal Snapshots + https://central.sonatype.com/repository/maven-snapshots/ + + + disabled-release + file:///dev/null + + + diff --git a/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMCreateAttachmentsHandler.java b/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMCreateAttachmentsHandler.java index 01021a263..ca12ecdbf 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMCreateAttachmentsHandler.java +++ b/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMCreateAttachmentsHandler.java @@ -109,7 +109,7 @@ public void processBefore(CdsCreateEventContext context, List data) thr logger.info( "START: Process attachments before persistence for entity: {}", context.getTarget().getQualifiedName()); - logger.debug("Number of entities to process: {}", data.size()); + logger.info("Number of entities to process: {}", data.size()); for (CdsData entityData : data) { Map> attachmentCompositionDetails = @@ -119,7 +119,7 @@ public void processBefore(CdsCreateEventContext context, List data) thr persistenceService, context.getTarget().getQualifiedName(), entityData); - logger.debug("Attachment compositions present: {}", attachmentCompositionDetails.keySet()); + logger.info("Attachment compositions found: {}", attachmentCompositionDetails.keySet()); updateName(context, data, attachmentCompositionDetails); // Remove uploadStatus from attachment data to prevent validation errors cleanupReadonlyContextsForAttachments(context, entityData, attachmentCompositionDetails); @@ -151,32 +151,45 @@ public void processAfter(CdsCreateEventContext context, List data) { Optional attachmentEntity = context.getModel().findEntity(attachmentCompositionDefinition); - if (attachmentEntity.isPresent()) { - String targetEntity = context.getTarget().getQualifiedName(); - List> attachments = - AttachmentsHandlerUtils.fetchAttachments( - targetEntity, entityData, attachmentCompositionName); + if (!attachmentEntity.isPresent()) { + logger.warn( + "[SDM] CREATE: Attachment entity '{}' not found in CDS model — skipping uploadStatus persistence for composition '{}'", + attachmentCompositionDefinition, + attachmentCompositionName); + continue; + } - if (attachments != null) { - logger.debug( - "Processing {} attachments for composition: {}", - attachments.size(), - attachmentCompositionName); - for (Map attachment : attachments) { - String id = (String) attachment.get("ID"); - String uploadStatus = (String) attachment.get("uploadStatus"); - if (id != null) { - CmisDocument cmisDocument = new CmisDocument(); - cmisDocument.setAttachmentId(id); - cmisDocument.setUploadStatus(uploadStatus); - logger.debug("Saving uploadStatus: {} for attachment ID: {}", uploadStatus, id); - // Update uploadStatus to Success in database if it was InProgress - dbQuery.saveUploadStatusToAttachment( - attachmentEntity.get(), persistenceService, cmisDocument); - totalProcessed++; - } + String targetEntity = context.getTarget().getQualifiedName(); + List> attachments = + AttachmentsHandlerUtils.fetchAttachments( + targetEntity, entityData, attachmentCompositionName); + + if (attachments != null && !attachments.isEmpty()) { + logger.info( + "[SDM] CREATE: Persisting uploadStatus for {} attachment(s) in composition '{}'", + attachments.size(), + attachmentCompositionName); + for (Map attachment : attachments) { + String id = (String) attachment.get("ID"); + String uploadStatus = (String) attachment.get("uploadStatus"); + if (id != null) { + logger.debug("Saving uploadStatus '{}' for attachment ID: {}", uploadStatus, id); + CmisDocument cmisDocument = new CmisDocument(); + cmisDocument.setAttachmentId(id); + cmisDocument.setUploadStatus(uploadStatus); + dbQuery.saveUploadStatusToAttachment( + attachmentEntity.get(), persistenceService, cmisDocument); + totalProcessed++; + } else { + logger.warn( + "[SDM] CREATE: Attachment in composition '{}' has no ID — skipping uploadStatus persistence", + attachmentCompositionName); } } + } else { + logger.debug( + "No attachments in payload for composition '{}' during post-processing", + attachmentCompositionName); } } } @@ -657,7 +670,43 @@ private void cleanupReadonlyContextsForAttachments( } } } else { - logger.debug("No attachments found for composition: {}", attachmentCompositionName); + logger.warn( + "[SDM] CREATE: fetchAttachments returned no results for composition '{}' on entity '{}'. " + + "This may indicate a deeply nested composition whose property name does not match the entity name. " + + "Fallback recursive cleanup will handle SDM_READONLY_CONTEXT removal.", + attachmentCompositionName, + targetEntity); + } + } + // Fallback: recursively remove SDM_READONLY_CONTEXT from any nested structure + // that fetchAttachments failed to resolve (e.g. deeply nested compositions) + logger.info( + "[SDM] CREATE: Running recursive fallback to remove SDM_READONLY_CONTEXT from entity '{}'. " + + "Any WARN entries above indicate compositions where fetchAttachments could not resolve attachments.", + targetEntity); + removeReadonlyContextRecursively(entityData); + } + + @SuppressWarnings("unchecked") + private void removeReadonlyContextRecursively(Map data) { + if (data == null) { + return; + } + if (data.containsKey(SDM_READONLY_CONTEXT)) { + logger.warn( + "[SDM] CREATE: Fallback removed SDM_READONLY_CONTEXT from map with keys: {}. " + + "This entry was not cleaned up by the composition-based path — " + + "likely a deeply nested or mismatched composition name.", + data.keySet()); + data.remove(SDM_READONLY_CONTEXT); + } + for (Object value : data.values()) { + if (value instanceof List) { + for (Object item : (List) value) { + if (item instanceof Map) { + removeReadonlyContextRecursively((Map) item); + } + } } } } diff --git a/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMUpdateAttachmentsHandler.java b/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMUpdateAttachmentsHandler.java index 2f07d0643..65126256d 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMUpdateAttachmentsHandler.java +++ b/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMUpdateAttachmentsHandler.java @@ -85,32 +85,45 @@ public void processAfter(CdsUpdateEventContext context, List data) { Optional attachmentEntity = context.getModel().findEntity(attachmentCompositionDefinition); - if (attachmentEntity.isPresent()) { - String targetEntity = context.getTarget().getQualifiedName(); - List> attachments = - AttachmentsHandlerUtils.fetchAttachments( - targetEntity, entityData, attachmentCompositionName); + if (!attachmentEntity.isPresent()) { + logger.warn( + "[SDM] UPDATE: Attachment entity '{}' not found in CDS model — skipping uploadStatus persistence for composition '{}'", + attachmentCompositionDefinition, + attachmentCompositionName); + continue; + } - if (attachments != null) { - logger.debug( - "Processing {} attachments for composition: {}", - attachments.size(), - attachmentCompositionName); - for (Map attachment : attachments) { - String id = (String) attachment.get("ID"); - String uploadStatus = (String) attachment.get("uploadStatus"); - if (id != null) { - CmisDocument cmisDocument = new CmisDocument(); - cmisDocument.setAttachmentId(id); - cmisDocument.setUploadStatus(uploadStatus); - // Update uploadStatus to Success in database if it was InProgress - logger.debug("Saving uploadStatus: {} for attachment ID: {}", uploadStatus, id); - dbQuery.saveUploadStatusToAttachment( - attachmentEntity.get(), persistenceService, cmisDocument); - totalProcessed++; - } + String targetEntity = context.getTarget().getQualifiedName(); + List> attachments = + AttachmentsHandlerUtils.fetchAttachments( + targetEntity, entityData, attachmentCompositionName); + + if (attachments != null && !attachments.isEmpty()) { + logger.info( + "[SDM] UPDATE: Persisting uploadStatus for {} attachment(s) in composition '{}'", + attachments.size(), + attachmentCompositionName); + for (Map attachment : attachments) { + String id = (String) attachment.get("ID"); + String uploadStatus = (String) attachment.get("uploadStatus"); + if (id != null) { + logger.debug("Saving uploadStatus '{}' for attachment ID: {}", uploadStatus, id); + CmisDocument cmisDocument = new CmisDocument(); + cmisDocument.setAttachmentId(id); + cmisDocument.setUploadStatus(uploadStatus); + dbQuery.saveUploadStatusToAttachment( + attachmentEntity.get(), persistenceService, cmisDocument); + totalProcessed++; + } else { + logger.warn( + "[SDM] UPDATE: Attachment in composition '{}' has no ID — skipping uploadStatus persistence", + attachmentCompositionName); } } + } else { + logger.debug( + "No attachments in payload for composition '{}' during post-processing", + attachmentCompositionName); } } } @@ -123,7 +136,7 @@ public void processBefore(CdsUpdateEventContext context, List data) thr logger.info( "START: Process attachments before persistence for entity: {}", context.getTarget().getQualifiedName()); - logger.debug("Number of entities to update: {}", data.size()); + logger.info("Number of entities to update: {}", data.size()); // Get comprehensive attachment composition details for each entity for (CdsData entityData : data) { @@ -134,7 +147,7 @@ public void processBefore(CdsUpdateEventContext context, List data) thr persistenceService, context.getTarget().getQualifiedName(), entityData); - logger.debug("Attachment compositions present: {}", attachmentCompositionDetails.keySet()); + logger.info("Attachment compositions found: {}", attachmentCompositionDetails.keySet()); updateName(context, data, attachmentCompositionDetails); @@ -208,6 +221,10 @@ private void renameDocument( if (attachments != null && !attachments.isEmpty()) { propertyTitles = SDMUtils.getPropertyTitles(attachmentEntity, attachments.get(0)); } else { + logger.info( + "[SDM] UPDATE: No attachments in payload for composition '{}' on entity '{}' — skipping rename", + attachmentCompositionName, + targetEntity); propertyTitles = null; } if (attachments != null && !attachments.isEmpty()) { @@ -366,7 +383,10 @@ public void processAttachment( propertiesInDB); if (updatedSecondaryProperties.isEmpty()) { - logger.debug("No changes detected for attachment ID: {}, skipping SDM update", id); + logger.info( + "[SDM] UPDATE: No property changes detected for attachment ID: {} (fileName: '{}') — skipping SDM call", + id, + filenameInRequest); return; } @@ -513,7 +533,7 @@ private void updateAttachmentInSDM( secondaryPropertiesWithInvalidDefinitions, context.getUserInfo().isSystemUser()); - logger.debug("SDM update response code: {} for attachment ID: {}", responseCode, id); + logger.info("SDM update response code: {} for attachment ID: {}", responseCode, id); AttachmentsHandlerUtils.handleSDMUpdateResponse( responseCode, @@ -655,7 +675,43 @@ private void cleanupReadonlyContextsForAttachments( } } } else { - logger.debug("No attachments found for composition: {}", attachmentCompositionName); + logger.warn( + "[SDM] UPDATE: fetchAttachments returned no results for composition '{}' on entity '{}'. " + + "This may indicate a deeply nested composition whose property name does not match the entity name. " + + "Fallback recursive cleanup will handle SDM_READONLY_CONTEXT removal.", + attachmentCompositionName, + targetEntity); + } + } + // Fallback: recursively remove SDM_READONLY_CONTEXT from any nested structure + // that fetchAttachments failed to resolve (e.g. deeply nested compositions) + logger.info( + "[SDM] UPDATE: Running recursive fallback to remove SDM_READONLY_CONTEXT from entity '{}'. " + + "Any WARN entries above indicate compositions where fetchAttachments could not resolve attachments.", + targetEntity); + removeReadonlyContextRecursively(entityData); + } + + @SuppressWarnings("unchecked") + private void removeReadonlyContextRecursively(Map data) { + if (data == null) { + return; + } + if (data.containsKey(SDM_READONLY_CONTEXT)) { + logger.warn( + "[SDM] UPDATE: Fallback removed SDM_READONLY_CONTEXT from map with keys: {}. " + + "This entry was not cleaned up by the composition-based path — " + + "likely a deeply nested or mismatched composition name.", + data.keySet()); + data.remove(SDM_READONLY_CONTEXT); + } + for (Object value : data.values()) { + if (value instanceof List) { + for (Object item : (List) value) { + if (item instanceof Map) { + removeReadonlyContextRecursively((Map) item); + } + } } } } diff --git a/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMCreateAttachmentsHandlerTest.java b/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMCreateAttachmentsHandlerTest.java index 719a22edc..33cc1d179 100644 --- a/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMCreateAttachmentsHandlerTest.java +++ b/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMCreateAttachmentsHandlerTest.java @@ -2,6 +2,7 @@ import static org.junit.Assert.assertNull; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyList; @@ -11,6 +12,7 @@ import com.sap.cds.CdsData; import com.sap.cds.reflect.*; import com.sap.cds.sdm.caching.CacheConfig; +import com.sap.cds.sdm.constants.SDMConstants; import com.sap.cds.sdm.handler.TokenHandler; import com.sap.cds.sdm.handler.applicationservice.SDMCreateAttachmentsHandler; import com.sap.cds.sdm.handler.applicationservice.helper.AttachmentsHandlerUtils; @@ -1024,4 +1026,116 @@ public void testUpdateActiveEntitySdmMetadata_CorrectFieldMapping() { return true; })); } + + // --- Tests for removeReadonlyContextRecursively fallback --- + + @Test + public void testCleanupReadonlyContexts_DirectAttachment_RemovesSDMReadonlyContext() + throws Exception { + CdsCreateEventContext ctx = mock(CdsCreateEventContext.class); + CdsEntity entity = mock(CdsEntity.class); + when(ctx.getTarget()).thenReturn(entity); + when(entity.getQualifiedName()).thenReturn("AdminService.Books"); + + Map attachment = new HashMap<>(); + attachment.put("ID", "att1"); + attachment.put(SDMConstants.SDM_READONLY_CONTEXT, Map.of("uploadStatus", "Success")); + + Map entityData = new HashMap<>(); + entityData.put("attachments", List.of(attachment)); + + Map> compositionDetails = new HashMap<>(); + Map info = new HashMap<>(); + info.put("name", "AdminService.Books.attachments"); + compositionDetails.put("AdminService.Books.attachments", info); + + java.lang.reflect.Method method = + SDMCreateAttachmentsHandler.class.getDeclaredMethod( + "cleanupReadonlyContextsForAttachments", + CdsCreateEventContext.class, + Map.class, + Map.class); + method.setAccessible(true); + method.invoke(handler, ctx, entityData, compositionDetails); + + assertFalse( + attachment.containsKey(SDMConstants.SDM_READONLY_CONTEXT), + "SDM_READONLY_CONTEXT should be removed from flat attachment"); + } + + @Test + public void testCleanupReadonlyContexts_DeeplyNestedAttachment_FallbackRemovesSDMReadonlyContext() + throws Exception { + // Simulates customer scenario: Books → chapters → sections → attachments + // fetchAttachments fails because parentKey 'Sections' (entity name) != 'sections' (property + // name) + CdsCreateEventContext ctx = mock(CdsCreateEventContext.class); + CdsEntity entity = mock(CdsEntity.class); + when(ctx.getTarget()).thenReturn(entity); + when(entity.getQualifiedName()).thenReturn("AdminService.Books"); + + Map attachment = new HashMap<>(); + attachment.put("ID", "att-nested"); + attachment.put(SDMConstants.SDM_READONLY_CONTEXT, Map.of("uploadStatus", "Success")); + + Map section = new HashMap<>(); + section.put("ID", "sec1"); + section.put("attachments", List.of(attachment)); + + Map chapter = new HashMap<>(); + chapter.put("ID", "chap1"); + chapter.put("sections", List.of(section)); // property name 'sections' != entity name 'Sections' + + Map entityData = new HashMap<>(); + entityData.put("cHapters", List.of(chapter)); + + Map> compositionDetails = new HashMap<>(); + Map info = new HashMap<>(); + info.put( + "name", + "AdminService.Sections.attachments"); // parentKeyFromComposition = 'Sections' — mismatch + compositionDetails.put("AdminService.Sections.attachments", info); + + java.lang.reflect.Method method = + SDMCreateAttachmentsHandler.class.getDeclaredMethod( + "cleanupReadonlyContextsForAttachments", + CdsCreateEventContext.class, + Map.class, + Map.class); + method.setAccessible(true); + method.invoke(handler, ctx, entityData, compositionDetails); + + assertFalse( + attachment.containsKey(SDMConstants.SDM_READONLY_CONTEXT), + "SDM_READONLY_CONTEXT should be removed from deeply nested attachment by fallback"); + } + + @Test + public void testCleanupReadonlyContexts_EmptyCompositionDetails_FallbackStillCleansUp() + throws Exception { + CdsCreateEventContext ctx = mock(CdsCreateEventContext.class); + CdsEntity entity = mock(CdsEntity.class); + when(ctx.getTarget()).thenReturn(entity); + when(entity.getQualifiedName()).thenReturn("AdminService.Books"); + + Map attachment = new HashMap<>(); + attachment.put("ID", "att1"); + attachment.put(SDMConstants.SDM_READONLY_CONTEXT, Map.of("uploadStatus", "InProgress")); + + Map entityData = new HashMap<>(); + entityData.put("attachments", List.of(attachment)); + + java.lang.reflect.Method method = + SDMCreateAttachmentsHandler.class.getDeclaredMethod( + "cleanupReadonlyContextsForAttachments", + CdsCreateEventContext.class, + Map.class, + Map.class); + method.setAccessible(true); + method.invoke(handler, ctx, entityData, new HashMap<>()); + + assertFalse( + attachment.containsKey(SDMConstants.SDM_READONLY_CONTEXT), + "SDM_READONLY_CONTEXT should be removed even when compositionDetails is empty"); + } } diff --git a/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMUpdateAttachmentsHandlerTest.java b/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMUpdateAttachmentsHandlerTest.java index 745887f8d..c3f53384e 100644 --- a/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMUpdateAttachmentsHandlerTest.java +++ b/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMUpdateAttachmentsHandlerTest.java @@ -1,5 +1,6 @@ package unit.com.sap.cds.sdm.handler.applicationservice; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -945,6 +946,126 @@ public void testRenameWithNoAttachments() throws IOException { // } // } + // --- Tests for removeReadonlyContextRecursively fallback --- + + @Test + public void testCleanupReadonlyContexts_DirectAttachment_RemovesSDMReadonlyContext() + throws Exception { + // SDM_READONLY_CONTEXT on a direct (flat) attachment is removed + CdsUpdateEventContext ctx = mock(CdsUpdateEventContext.class); + CdsEntity entity = mock(CdsEntity.class); + when(ctx.getTarget()).thenReturn(entity); + when(entity.getQualifiedName()).thenReturn("AdminService.Books"); + + Map attachment = new HashMap<>(); + attachment.put("ID", "att1"); + attachment.put(SDMConstants.SDM_READONLY_CONTEXT, Map.of("uploadStatus", "Success")); + + List> attachments = new ArrayList<>(); + attachments.add(attachment); + + Map entityData = new HashMap<>(); + entityData.put("attachments", attachments); + + Map> compositionDetails = new HashMap<>(); + Map info = new HashMap<>(); + info.put("name", "AdminService.Books.attachments"); + compositionDetails.put("AdminService.Books.attachments", info); + + java.lang.reflect.Method method = + SDMUpdateAttachmentsHandler.class.getDeclaredMethod( + "cleanupReadonlyContextsForAttachments", + CdsUpdateEventContext.class, + Map.class, + Map.class); + method.setAccessible(true); + method.invoke(handler, ctx, entityData, compositionDetails); + + assertFalse( + attachment.containsKey(SDMConstants.SDM_READONLY_CONTEXT), + "SDM_READONLY_CONTEXT should be removed from flat attachment"); + } + + @Test + public void testCleanupReadonlyContexts_DeeplyNestedAttachment_FallbackRemovesSDMReadonlyContext() + throws Exception { + // Simulates customer scenario: Books → chapters → sections → attachments + // fetchAttachments fails to find the attachment because parentKey 'Sections' (entity name) + // does not match 'sections' (property name in payload) — fallback must clean it up + CdsUpdateEventContext ctx = mock(CdsUpdateEventContext.class); + CdsEntity entity = mock(CdsEntity.class); + when(ctx.getTarget()).thenReturn(entity); + when(entity.getQualifiedName()).thenReturn("AdminService.Books"); + + Map attachment = new HashMap<>(); + attachment.put("ID", "att-nested"); + attachment.put(SDMConstants.SDM_READONLY_CONTEXT, Map.of("uploadStatus", "Success")); + + List> attachments = new ArrayList<>(); + attachments.add(attachment); + + Map section = new HashMap<>(); + section.put("ID", "sec1"); + section.put("attachments", attachments); + + Map chapter = new HashMap<>(); + chapter.put("ID", "chap1"); + chapter.put("sections", List.of(section)); // property name 'sections' != entity name 'Sections' + + Map entityData = new HashMap<>(); + entityData.put("cHapters", List.of(chapter)); + + // Composition name uses entity name 'Sections' — parentKeyFromComposition = 'Sections' + // but entityData key is 'sections' → fetchAttachments returns empty → fallback must handle it + Map> compositionDetails = new HashMap<>(); + Map info = new HashMap<>(); + info.put("name", "AdminService.Sections.attachments"); + compositionDetails.put("AdminService.Sections.attachments", info); + + java.lang.reflect.Method method = + SDMUpdateAttachmentsHandler.class.getDeclaredMethod( + "cleanupReadonlyContextsForAttachments", + CdsUpdateEventContext.class, + Map.class, + Map.class); + method.setAccessible(true); + method.invoke(handler, ctx, entityData, compositionDetails); + + assertFalse( + attachment.containsKey(SDMConstants.SDM_READONLY_CONTEXT), + "SDM_READONLY_CONTEXT should be removed from deeply nested attachment by fallback"); + } + + @Test + public void testCleanupReadonlyContexts_EmptyCompositionDetails_FallbackStillCleansUp() + throws Exception { + // When getAttachmentCompositionDetails returns empty (e.g. on error), fallback still cleans up + CdsUpdateEventContext ctx = mock(CdsUpdateEventContext.class); + CdsEntity entity = mock(CdsEntity.class); + when(ctx.getTarget()).thenReturn(entity); + when(entity.getQualifiedName()).thenReturn("AdminService.Books"); + + Map attachment = new HashMap<>(); + attachment.put("ID", "att1"); + attachment.put(SDMConstants.SDM_READONLY_CONTEXT, Map.of("uploadStatus", "InProgress")); + + Map entityData = new HashMap<>(); + entityData.put("attachments", List.of(attachment)); + + java.lang.reflect.Method method = + SDMUpdateAttachmentsHandler.class.getDeclaredMethod( + "cleanupReadonlyContextsForAttachments", + CdsUpdateEventContext.class, + Map.class, + Map.class); + method.setAccessible(true); + method.invoke(handler, ctx, entityData, new HashMap<>()); + + assertFalse( + attachment.containsKey(SDMConstants.SDM_READONLY_CONTEXT), + "SDM_READONLY_CONTEXT should be removed even when compositionDetails is empty"); + } + private List prepareMockAttachmentData(String... fileNames) { List data = new ArrayList<>(); for (String fileName : fileNames) {