getOSBinding(CdsEnvironment environment)
.filter(b -> b.getServiceName().map(name -> name.equals("objectstore")).orElse(false))
.findFirst();
}
+
+ private static boolean isMultitenancyEnabled(CdsEnvironment env) {
+ return Boolean.TRUE.equals(
+ env.getProperty("cds.multitenancy.enabled", Boolean.class, Boolean.FALSE));
+ }
+
+ private static String getObjectStoreKind(CdsEnvironment env) {
+ return env.getProperty("cds.attachments.objectStore.kind", String.class, null);
+ }
}
diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandler.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandler.java
index 9a44d9c8a..cdc4e6bb3 100644
--- a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandler.java
+++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandler.java
@@ -6,27 +6,20 @@
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.cds4j.sap.attachments.StatusCode;
-import com.sap.cds.feature.attachments.oss.client.AWSClient;
-import com.sap.cds.feature.attachments.oss.client.AzureClient;
-import com.sap.cds.feature.attachments.oss.client.GoogleClient;
import com.sap.cds.feature.attachments.oss.client.OSClient;
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.feature.attachments.service.model.servicehandler.AttachmentRestoreEventContext;
+import com.sap.cds.services.EventContext;
import com.sap.cds.services.ServiceException;
import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.handler.annotations.On;
import com.sap.cds.services.handler.annotations.ServiceName;
-import com.sap.cloud.environment.servicebinding.api.ServiceBinding;
import java.io.InputStream;
-import java.nio.charset.StandardCharsets;
-import java.util.Base64;
import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
-import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -39,62 +32,24 @@ public class OSSAttachmentsServiceHandler implements EventHandler {
private static final Logger logger = LoggerFactory.getLogger(OSSAttachmentsServiceHandler.class);
private final OSClient osClient;
+ private final boolean multitenancyEnabled;
+ private final String objectStoreKind;
/**
- * Creates a new OSSAttachmentsServiceHandler using the provided {@link ServiceBinding}.
+ * Creates a new OSSAttachmentsServiceHandler with the given {@link OSClient}.
*
- * The handler will automatically detect the storage backend (AWS S3, Azure Blob Storage,
- * Google Cloud Storage) based on the credentials in the service binding. If no valid binding is
- * found, an {@link ObjectStoreServiceException} is thrown.
+ *
Use {@link com.sap.cds.feature.attachments.oss.client.OSClientFactory#create
+ * OSClientFactory.create()} to obtain an {@link OSClient} from a service binding.
*
- *
- * - For AWS, the binding must contain a "host" with "aws", "s3", or "amazon".
- *
- For Azure, the binding must contain a "container_uri" with "azure" or "windows".
- *
- For Google, the binding must contain a valid "base64EncodedPrivateKeyData" containing
- * "google" or "gcp".
- *
- *
- * @param binding the {@link ServiceBinding} containing credentials for the object store service
- * @throws ObjectStoreServiceException if no valid object store service binding is found
+ * @param osClient the object store client for storage operations
+ * @param multitenancyEnabled whether multitenancy is enabled
+ * @param objectStoreKind the object store kind (e.g. "shared")
*/
- public OSSAttachmentsServiceHandler(ServiceBinding binding, ExecutorService executor) {
- final String host = (String) binding.getCredentials().get("host"); // AWS
- final String containerUri = (String) binding.getCredentials().get("container_uri"); // Azure
- final String base64EncodedPrivateKeyData =
- (String) binding.getCredentials().get("base64EncodedPrivateKeyData"); // GCP
-
- // Check the service binding credentials to determine which client to use.
- if (host != null && Stream.of("aws", "s3", "amazon").anyMatch(host::contains)) {
- this.osClient = new AWSClient(binding, executor);
- } else if (containerUri != null
- && Stream.of("azure", "windows").anyMatch(containerUri::contains)) {
- this.osClient = new AzureClient(binding, executor);
- } else if (base64EncodedPrivateKeyData != null) {
- String decoded = "";
- try {
- decoded =
- new String(
- Base64.getDecoder().decode(base64EncodedPrivateKeyData), StandardCharsets.UTF_8);
- } catch (IllegalArgumentException e) {
- throw new ObjectStoreServiceException(
- "No valid base64EncodedPrivateKeyData found in Google service binding: %s"
- .formatted(binding),
- e);
- }
- // Redeclaring is needed here to make the variable effectively final for the
- // lambda expression
- final String dec = decoded;
- if (Stream.of("google", "gcp").anyMatch(dec::contains)) {
- this.osClient = new GoogleClient(binding, executor);
- } else {
- throw new ObjectStoreServiceException(
- "No valid Google service binding found in binding: %s".formatted(binding));
- }
- } else {
- throw new ObjectStoreServiceException(
- "No valid object store service found in binding: %s. Please ensure you have a valid AWS S3, Azure Blob Storage, or Google Cloud Storage service binding."
- .formatted(binding));
- }
+ public OSSAttachmentsServiceHandler(
+ OSClient osClient, boolean multitenancyEnabled, String objectStoreKind) {
+ this.osClient = osClient;
+ this.multitenancyEnabled = multitenancyEnabled;
+ this.objectStoreKind = objectStoreKind;
}
@On
@@ -106,9 +61,10 @@ void createAttachment(AttachmentCreateEventContext context) {
String contentId = (String) context.getAttachmentIds().get(Attachments.ID);
MediaData data = context.getData();
String fileName = data.getFileName();
+ String objectKey = buildObjectKey(context, contentId);
try {
- osClient.uploadContent(data.getContent(), contentId, data.getMimeType()).get();
+ osClient.uploadContent(data.getContent(), objectKey, data.getMimeType()).get();
logger.info("Uploaded file {}", fileName);
context.getData().setStatus(StatusCode.SCANNING);
context.setIsInternalStored(false);
@@ -126,11 +82,13 @@ void createAttachment(AttachmentCreateEventContext context) {
@On
void markAttachmentAsDeleted(AttachmentMarkAsDeletedEventContext context) {
logger.info(
- "OS Attachment Service handler called for marking attachment as deleted with document id {}",
+ "OS Attachment Service handler called for marking attachment as deleted with document id"
+ + " {}",
context.getContentId());
try {
- osClient.deleteContent(context.getContentId()).get();
+ String objectKey = buildObjectKey(context, context.getContentId());
+ osClient.deleteContent(objectKey).get();
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new ServiceException(
@@ -159,7 +117,8 @@ void readAttachment(AttachmentReadEventContext context) {
"OS Attachment Service handler called for reading attachment with document id: {}",
context.getContentId());
try {
- Future future = osClient.readContent(context.getContentId());
+ String objectKey = buildObjectKey(context, context.getContentId());
+ Future future = osClient.readContent(objectKey);
InputStream inputStream = future.get(); // Wait for the content to be read
if (inputStream != null) {
context.getData().setContent(inputStream);
@@ -179,4 +138,57 @@ void readAttachment(AttachmentReadEventContext context) {
context.setCompleted();
}
}
+
+ /**
+ * Builds the object key for storage operations. In shared multitenancy mode, the key is prefixed
+ * with the tenant ID ({@code tenantId/contentId}). Otherwise, the raw content ID is used.
+ */
+ private String buildObjectKey(EventContext context, String contentId) {
+ if (multitenancyEnabled && "shared".equals(objectStoreKind)) {
+ String tenant = getTenant(context);
+ validateTenantId(tenant);
+ validateContentId(contentId);
+ return tenant + "/" + contentId;
+ }
+ return contentId;
+ }
+
+ private String getTenant(EventContext context) {
+ String tenant = context.getUserInfo().getTenant();
+ if (tenant == null) {
+ throw new ServiceException("Tenant ID is required for multitenant attachment operations");
+ }
+ return tenant;
+ }
+
+ /**
+ * Validates that the tenant ID is safe for use in object key construction. Rejects null, empty,
+ * or values containing path separators ({@code /}, {@code \}, {@code ..}) to prevent path
+ * traversal attacks.
+ *
+ * @param tenantId the tenant ID to validate
+ * @throws ServiceException if the tenant ID is invalid
+ */
+ static void validateTenantId(String tenantId) {
+ if (tenantId == null
+ || tenantId.isEmpty()
+ || tenantId.contains("/")
+ || tenantId.contains("\\")
+ || tenantId.contains("..")) {
+ throw new ServiceException(
+ "Invalid tenant ID for attachment storage: must not be empty or contain path separators");
+ }
+ }
+
+ private static void validateContentId(String contentId) {
+ if (contentId == null
+ || contentId.isEmpty()
+ || contentId.contains("/")
+ || contentId.contains("\\")
+ || contentId.contains("..")) {
+ throw new ServiceException(
+ "Invalid content ID for attachment storage: must not be empty or contain path"
+ + " separators");
+ }
+ }
}
diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/TenantCleanupHandler.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/TenantCleanupHandler.java
new file mode 100644
index 000000000..96fb0a230
--- /dev/null
+++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/TenantCleanupHandler.java
@@ -0,0 +1,44 @@
+/*
+ * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
+ */
+package com.sap.cds.feature.attachments.oss.handler;
+
+import com.sap.cds.feature.attachments.oss.client.OSClient;
+import com.sap.cds.services.handler.EventHandler;
+import com.sap.cds.services.handler.annotations.After;
+import com.sap.cds.services.handler.annotations.ServiceName;
+import com.sap.cds.services.mt.DeploymentService;
+import com.sap.cds.services.mt.UnsubscribeEventContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Event handler that cleans up a tenant's attachment data from the shared object store when the
+ * tenant unsubscribes. Registered only in shared multitenancy mode.
+ */
+@ServiceName(DeploymentService.DEFAULT_NAME)
+public class TenantCleanupHandler implements EventHandler {
+
+ private static final Logger logger = LoggerFactory.getLogger(TenantCleanupHandler.class);
+ private final OSClient osClient;
+
+ public TenantCleanupHandler(OSClient osClient) {
+ this.osClient = osClient;
+ }
+
+ @After(event = DeploymentService.EVENT_UNSUBSCRIBE)
+ void cleanupTenantData(UnsubscribeEventContext context) {
+ String tenantId = context.getTenant();
+ OSSAttachmentsServiceHandler.validateTenantId(tenantId);
+ String prefix = tenantId + "/";
+ try {
+ osClient.deleteContentByPrefix(prefix).get();
+ logger.info("Cleaned up all objects for tenant {} from shared object store", tenantId);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ logger.error("Interrupted while cleaning up objects for tenant {}", tenantId, e);
+ } catch (Exception e) {
+ logger.error("Failed to clean up objects for tenant {}", tenantId, e);
+ }
+ }
+}
diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/AWSClientTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/AWSClientTest.java
index 032c1a8a2..df25d4fa2 100644
--- a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/AWSClientTest.java
+++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/AWSClientTest.java
@@ -8,16 +8,19 @@
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
-import com.sap.cds.feature.attachments.oss.handler.OSSAttachmentsServiceHandler;
-import com.sap.cds.feature.attachments.oss.handler.OSSAttachmentsServiceHandlerTestUtils;
import com.sap.cds.feature.attachments.oss.handler.ObjectStoreServiceException;
import com.sap.cloud.environment.servicebinding.api.ServiceBinding;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
+import java.util.Collections;
import java.util.HashMap;
+import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
@@ -30,29 +33,31 @@
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.DeleteObjectResponse;
+import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest;
+import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
+import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectResponse;
+import software.amazon.awssdk.services.s3.model.S3Object;
class AWSClientTest {
ExecutorService executor = Executors.newCachedThreadPool();
@Test
- void testConstructorWithAwsBindingUsesAwsClient()
- throws NoSuchFieldException, IllegalAccessException {
- OSSAttachmentsServiceHandler handler =
- new OSSAttachmentsServiceHandler(getDummyBinding(), executor);
- OSClient client = OSSAttachmentsServiceHandlerTestUtils.getOsClient(handler);
+ void testFactoryWithAwsBindingCreatesAwsClient() {
+ OSClient client = OSClientFactory.create(getDummyBinding(), executor);
assertInstanceOf(AWSClient.class, client);
}
@Test
void testReadContent() throws Exception {
- AWSClient awsClient = new AWSClient(getDummyBinding(), executor);
-
- // Mock S3Client to return a dummy InputStream
S3Client mockS3Client = mock(S3Client.class);
+ AWSClient awsClient =
+ new AWSClient(mockS3Client, mock(S3AsyncClient.class), "bucket", executor);
+
ByteArrayInputStream mockInputStream = new ByteArrayInputStream("test-data".getBytes());
GetObjectResponse mockResponse = mock(GetObjectResponse.class);
ResponseInputStream mockResponseInputStream =
@@ -60,20 +65,15 @@ void testReadContent() throws Exception {
when(mockS3Client.getObject(any(GetObjectRequest.class))).thenReturn(mockResponseInputStream);
- var field = AWSClient.class.getDeclaredField("s3Client");
- field.setAccessible(true);
- field.set(awsClient, mockS3Client);
-
InputStream result = awsClient.readContent("test.txt").get();
assertNotNull(result);
}
@Test
void testUploadContent() throws Exception {
- AWSClient awsClient = new AWSClient(getDummyBinding(), executor);
-
- // Mock S3AsyncClient to return a successful PutObjectResponse
S3AsyncClient mockAsyncClient = mock(S3AsyncClient.class);
+ AWSClient awsClient = new AWSClient(mock(S3Client.class), mockAsyncClient, "bucket", executor);
+
PutObjectResponse mockPutRes = mock(PutObjectResponse.class);
SdkHttpResponse mockHttpRes = mock(SdkHttpResponse.class);
when(mockHttpRes.isSuccessful()).thenReturn(true);
@@ -83,50 +83,35 @@ void testUploadContent() throws Exception {
when(mockAsyncClient.putObject(any(PutObjectRequest.class), any(AsyncRequestBody.class)))
.thenReturn(successFuture);
- var field = AWSClient.class.getDeclaredField("s3AsyncClient");
- field.setAccessible(true);
- field.set(awsClient, mockAsyncClient);
-
- // Should not throw
-
awsClient
.uploadContent(new ByteArrayInputStream("test".getBytes()), "test.txt", "text/plain")
.get();
}
@Test
- void testDeleteContent() throws NoSuchFieldException, IllegalAccessException {
- AWSClient awsClient = new AWSClient(getDummyBinding(), executor);
-
- // Mock S3Client to return a DeleteObjectResponse with successful SdkHttpResponse
+ void testDeleteContent() {
S3Client mockS3Client = mock(S3Client.class);
+ AWSClient awsClient =
+ new AWSClient(mockS3Client, mock(S3AsyncClient.class), "bucket", executor);
+
DeleteObjectResponse mockDelRes = mock(DeleteObjectResponse.class);
SdkHttpResponse mockHttpRes = mock(SdkHttpResponse.class);
when(mockHttpRes.isSuccessful()).thenReturn(true);
when(mockDelRes.sdkHttpResponse()).thenReturn(mockHttpRes);
when(mockS3Client.deleteObject(any(DeleteObjectRequest.class))).thenReturn(mockDelRes);
- var field = AWSClient.class.getDeclaredField("s3Client");
- field.setAccessible(true);
- field.set(awsClient, mockS3Client);
-
assertDoesNotThrow(() -> awsClient.deleteContent("test.txt").get());
}
@Test
void testReadContentThrows() throws Exception {
- AWSClient awsClient = new AWSClient(getDummyBinding(), executor);
-
- // Mock S3Client to return a dummy InputStream
S3Client mockS3Client = mock(S3Client.class);
+ AWSClient awsClient =
+ new AWSClient(mockS3Client, mock(S3AsyncClient.class), "bucket", executor);
when(mockS3Client.getObject(any(GetObjectRequest.class)))
.thenThrow(new RuntimeException("Simulated S3 failure"));
- var field = AWSClient.class.getDeclaredField("s3Client");
- field.setAccessible(true);
- field.set(awsClient, mockS3Client);
-
ExecutionException thrown =
assertThrows(ExecutionException.class, () -> awsClient.readContent("test.txt").get());
assertInstanceOf(ObjectStoreServiceException.class, thrown.getCause());
@@ -134,19 +119,14 @@ void testReadContentThrows() throws Exception {
@Test
void testUploadContentThrows() throws Exception {
- AWSClient awsClient = new AWSClient(getDummyBinding(), executor);
-
- // Mock S3AsyncClient that always fails
S3AsyncClient mockAsyncClient = mock(S3AsyncClient.class);
+ AWSClient awsClient = new AWSClient(mock(S3Client.class), mockAsyncClient, "bucket", executor);
+
CompletableFuture> failedFuture = new CompletableFuture<>();
failedFuture.completeExceptionally(new RuntimeException("Simulated S3 failure"));
when(mockAsyncClient.putObject(any(PutObjectRequest.class), any(AsyncRequestBody.class)))
.thenReturn((CompletableFuture) failedFuture);
- var field = AWSClient.class.getDeclaredField("s3AsyncClient");
- field.setAccessible(true);
- field.set(awsClient, mockAsyncClient);
-
ExecutionException thrown =
assertThrows(
ExecutionException.class,
@@ -161,18 +141,13 @@ void testUploadContentThrows() throws Exception {
@Test
void testUploadContentThrowsOnPutResponseNull() throws Exception {
- AWSClient awsClient = new AWSClient(getDummyBinding(), executor);
-
- // Mock S3AsyncClient that returns a null PutObjectResponse
S3AsyncClient mockAsyncClient = mock(S3AsyncClient.class);
+ AWSClient awsClient = new AWSClient(mock(S3Client.class), mockAsyncClient, "bucket", executor);
+
CompletableFuture nullFuture = CompletableFuture.completedFuture(null);
when(mockAsyncClient.putObject(any(PutObjectRequest.class), any(AsyncRequestBody.class)))
.thenReturn(nullFuture);
- var field = AWSClient.class.getDeclaredField("s3AsyncClient");
- field.setAccessible(true);
- field.set(awsClient, mockAsyncClient);
-
ExecutionException thrown =
assertThrows(
ExecutionException.class,
@@ -187,44 +162,131 @@ void testUploadContentThrowsOnPutResponseNull() throws Exception {
@Test
void testDeleteContentThrowsOnRuntimeException() throws Exception {
- AWSClient awsClient = new AWSClient(getDummyBinding(), executor);
-
- // Mock S3Client to throw a RuntimeException
S3Client mockS3Client = mock(S3Client.class);
+ AWSClient awsClient =
+ new AWSClient(mockS3Client, mock(S3AsyncClient.class), "bucket", executor);
+
when(mockS3Client.deleteObject(any(DeleteObjectRequest.class)))
.thenThrow(new RuntimeException("Simulated S3 delete failure"));
- var field = AWSClient.class.getDeclaredField("s3Client");
- field.setAccessible(true);
- field.set(awsClient, mockS3Client);
-
ExecutionException thrown =
assertThrows(ExecutionException.class, () -> awsClient.deleteContent("test.txt").get());
assertInstanceOf(ObjectStoreServiceException.class, thrown.getCause());
}
@Test
- void testDeleteContentThrowsOnUnsuccessfulResponse()
- throws NoSuchFieldException, IllegalAccessException {
- AWSClient awsClient = new AWSClient(getDummyBinding(), executor);
-
- // Mock S3Client to return a DeleteObjectResponse with unsuccessful SdkHttpResponse
+ void testDeleteContentThrowsOnUnsuccessfulResponse() {
S3Client mockS3Client = mock(S3Client.class);
+ AWSClient awsClient =
+ new AWSClient(mockS3Client, mock(S3AsyncClient.class), "bucket", executor);
+
DeleteObjectResponse mockDelRes = mock(DeleteObjectResponse.class);
SdkHttpResponse mockHttpRes = mock(SdkHttpResponse.class);
when(mockHttpRes.isSuccessful()).thenReturn(false);
when(mockDelRes.sdkHttpResponse()).thenReturn(mockHttpRes);
when(mockS3Client.deleteObject(any(DeleteObjectRequest.class))).thenReturn(mockDelRes);
- var field = AWSClient.class.getDeclaredField("s3Client");
- field.setAccessible(true);
- field.set(awsClient, mockS3Client);
-
ExecutionException thrown =
assertThrows(ExecutionException.class, () -> awsClient.deleteContent("test.txt").get());
assertInstanceOf(ObjectStoreServiceException.class, thrown.getCause());
}
+ @Test
+ void testDeleteContentByPrefix() throws Exception {
+ S3Client mockS3Client = mock(S3Client.class);
+ AWSClient awsClient =
+ new AWSClient(mockS3Client, mock(S3AsyncClient.class), "bucket", executor);
+
+ S3Object obj1 = S3Object.builder().key("prefix/file1.txt").build();
+ S3Object obj2 = S3Object.builder().key("prefix/file2.txt").build();
+
+ ListObjectsV2Response listResponse = mock(ListObjectsV2Response.class);
+ when(listResponse.contents()).thenReturn(List.of(obj1, obj2));
+ when(listResponse.isTruncated()).thenReturn(false);
+ when(mockS3Client.listObjectsV2(any(ListObjectsV2Request.class))).thenReturn(listResponse);
+
+ DeleteObjectsResponse deleteResponse = mock(DeleteObjectsResponse.class);
+ when(deleteResponse.hasErrors()).thenReturn(false);
+ when(deleteResponse.errors()).thenReturn(Collections.emptyList());
+ when(mockS3Client.deleteObjects(any(DeleteObjectsRequest.class))).thenReturn(deleteResponse);
+
+ awsClient.deleteContentByPrefix("prefix/").get();
+
+ verify(mockS3Client).deleteObjects(any(DeleteObjectsRequest.class));
+ }
+
+ @Test
+ void testDeleteContentByPrefixEmptyList() throws Exception {
+ S3Client mockS3Client = mock(S3Client.class);
+ AWSClient awsClient =
+ new AWSClient(mockS3Client, mock(S3AsyncClient.class), "bucket", executor);
+
+ ListObjectsV2Response listResponse = mock(ListObjectsV2Response.class);
+ when(listResponse.contents()).thenReturn(Collections.emptyList());
+ when(listResponse.isTruncated()).thenReturn(false);
+ when(mockS3Client.listObjectsV2(any(ListObjectsV2Request.class))).thenReturn(listResponse);
+
+ assertDoesNotThrow(() -> awsClient.deleteContentByPrefix("prefix/").get());
+ }
+
+ @Test
+ void testDeleteContentByPrefixThrowsOnRuntimeException() throws Exception {
+ S3Client mockS3Client = mock(S3Client.class);
+ AWSClient awsClient =
+ new AWSClient(mockS3Client, mock(S3AsyncClient.class), "bucket", executor);
+
+ when(mockS3Client.listObjectsV2(any(ListObjectsV2Request.class)))
+ .thenThrow(new RuntimeException("Simulated failure"));
+
+ ExecutionException thrown =
+ assertThrows(
+ ExecutionException.class, () -> awsClient.deleteContentByPrefix("prefix/").get());
+ assertInstanceOf(ObjectStoreServiceException.class, thrown.getCause());
+ }
+
+ @Test
+ void testDeleteContentByPrefixWithPagination() throws Exception {
+ S3Client mockS3Client = mock(S3Client.class);
+ AWSClient awsClient =
+ new AWSClient(mockS3Client, mock(S3AsyncClient.class), "bucket", executor);
+
+ // First page: 2 objects, isTruncated=true
+ S3Object obj1 = S3Object.builder().key("prefix/file1.txt").build();
+ S3Object obj2 = S3Object.builder().key("prefix/file2.txt").build();
+
+ ListObjectsV2Response firstPage = mock(ListObjectsV2Response.class);
+ when(firstPage.contents()).thenReturn(List.of(obj1, obj2));
+ when(firstPage.isTruncated()).thenReturn(true);
+ when(firstPage.nextContinuationToken()).thenReturn("token1");
+
+ // Second page: 1 object, isTruncated=false
+ S3Object obj3 = S3Object.builder().key("prefix/file3.txt").build();
+
+ ListObjectsV2Response secondPage = mock(ListObjectsV2Response.class);
+ when(secondPage.contents()).thenReturn(List.of(obj3));
+ when(secondPage.isTruncated()).thenReturn(false);
+
+ // First call returns first page, second call (with token) returns second page
+ when(mockS3Client.listObjectsV2(
+ argThat((ListObjectsV2Request req) -> req != null && req.continuationToken() == null)))
+ .thenReturn(firstPage);
+ when(mockS3Client.listObjectsV2(
+ argThat(
+ (ListObjectsV2Request req) ->
+ req != null && "token1".equals(req.continuationToken()))))
+ .thenReturn(secondPage);
+
+ DeleteObjectsResponse deleteResponse = mock(DeleteObjectsResponse.class);
+ when(deleteResponse.hasErrors()).thenReturn(false);
+ when(deleteResponse.errors()).thenReturn(Collections.emptyList());
+ when(mockS3Client.deleteObjects(any(DeleteObjectsRequest.class))).thenReturn(deleteResponse);
+
+ awsClient.deleteContentByPrefix("prefix/").get();
+
+ // deleteObjects should be called twice — once per page
+ verify(mockS3Client, times(2)).deleteObjects(any(DeleteObjectsRequest.class));
+ }
+
private ServiceBinding getDummyBinding() {
ServiceBinding binding = mock(ServiceBinding.class);
HashMap creds = new HashMap<>();
diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/AzureClientTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/AzureClientTest.java
index 9b7100468..f1c55570d 100644
--- a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/AzureClientTest.java
+++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/AzureClientTest.java
@@ -7,13 +7,17 @@
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
+import com.azure.core.http.rest.PagedIterable;
import com.azure.storage.blob.BlobClient;
import com.azure.storage.blob.BlobContainerClient;
+import com.azure.storage.blob.models.BlobItem;
+import com.azure.storage.blob.models.ListBlobsOptions;
import com.azure.storage.blob.specialized.BlobOutputStream;
import com.azure.storage.blob.specialized.BlockBlobClient;
import com.sap.cds.feature.attachments.oss.handler.ObjectStoreServiceException;
import java.io.IOException;
import java.io.InputStream;
+import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -23,25 +27,11 @@ class AzureClientTest {
ExecutorService executor = Executors.newCachedThreadPool();
@Test
- void testReadContent()
- throws NoSuchFieldException,
- SecurityException,
- IllegalArgumentException,
- IllegalAccessException,
- InterruptedException,
- ExecutionException {
- AzureClient azureClient = mock(AzureClient.class, CALLS_REAL_METHODS);
-
- // Mock BlobContainerClient and BlobClient
+ void testReadContent() throws InterruptedException, ExecutionException {
BlobContainerClient mockContainer = mock(BlobContainerClient.class);
BlobClient mockBlobClient = mock(BlobClient.class);
+ AzureClient azureClient = new AzureClient(mockContainer, executor);
- var field = AzureClient.class.getDeclaredField("blobContainerClient");
- field.setAccessible(true);
- field.set(azureClient, mockContainer);
- var executorField = AzureClient.class.getDeclaredField("executor");
- executorField.setAccessible(true);
- executorField.set(azureClient, executor);
when(mockContainer.getBlobClient(anyString())).thenReturn(mockBlobClient);
// Should not throw
@@ -49,26 +39,15 @@ void testReadContent()
}
@Test
- void testUploadContent()
- throws NoSuchFieldException,
- IllegalAccessException,
- InterruptedException,
- ExecutionException {
- AzureClient azureClient = mock(AzureClient.class, CALLS_REAL_METHODS);
-
- // Mock BlobContainerClient and BlockBlobClient
+ void testUploadContent() throws InterruptedException, ExecutionException {
BlobContainerClient mockContainer = mock(BlobContainerClient.class);
+ BlobClient mockBlobClient = mock(BlobClient.class);
BlockBlobClient mockBlockBlob = mock(BlockBlobClient.class);
BlobOutputStream mockOutputStream = mock(BlobOutputStream.class);
+ AzureClient azureClient = new AzureClient(mockContainer, executor);
- var field = AzureClient.class.getDeclaredField("blobContainerClient");
- field.setAccessible(true);
- field.set(azureClient, mockContainer);
- var executorField = AzureClient.class.getDeclaredField("executor");
- executorField.setAccessible(true);
- executorField.set(azureClient, executor);
- when(mockContainer.getBlobClient(anyString())).thenReturn(mock(BlobClient.class));
- when(mockContainer.getBlobClient(anyString()).getBlockBlobClient()).thenReturn(mockBlockBlob);
+ when(mockContainer.getBlobClient(anyString())).thenReturn(mockBlobClient);
+ when(mockBlobClient.getBlockBlobClient()).thenReturn(mockBlockBlob);
when(mockBlockBlob.getBlobOutputStream()).thenReturn(mockOutputStream);
InputStream mockInput = new java.io.ByteArrayInputStream("test-data".getBytes());
@@ -78,23 +57,15 @@ void testUploadContent()
}
@Test
- void testUploadContentThrowsOnIOException()
- throws NoSuchFieldException, IllegalAccessException, IOException {
- AzureClient azureClient = mock(AzureClient.class, CALLS_REAL_METHODS);
-
- // Mock BlobContainerClient and BlockBlobClient
+ void testUploadContentThrowsOnIOException() throws IOException {
BlobContainerClient mockContainer = mock(BlobContainerClient.class);
+ BlobClient mockBlobClient = mock(BlobClient.class);
BlockBlobClient mockBlockBlob = mock(BlockBlobClient.class);
BlobOutputStream mockOutputStream = mock(BlobOutputStream.class);
+ AzureClient azureClient = new AzureClient(mockContainer, executor);
- var field = AzureClient.class.getDeclaredField("blobContainerClient");
- field.setAccessible(true);
- field.set(azureClient, mockContainer);
- var executorField = AzureClient.class.getDeclaredField("executor");
- executorField.setAccessible(true);
- executorField.set(azureClient, executor);
- when(mockContainer.getBlobClient(anyString())).thenReturn(mock(BlobClient.class));
- when(mockContainer.getBlobClient(anyString()).getBlockBlobClient()).thenReturn(mockBlockBlob);
+ when(mockContainer.getBlobClient(anyString())).thenReturn(mockBlobClient);
+ when(mockBlobClient.getBlockBlobClient()).thenReturn(mockBlockBlob);
when(mockBlockBlob.getBlobOutputStream()).thenReturn(mockOutputStream);
// Mock InputStream to throw IOException
@@ -109,23 +80,11 @@ void testUploadContentThrowsOnIOException()
}
@Test
- void testDeleteContentThrowsOnRuntimeException()
- throws NoSuchFieldException,
- SecurityException,
- IllegalArgumentException,
- IllegalAccessException {
- AzureClient azureClient = mock(AzureClient.class, CALLS_REAL_METHODS);
-
- // Mock BlobContainerClient and BlobClient
+ void testDeleteContentThrowsOnRuntimeException() {
BlobContainerClient mockContainer = mock(BlobContainerClient.class);
BlobClient mockBlobClient = mock(BlobClient.class);
+ AzureClient azureClient = new AzureClient(mockContainer, executor);
- var field = AzureClient.class.getDeclaredField("blobContainerClient");
- field.setAccessible(true);
- field.set(azureClient, mockContainer);
- var executorField = AzureClient.class.getDeclaredField("executor");
- executorField.setAccessible(true);
- executorField.set(azureClient, executor);
when(mockContainer.getBlobClient(anyString())).thenReturn(mockBlobClient);
// Mock delete to throw RuntimeException
@@ -137,25 +96,11 @@ void testDeleteContentThrowsOnRuntimeException()
}
@Test
- void testDeleteContent()
- throws NoSuchFieldException,
- SecurityException,
- IllegalArgumentException,
- IllegalAccessException,
- InterruptedException,
- ExecutionException {
- AzureClient azureClient = mock(AzureClient.class, CALLS_REAL_METHODS);
-
- // Mock BlobContainerClient and BlobClient
+ void testDeleteContent() throws InterruptedException, ExecutionException {
BlobContainerClient mockContainer = mock(BlobContainerClient.class);
BlobClient mockBlobClient = mock(BlobClient.class);
+ AzureClient azureClient = new AzureClient(mockContainer, executor);
- var field = AzureClient.class.getDeclaredField("blobContainerClient");
- field.setAccessible(true);
- field.set(azureClient, mockContainer);
- var executorField = AzureClient.class.getDeclaredField("executor");
- executorField.setAccessible(true);
- executorField.set(azureClient, executor);
when(mockContainer.getBlobClient(anyString())).thenReturn(mockBlobClient);
// Should not throw
@@ -163,30 +108,54 @@ void testDeleteContent()
}
@Test
- void testReadContentThrowsOnRuntimeException()
- throws NoSuchFieldException,
- SecurityException,
- IllegalArgumentException,
- IllegalAccessException {
- AzureClient azureClient = mock(AzureClient.class, CALLS_REAL_METHODS);
-
- // Mock BlobContainerClient and BlobClient
+ void testReadContentThrowsOnRuntimeException() {
BlobContainerClient mockContainer = mock(BlobContainerClient.class);
BlobClient mockBlobClient = mock(BlobClient.class);
+ AzureClient azureClient = new AzureClient(mockContainer, executor);
- var field = AzureClient.class.getDeclaredField("blobContainerClient");
- field.setAccessible(true);
- field.set(azureClient, mockContainer);
- var executorField = AzureClient.class.getDeclaredField("executor");
- executorField.setAccessible(true);
- executorField.set(azureClient, executor);
when(mockContainer.getBlobClient(anyString())).thenReturn(mockBlobClient);
- // Mock delete to throw RuntimeException
+ // Mock openInputStream to throw RuntimeException
doThrow(new RuntimeException("Simulated read failure")).when(mockBlobClient).openInputStream();
ExecutionException thrown =
assertThrows(ExecutionException.class, () -> azureClient.readContent("file.txt").get());
assertInstanceOf(ObjectStoreServiceException.class, thrown.getCause());
}
+
+ @Test
+ void testDeleteContentByPrefix() throws InterruptedException, ExecutionException {
+ BlobContainerClient mockContainer = mock(BlobContainerClient.class);
+ BlobClient mockBlobClient = mock(BlobClient.class);
+ AzureClient azureClient = new AzureClient(mockContainer, executor);
+
+ BlobItem item1 = mock(BlobItem.class);
+ when(item1.getName()).thenReturn("prefix/file1.txt");
+ BlobItem item2 = mock(BlobItem.class);
+ when(item2.getName()).thenReturn("prefix/file2.txt");
+
+ @SuppressWarnings("unchecked")
+ PagedIterable pagedIterable = mock(PagedIterable.class);
+ when(pagedIterable.iterator()).thenReturn(List.of(item1, item2).iterator());
+ when(mockContainer.listBlobs(any(ListBlobsOptions.class), isNull())).thenReturn(pagedIterable);
+ when(mockContainer.getBlobClient(anyString())).thenReturn(mockBlobClient);
+
+ azureClient.deleteContentByPrefix("prefix/").get();
+
+ verify(mockBlobClient, times(2)).delete();
+ }
+
+ @Test
+ void testDeleteContentByPrefixThrowsOnRuntimeException() {
+ BlobContainerClient mockContainer = mock(BlobContainerClient.class);
+ AzureClient azureClient = new AzureClient(mockContainer, executor);
+
+ when(mockContainer.listBlobs(any(ListBlobsOptions.class), isNull()))
+ .thenThrow(new RuntimeException("Simulated failure"));
+
+ ExecutionException thrown =
+ assertThrows(
+ ExecutionException.class, () -> azureClient.deleteContentByPrefix("prefix/").get());
+ assertInstanceOf(ObjectStoreServiceException.class, thrown.getCause());
+ }
}
diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/GoogleClientTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/GoogleClientTest.java
index f4c2fd188..e959bef4e 100644
--- a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/GoogleClientTest.java
+++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/GoogleClientTest.java
@@ -54,18 +54,12 @@ void testConstructorThrowsOnInvalidCredentials() {
}
@Test
- void testDeleteContent()
- throws NoSuchFieldException,
- IllegalArgumentException,
- IllegalAccessException,
- InterruptedException,
- ExecutionException {
- GoogleClient googleClient = mock(GoogleClient.class, CALLS_REAL_METHODS);
+ void testDeleteContent() throws InterruptedException, ExecutionException {
+ Storage mockStorage = mock(Storage.class);
+ GoogleClient googleClient = new GoogleClient(mockStorage, "my-bucket", executor);
String fileName = "file.txt";
- // Mock storage and paging
- Storage mockStorage = mock(Storage.class);
Page mockPage = mock(Page.class);
Blob mockBlob = mock(Blob.class);
when(mockBlob.getName()).thenReturn(fileName);
@@ -75,48 +69,19 @@ void testDeleteContent()
when(mockStorage.list(anyString(), any(), any())).thenReturn(mockPage);
when(mockStorage.delete(any(BlobId.class))).thenReturn(true);
- // Inject mock storage and bucketName into googleClient using reflection
- var field = GoogleClient.class.getDeclaredField("storage");
- field.setAccessible(true);
- field.set(googleClient, mockStorage);
- var executorField = GoogleClient.class.getDeclaredField("executor");
- executorField.setAccessible(true);
- executorField.set(googleClient, executor);
- var bucketField = GoogleClient.class.getDeclaredField("bucketName");
- bucketField.setAccessible(true);
- bucketField.set(googleClient, "my-bucket");
-
// Should not throw
googleClient.deleteContent(fileName).get();
}
@Test
- void testUploadContent()
- throws NoSuchFieldException,
- IllegalArgumentException,
- IllegalAccessException,
- InterruptedException,
- ExecutionException,
- IOException {
- GoogleClient googleClient = mock(GoogleClient.class, CALLS_REAL_METHODS);
-
- // Mock storage and writer
+ void testUploadContent() throws InterruptedException, ExecutionException, IOException {
Storage mockStorage = mock(Storage.class);
- WriteChannel mockWriter = mock(WriteChannel.class);
+ GoogleClient googleClient = new GoogleClient(mockStorage, "my-bucket", executor);
- // Inject mock storage and bucketName into googleClient using reflection
- var field = GoogleClient.class.getDeclaredField("storage");
- field.setAccessible(true);
- field.set(googleClient, mockStorage);
- var executorField = GoogleClient.class.getDeclaredField("executor");
- executorField.setAccessible(true);
- executorField.set(googleClient, executor);
- var bucketField = GoogleClient.class.getDeclaredField("bucketName");
- bucketField.setAccessible(true);
- bucketField.set(googleClient, "my-bucket");
+ WriteChannel mockWriter = mock(WriteChannel.class);
when(mockStorage.writer(any(BlobInfo.class))).thenReturn(mockWriter);
- when(mockWriter.write(any(java.nio.ByteBuffer.class))).thenReturn(42); // return any int
+ when(mockWriter.write(any(java.nio.ByteBuffer.class))).thenReturn(42);
InputStream input = new java.io.ByteArrayInputStream("test".getBytes());
// Should not throw
@@ -124,44 +89,25 @@ void testUploadContent()
}
@Test
- void testReadContent()
- throws NoSuchFieldException,
- IllegalArgumentException,
- IllegalAccessException,
- InterruptedException,
- ExecutionException {
- GoogleClient googleClient = mock(GoogleClient.class, CALLS_REAL_METHODS);
-
- // Mock storage and read channel
+ void testReadContent() throws InterruptedException, ExecutionException {
Storage mockStorage = mock(Storage.class);
+ GoogleClient googleClient = new GoogleClient(mockStorage, "my-bucket", executor);
+
ReadChannel mockReadChannel = mock(ReadChannel.class);
when(mockStorage.reader(any(com.google.cloud.storage.BlobId.class)))
.thenReturn(mockReadChannel);
- // Inject mock storage and bucketName into googleClient using reflection
- var field = GoogleClient.class.getDeclaredField("storage");
- field.setAccessible(true);
- field.set(googleClient, mockStorage);
- var executorField = GoogleClient.class.getDeclaredField("executor");
- executorField.setAccessible(true);
- executorField.set(googleClient, executor);
- var bucketField = GoogleClient.class.getDeclaredField("bucketName");
- bucketField.setAccessible(true);
- bucketField.set(googleClient, "my-bucket");
-
// Should not throw
googleClient.readContent("file.txt").get();
}
@Test
- void testDeleteContentDoesNotWork()
- throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
- GoogleClient googleClient = mock(GoogleClient.class, CALLS_REAL_METHODS);
+ void testDeleteContentDoesNotWork() {
+ Storage mockStorage = mock(Storage.class);
+ GoogleClient googleClient = new GoogleClient(mockStorage, "my-bucket", executor);
String fileName = "file.txt";
- // Mock storage and paging
- Storage mockStorage = mock(Storage.class);
Page mockPage = mock(Page.class);
Blob mockBlob = mock(Blob.class);
when(mockBlob.getName()).thenReturn(fileName);
@@ -171,41 +117,17 @@ void testDeleteContentDoesNotWork()
when(mockStorage.list(anyString(), any(), any())).thenReturn(mockPage);
when(mockStorage.delete(any(BlobId.class))).thenReturn(false);
- // Inject mock storage and bucketName into googleClient using reflection
- var field = GoogleClient.class.getDeclaredField("storage");
- field.setAccessible(true);
- field.set(googleClient, mockStorage);
- var executorField = GoogleClient.class.getDeclaredField("executor");
- executorField.setAccessible(true);
- executorField.set(googleClient, executor);
- var bucketField = GoogleClient.class.getDeclaredField("bucketName");
- bucketField.setAccessible(true);
- bucketField.set(googleClient, "my-bucket");
-
ExecutionException thrown =
assertThrows(ExecutionException.class, () -> googleClient.deleteContent(fileName).get());
assertInstanceOf(ObjectStoreServiceException.class, thrown.getCause());
}
@Test
- void testUploadContentThrowsOnIOException()
- throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException, IOException {
- GoogleClient googleClient = mock(GoogleClient.class, CALLS_REAL_METHODS);
-
- // Mock storage and writer
+ void testUploadContentThrowsOnIOException() throws IOException {
Storage mockStorage = mock(Storage.class);
- WriteChannel mockWriter = mock(WriteChannel.class);
+ GoogleClient googleClient = new GoogleClient(mockStorage, "my-bucket", executor);
- // Inject mock storage and bucketName into googleClient using reflection
- var field = GoogleClient.class.getDeclaredField("storage");
- field.setAccessible(true);
- field.set(googleClient, mockStorage);
- var executorField = GoogleClient.class.getDeclaredField("executor");
- executorField.setAccessible(true);
- executorField.set(googleClient, executor);
- var bucketField = GoogleClient.class.getDeclaredField("bucketName");
- bucketField.setAccessible(true);
- bucketField.set(googleClient, "my-bucket");
+ WriteChannel mockWriter = mock(WriteChannel.class);
when(mockStorage.writer(any(BlobInfo.class))).thenReturn(mockWriter);
// Simulate IOException on write
@@ -223,21 +145,11 @@ void testUploadContentThrowsOnIOException()
}
@Test
- void testDeleteContentThrowsOnRuntimeException()
- throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
- GoogleClient googleClient = mock(GoogleClient.class, CALLS_REAL_METHODS);
-
- // Mock storage and blob to throw RuntimeException on delete
+ void testDeleteContentThrowsOnRuntimeException() {
Storage mockStorage = mock(Storage.class);
- Blob mockBlob = mock(Blob.class);
+ GoogleClient googleClient = new GoogleClient(mockStorage, "my-bucket", executor);
- // Inject mock storage into googleClient using reflection
- var field = GoogleClient.class.getDeclaredField("storage");
- field.setAccessible(true);
- field.set(googleClient, mockStorage);
- var executorField = GoogleClient.class.getDeclaredField("executor");
- executorField.setAccessible(true);
- executorField.set(googleClient, executor);
+ Blob mockBlob = mock(Blob.class);
when(mockStorage.get(any(String.class), any(String.class))).thenReturn(mockBlob);
doThrow(new RuntimeException("Simulated delete failure")).when(mockBlob).delete();
@@ -248,22 +160,10 @@ void testDeleteContentThrowsOnRuntimeException()
}
@Test
- void testReadContentThrowsOnRuntimeException()
- throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
- GoogleClient googleClient = mock(GoogleClient.class, CALLS_REAL_METHODS);
-
- // Mock storage and blob to throw RuntimeException on reader
+ void testReadContentThrowsOnRuntimeException() {
Storage mockStorage = mock(Storage.class);
+ GoogleClient googleClient = new GoogleClient(mockStorage, "my-bucket", executor);
- // Inject mock storage into googleClient using reflection
- var field = GoogleClient.class.getDeclaredField("storage");
- field.setAccessible(true);
- field.set(googleClient, mockStorage);
- var executorField = GoogleClient.class.getDeclaredField("executor");
- executorField.setAccessible(true);
- executorField.set(googleClient, executor);
-
- // Mock blob.reader() to throw RuntimeException
doThrow(new RuntimeException("Simulated read failure"))
.when(mockStorage)
.reader(any(com.google.cloud.storage.BlobId.class));
@@ -272,4 +172,41 @@ void testReadContentThrowsOnRuntimeException()
assertThrows(ExecutionException.class, () -> googleClient.readContent("file.txt").get());
assertInstanceOf(ObjectStoreServiceException.class, thrown.getCause());
}
+
+ @Test
+ void testDeleteContentByPrefix() throws InterruptedException, ExecutionException {
+ Storage mockStorage = mock(Storage.class);
+ GoogleClient googleClient = new GoogleClient(mockStorage, "my-bucket", executor);
+
+ Page mockPage = mock(Page.class);
+ Blob mockBlob1 = mock(Blob.class);
+ when(mockBlob1.getName()).thenReturn("prefix/file1.txt");
+ when(mockBlob1.getGeneration()).thenReturn(1L);
+ Blob mockBlob2 = mock(Blob.class);
+ when(mockBlob2.getName()).thenReturn("prefix/file2.txt");
+ when(mockBlob2.getGeneration()).thenReturn(2L);
+
+ Iterator blobIterator = java.util.List.of(mockBlob1, mockBlob2).iterator();
+ when(mockPage.iterateAll()).thenReturn(() -> blobIterator);
+ when(mockStorage.list(anyString(), any(), any())).thenReturn(mockPage);
+ when(mockStorage.delete(anyList())).thenReturn(java.util.List.of(true, true));
+
+ googleClient.deleteContentByPrefix("prefix/").get();
+
+ verify(mockStorage).delete(anyList());
+ }
+
+ @Test
+ void testDeleteContentByPrefixThrowsOnRuntimeException() {
+ Storage mockStorage = mock(Storage.class);
+ GoogleClient googleClient = new GoogleClient(mockStorage, "my-bucket", executor);
+
+ when(mockStorage.list(anyString(), any(), any()))
+ .thenThrow(new RuntimeException("Simulated failure"));
+
+ ExecutionException thrown =
+ assertThrows(
+ ExecutionException.class, () -> googleClient.deleteContentByPrefix("prefix/").get());
+ assertInstanceOf(ObjectStoreServiceException.class, thrown.getCause());
+ }
}
diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/OSClientTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/OSClientTest.java
new file mode 100644
index 000000000..48adf9f63
--- /dev/null
+++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/OSClientTest.java
@@ -0,0 +1,40 @@
+/*
+ * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
+ */
+package com.sap.cds.feature.attachments.oss.client;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.concurrent.ExecutionException;
+import org.junit.jupiter.api.Test;
+
+class OSClientTest {
+
+ @Test
+ void testDefaultDeleteContentByPrefixThrowsUnsupportedOperationException() {
+ OSClient client =
+ new OSClient() {
+ @Override
+ public java.util.concurrent.Future uploadContent(
+ java.io.InputStream content, String completeFileName, String contentType) {
+ return null;
+ }
+
+ @Override
+ public java.util.concurrent.Future deleteContent(String completeFileName) {
+ return null;
+ }
+
+ @Override
+ public java.util.concurrent.Future readContent(
+ String completeFileName) {
+ return null;
+ }
+ };
+
+ ExecutionException thrown =
+ assertThrows(ExecutionException.class, () -> client.deleteContentByPrefix("prefix/").get());
+ assertInstanceOf(UnsupportedOperationException.class, thrown.getCause());
+ }
+}
diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/configuration/RegistrationTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/configuration/RegistrationTest.java
index a364aa7d7..3987e1a72 100644
--- a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/configuration/RegistrationTest.java
+++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/configuration/RegistrationTest.java
@@ -4,7 +4,11 @@
package com.sap.cds.feature.attachments.oss.configuration;
import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.*;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
import com.sap.cds.feature.attachments.oss.handler.OSSAttachmentsServiceHandler;
import com.sap.cds.services.environment.CdsEnvironment;
@@ -15,37 +19,108 @@
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;
class RegistrationTest {
- @Test
- void testEventHandlersRegistersOSSHandler() {
- // Arrange
- Registration registration = new Registration();
- CdsRuntimeConfigurer configurer = mock(CdsRuntimeConfigurer.class);
- CdsRuntime cdsRuntime = mock(CdsRuntime.class);
- CdsEnvironment environment = mock(CdsEnvironment.class);
- ServiceBinding binding = mock(ServiceBinding.class);
+ private Registration registration;
+ private CdsRuntimeConfigurer configurer;
+ private CdsEnvironment environment;
+ private ServiceBinding awsBinding;
- // Setup valid AWS credentials for the binding
+ private static ServiceBinding createAwsBinding() {
+ ServiceBinding binding = mock(ServiceBinding.class);
Map credentials = new HashMap<>();
credentials.put("host", "aws.example.com");
credentials.put("region", "us-east-1");
credentials.put("access_key_id", "test-access-key");
credentials.put("secret_access_key", "test-secret-key");
credentials.put("bucket", "test-bucket");
+ when(binding.getServiceName()).thenReturn(Optional.of("objectstore"));
+ when(binding.getCredentials()).thenReturn(credentials);
+ return binding;
+ }
+ @BeforeEach
+ void setup() {
+ registration = new Registration();
+ configurer = mock(CdsRuntimeConfigurer.class);
+ CdsRuntime cdsRuntime = mock(CdsRuntime.class);
+ environment = mock(CdsEnvironment.class);
when(configurer.getCdsRuntime()).thenReturn(cdsRuntime);
when(cdsRuntime.getEnvironment()).thenReturn(environment);
- when(binding.getServiceName()).thenReturn(Optional.of("objectstore"));
- when(binding.getCredentials()).thenReturn(credentials);
- when(environment.getServiceBindings()).thenReturn(Stream.of(binding));
+ when(environment.getProperty("cds.attachments.objectStore.threadPoolSize", Integer.class, 16))
+ .thenReturn(16);
+ awsBinding = createAwsBinding();
+ }
+
+ @Test
+ void testEventHandlersRegistersOSSHandler() {
+ when(environment.getServiceBindings()).thenReturn(Stream.of(awsBinding));
- // Act
registration.eventHandlers(configurer);
- // Assert: OSSAttachmentsServiceHandler should be registered
verify(configurer).eventHandler(any(OSSAttachmentsServiceHandler.class));
}
+
+ @Test
+ void testEventHandlersRegistersCleanupHandlerWhenMultitenancyShared() {
+ when(environment.getServiceBindings()).thenReturn(Stream.of(awsBinding));
+ when(environment.getProperty("cds.multitenancy.enabled", Boolean.class, Boolean.FALSE))
+ .thenReturn(Boolean.TRUE);
+ when(environment.getProperty("cds.attachments.objectStore.kind", String.class, null))
+ .thenReturn("shared");
+
+ registration.eventHandlers(configurer);
+
+ verify(configurer, times(2)).eventHandler(any());
+ }
+
+ @Test
+ void testEventHandlersNoBindingDoesNotRegister() {
+ when(environment.getServiceBindings()).thenReturn(Stream.empty());
+
+ registration.eventHandlers(configurer);
+
+ verify(configurer, never()).eventHandler(any());
+ }
+
+ @Test
+ void testMtEnabledNonSharedKindRegistersOnlyOSSHandler() {
+ when(environment.getServiceBindings()).thenReturn(Stream.of(awsBinding));
+ when(environment.getProperty("cds.multitenancy.enabled", Boolean.class, Boolean.FALSE))
+ .thenReturn(Boolean.TRUE);
+ when(environment.getProperty("cds.attachments.objectStore.kind", String.class, null))
+ .thenReturn("dedicated");
+
+ registration.eventHandlers(configurer);
+
+ verify(configurer, times(1)).eventHandler(any(OSSAttachmentsServiceHandler.class));
+ verify(configurer, times(1)).eventHandler(any());
+ }
+
+ @Test
+ void testMtEnabledNullKindRegistersOnlyOSSHandler() {
+ when(environment.getServiceBindings()).thenReturn(Stream.of(awsBinding));
+ when(environment.getProperty("cds.multitenancy.enabled", Boolean.class, Boolean.FALSE))
+ .thenReturn(Boolean.TRUE);
+
+ registration.eventHandlers(configurer);
+
+ verify(configurer, times(1)).eventHandler(any(OSSAttachmentsServiceHandler.class));
+ verify(configurer, times(1)).eventHandler(any());
+ }
+
+ @Test
+ void testMtDisabledSharedKindRegistersOnlyOSSHandler() {
+ when(environment.getServiceBindings()).thenReturn(Stream.of(awsBinding));
+ when(environment.getProperty("cds.attachments.objectStore.kind", String.class, null))
+ .thenReturn("shared");
+
+ registration.eventHandlers(configurer);
+
+ verify(configurer, times(1)).eventHandler(any(OSSAttachmentsServiceHandler.class));
+ verify(configurer, times(1)).eventHandler(any());
+ }
}
diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandlerTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandlerTest.java
index 2d973221b..91bc98926 100644
--- a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandlerTest.java
+++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandlerTest.java
@@ -3,39 +3,52 @@
*/
package com.sap.cds.feature.attachments.oss.handler;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.CALLS_REAL_METHODS;
+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;
import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData;
+import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.StatusCode;
import com.sap.cds.feature.attachments.oss.client.OSClient;
+import com.sap.cds.feature.attachments.oss.client.OSClientFactory;
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.ServiceException;
+import com.sap.cds.services.request.ModifiableUserInfo;
+import com.sap.cds.services.request.UserInfo;
import com.sap.cloud.environment.servicebinding.api.ServiceBinding;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
+import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
class OSSAttachmentsServiceHandlerTest {
- ExecutorService executor = Executors.newCachedThreadPool();
- @Test
- void testRestoreAttachmentCallsSetCompleted() {
- // Setup a valid AWS binding for the test
+ private static final ExecutorService executor = Executors.newCachedThreadPool();
+
+ private OSClient mockOsClient;
+ private OSSAttachmentsServiceHandler handler;
+
+ private static ServiceBinding createAwsBinding() {
ServiceBinding binding = mock(ServiceBinding.class);
HashMap creds = new HashMap<>();
creds.put("host", "aws.example.com");
@@ -44,337 +57,428 @@ void testRestoreAttachmentCallsSetCompleted() {
creds.put("secret_access_key", "test-secret-key");
creds.put("bucket", "test-bucket");
when(binding.getCredentials()).thenReturn(creds);
-
- OSSAttachmentsServiceHandler handler = new OSSAttachmentsServiceHandler(binding, executor);
- AttachmentRestoreEventContext context = mock(AttachmentRestoreEventContext.class);
- handler.restoreAttachment(context);
- verify(context).setCompleted();
+ return binding;
}
- @Test
- void testCreateAttachmentCallsOsClientUploadContent()
- throws NoSuchFieldException, IllegalAccessException {
- OSClient mockOsClient = mock(OSClient.class);
- // Mock the handler, but call the real method readAttachment
- OSSAttachmentsServiceHandler handler =
- mock(OSSAttachmentsServiceHandler.class, CALLS_REAL_METHODS);
- AttachmentCreateEventContext context = mock(AttachmentCreateEventContext.class);
-
- var field = OSSAttachmentsServiceHandler.class.getDeclaredField("osClient");
- field.setAccessible(true);
- field.set(handler, mockOsClient);
-
- String contentId = "doc123";
- String mimeType = "text/plain";
- String fileName = "file.txt";
-
- MediaData mockMediaData = mock(MediaData.class);
- var mockEntity = mock(com.sap.cds.reflect.CdsEntity.class);
- when(mockEntity.getQualifiedName()).thenReturn(fileName);
-
- InputStream contentStream = new ByteArrayInputStream("test".getBytes());
-
- when(context.getAttachmentEntity()).thenReturn(mockEntity);
- when(context.getAttachmentIds()).thenReturn(java.util.Map.of("ID", contentId));
- when(context.getData()).thenReturn(mockMediaData);
- when(mockMediaData.getContent()).thenReturn(contentStream);
- when(mockMediaData.getMimeType()).thenReturn(mimeType);
- when(mockOsClient.uploadContent(any(), anyString(), anyString()))
- .thenReturn(CompletableFuture.completedFuture(null));
-
- when(context.getContentId()).thenReturn(contentId);
-
- handler.createAttachment(context);
-
- verify(mockOsClient).uploadContent(contentStream, contentId, mimeType);
- verify(context).setIsInternalStored(false);
- verify(context).setContentId(contentId);
- verify(context).setCompleted();
+ private static CdsEntity stubEntity(String name) {
+ CdsEntity entity = mock(CdsEntity.class);
+ when(entity.getQualifiedName()).thenReturn(name);
+ return entity;
}
- @Test
- void testReadAttachmentCallsOsClientReadContent()
- throws NoSuchFieldException, IllegalAccessException {
- OSClient mockOsClient = mock(OSClient.class);
- // Mock the handler, but call the real method readAttachment
- OSSAttachmentsServiceHandler handler =
- mock(OSSAttachmentsServiceHandler.class, CALLS_REAL_METHODS);
- AttachmentReadEventContext context = mock(AttachmentReadEventContext.class);
-
- var field = OSSAttachmentsServiceHandler.class.getDeclaredField("osClient");
- field.setAccessible(true);
- field.set(handler, mockOsClient);
-
- String contentId = "doc123";
- MediaData mockMediaData = mock(MediaData.class);
-
- when(context.getContentId()).thenReturn(contentId);
- when(context.getData()).thenReturn(mockMediaData);
- when(mockOsClient.readContent(contentId))
- .thenReturn(CompletableFuture.completedFuture(new ByteArrayInputStream("test".getBytes())));
-
- handler.readAttachment(context);
-
- verify(mockOsClient).readContent(contentId);
- verify(mockMediaData).setContent(any(InputStream.class));
- verify(context).setCompleted();
+ /**
+ * Creates a real {@link AttachmentCreateEventContext} populated with the given values. The only
+ * mock used is CdsEntity (a model-level concept not creatable without a full model).
+ */
+ private static AttachmentCreateEventContext createContext(
+ String contentId, String mimeType, String fileName, byte[] content) {
+ var ctx = AttachmentCreateEventContext.create();
+ ctx.setData(MediaData.create());
+ ctx.getData().setContent(new ByteArrayInputStream(content));
+ ctx.getData().setMimeType(mimeType);
+ ctx.getData().setFileName(fileName);
+ ctx.setAttachmentIds(Map.of(Attachments.ID, contentId));
+ ctx.setAttachmentEntity(stubEntity("TestEntity"));
+ return ctx;
}
- @Test
- void testReadAttachmentCallsOsClientReadNullContent()
- throws NoSuchFieldException, IllegalAccessException {
- OSClient mockOsClient = mock(OSClient.class);
- // Mock the handler, but call the real method readAttachment
- OSSAttachmentsServiceHandler handler =
- mock(OSSAttachmentsServiceHandler.class, CALLS_REAL_METHODS);
- AttachmentReadEventContext context = mock(AttachmentReadEventContext.class);
-
- var field = OSSAttachmentsServiceHandler.class.getDeclaredField("osClient");
- field.setAccessible(true);
- field.set(handler, mockOsClient);
-
- String contentId = "doc123";
- MediaData mockMediaData = mock(MediaData.class);
-
- when(context.getContentId()).thenReturn(contentId);
- when(context.getData()).thenReturn(mockMediaData);
- when(mockOsClient.readContent(contentId)).thenReturn(CompletableFuture.completedFuture(null));
-
- assertThrows(ServiceException.class, () -> handler.readAttachment(context));
-
- verify(mockOsClient).readContent(contentId);
- verify(context).setCompleted();
+ private static UserInfo userInfoWithTenant(String tenant) {
+ ModifiableUserInfo userInfo = UserInfo.create();
+ userInfo.setTenant(tenant);
+ return userInfo;
}
- @Test
- void testMarkAttachmentAsDeletedCallsOsClientDeleteContent()
- throws NoSuchFieldException, IllegalAccessException {
- OSClient mockOsClient = mock(OSClient.class);
- // Mock the handler, but call the real method readAttachment
- OSSAttachmentsServiceHandler handler =
- mock(OSSAttachmentsServiceHandler.class, CALLS_REAL_METHODS);
- AttachmentMarkAsDeletedEventContext context = mock(AttachmentMarkAsDeletedEventContext.class);
-
- var field = OSSAttachmentsServiceHandler.class.getDeclaredField("osClient");
- field.setAccessible(true);
- field.set(handler, mockOsClient);
+ @Nested
+ class FactoryTests {
+
+ @Test
+ void testFactoryHandlesInvalidBase64EncodedPrivateKeyData() {
+ ServiceBinding binding = mock(ServiceBinding.class);
+ HashMap creds = new HashMap<>();
+ creds.put("base64EncodedPrivateKeyData", "not-a-valid-base64-string");
+ when(binding.getCredentials()).thenReturn(creds);
+
+ assertThrows(
+ ObjectStoreServiceException.class, () -> OSClientFactory.create(binding, executor));
+ }
+
+ @Test
+ void testFactoryHandlesValidBase64ButNoGoogleOrGcp() {
+ String plain = "this is just a dummy string without keywords";
+ String base64 = Base64.getEncoder().encodeToString(plain.getBytes(StandardCharsets.UTF_8));
+
+ ServiceBinding binding = mock(ServiceBinding.class);
+ HashMap creds = new HashMap<>();
+ creds.put("base64EncodedPrivateKeyData", base64);
+ when(binding.getCredentials()).thenReturn(creds);
+
+ assertThrows(
+ ObjectStoreServiceException.class, () -> OSClientFactory.create(binding, executor));
+ }
+
+ @Test
+ void testFactoryHandlesInValidBase64() {
+ ServiceBinding binding = mock(ServiceBinding.class);
+ HashMap creds = new HashMap<>();
+ creds.put("base64EncodedPrivateKeyData", "this is just a dummy string without keywords");
+ when(binding.getCredentials()).thenReturn(creds);
+
+ assertThrows(
+ ObjectStoreServiceException.class, () -> OSClientFactory.create(binding, executor));
+ }
+
+ @Test
+ void testFactoryHandlesNoValidObjectStoreService() {
+ ServiceBinding binding = mock(ServiceBinding.class);
+ HashMap creds = new HashMap<>();
+ creds.put("someOtherField", "someValue");
+ when(binding.getCredentials()).thenReturn(creds);
+
+ assertThrows(
+ ObjectStoreServiceException.class, () -> OSClientFactory.create(binding, executor));
+ }
+ }
- String contentId = "doc123";
- when(context.getContentId()).thenReturn(contentId);
- when(mockOsClient.deleteContent(contentId)).thenReturn(CompletableFuture.completedFuture(null));
+ @Nested
+ class SingleTenantOperations {
- handler.markAttachmentAsDeleted(context);
+ @BeforeEach
+ void setup() {
+ mockOsClient = mock(OSClient.class);
+ handler = new OSSAttachmentsServiceHandler(mockOsClient, false, null);
+ }
- verify(mockOsClient).deleteContent(contentId);
- verify(context).setCompleted();
- }
+ @Test
+ void testRestoreAttachmentCallsSetCompleted() {
+ var context = AttachmentRestoreEventContext.create();
+ context.setRestoreTimestamp(Instant.now());
- @Test
- void testConstructorHandlesInvalidBase64EncodedPrivateKeyData() {
- // Arrange: ServiceBinding with invalid base64EncodedPrivateKeyData (not valid base64)
- ServiceBinding binding = mock(ServiceBinding.class);
- HashMap creds = new HashMap<>();
- creds.put("base64EncodedPrivateKeyData", "not-a-valid-base64-string");
- when(binding.getCredentials()).thenReturn(creds);
+ handler.restoreAttachment(context);
- assertThrows(
- ObjectStoreServiceException.class,
- () -> new OSSAttachmentsServiceHandler(binding, executor));
- }
+ assertThat(context.isCompleted()).isTrue();
+ }
- @Test
- void testConstructorHandlesValidBase64ButNoGoogleOrGcp() {
- String plain = "this is just a dummy string without keywords";
- String base64 =
- Base64.getEncoder().encodeToString(plain.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ @Test
+ void testCreateAttachmentUploadsContent() {
+ when(mockOsClient.uploadContent(any(), anyString(), anyString()))
+ .thenReturn(CompletableFuture.completedFuture(null));
- ServiceBinding binding = mock(ServiceBinding.class);
- HashMap creds = new HashMap<>();
- creds.put("base64EncodedPrivateKeyData", base64);
- when(binding.getCredentials()).thenReturn(creds);
+ var context = createContext("doc123", "text/plain", "file.txt", "test".getBytes());
- assertThrows(
- ObjectStoreServiceException.class,
- () -> new OSSAttachmentsServiceHandler(binding, executor));
- }
+ handler.createAttachment(context);
- @Test
- void testConstructorHandlesInValidBase64() {
- ServiceBinding binding = mock(ServiceBinding.class);
- HashMap creds = new HashMap<>();
- creds.put("base64EncodedPrivateKeyData", "this is just a dummy string without keywords");
- when(binding.getCredentials()).thenReturn(creds);
+ verify(mockOsClient).uploadContent(any(InputStream.class), eq("doc123"), eq("text/plain"));
+ assertThat(context.getIsInternalStored()).isFalse();
+ assertThat(context.getContentId()).isEqualTo("doc123");
+ assertThat(context.getData().getStatus()).isEqualTo(StatusCode.SCANNING);
+ assertThat(context.isCompleted()).isTrue();
+ }
- assertThrows(
- ObjectStoreServiceException.class,
- () -> new OSSAttachmentsServiceHandler(binding, executor));
- }
+ @Test
+ void testReadAttachmentReadsContent() {
+ when(mockOsClient.readContent("doc123"))
+ .thenReturn(
+ CompletableFuture.completedFuture(new ByteArrayInputStream("test".getBytes())));
- @Test
- void testConstructorHandlesNoValidObjectStoreService() {
- // Arrange: ServiceBinding with no valid object store credentials
- ServiceBinding binding = mock(ServiceBinding.class);
- HashMap creds = new HashMap<>();
- // No host, container_uri, or base64EncodedPrivateKeyData
- creds.put("someOtherField", "someValue");
- when(binding.getCredentials()).thenReturn(creds);
+ var context = AttachmentReadEventContext.create();
+ context.setContentId("doc123");
+ context.setData(MediaData.create());
- assertThrows(
- ObjectStoreServiceException.class,
- () -> new OSSAttachmentsServiceHandler(binding, executor));
- }
+ handler.readAttachment(context);
- // Helper method to setup common mocks for createAttachment exception tests
- private AttachmentCreateEventContext setupCreateAttachmentContext(
- OSClient mockOsClient, OSSAttachmentsServiceHandler handler, Exception exceptionToThrow)
- throws NoSuchFieldException,
- IllegalAccessException,
- InterruptedException,
- ExecutionException {
-
- var field = OSSAttachmentsServiceHandler.class.getDeclaredField("osClient");
- field.setAccessible(true);
- field.set(handler, mockOsClient);
-
- AttachmentCreateEventContext context = mock(AttachmentCreateEventContext.class);
- MediaData mockMediaData = mock(MediaData.class);
- CdsEntity mockEntity = mock(CdsEntity.class);
- HashMap attachmentIds = new HashMap<>();
- attachmentIds.put("ID", "test-id");
-
- when(context.getAttachmentIds()).thenReturn(attachmentIds);
- when(context.getData()).thenReturn(mockMediaData);
- when(context.getAttachmentEntity()).thenReturn(mockEntity);
- when(mockEntity.getQualifiedName()).thenReturn("TestEntity");
- when(mockMediaData.getFileName()).thenReturn("test.txt");
- when(mockMediaData.getContent()).thenReturn(new ByteArrayInputStream("test".getBytes()));
- when(mockMediaData.getMimeType()).thenReturn("text/plain");
-
- @SuppressWarnings("unchecked")
- CompletableFuture future = mock(CompletableFuture.class);
- when(mockOsClient.uploadContent(any(InputStream.class), anyString(), anyString()))
- .thenReturn(future);
- when(future.get()).thenThrow(exceptionToThrow);
-
- return context;
- }
+ verify(mockOsClient).readContent("doc123");
+ assertThat(context.getData().getContent()).isNotNull();
+ assertThat(context.isCompleted()).isTrue();
+ }
- @Test
- void testCreateAttachmentExceptionHandling()
- throws NoSuchFieldException,
- IllegalAccessException,
- InterruptedException,
- ExecutionException {
- OSClient mockOsClient = mock(OSClient.class);
- OSSAttachmentsServiceHandler handler =
- mock(OSSAttachmentsServiceHandler.class, CALLS_REAL_METHODS);
-
- // Test InterruptedException
- AttachmentCreateEventContext context1 =
- setupCreateAttachmentContext(
- mockOsClient, handler, new InterruptedException("Thread interrupted"));
- assertThrows(ServiceException.class, () -> handler.createAttachment(context1));
- verify(context1).setCompleted();
-
- // Test ObjectStoreServiceException
- AttachmentCreateEventContext context2 =
- setupCreateAttachmentContext(
- mockOsClient, handler, new ObjectStoreServiceException("Upload failed"));
- assertThrows(ServiceException.class, () -> handler.createAttachment(context2));
- verify(context2).setCompleted();
-
- // Test ExecutionException
- AttachmentCreateEventContext context3 =
- setupCreateAttachmentContext(
- mockOsClient, handler, new ExecutionException("Upload failed", new RuntimeException()));
- assertThrows(ServiceException.class, () -> handler.createAttachment(context3));
- verify(context3).setCompleted();
- }
+ @Test
+ void testReadAttachmentWithNullContentThrows() {
+ when(mockOsClient.readContent("doc123")).thenReturn(CompletableFuture.completedFuture(null));
- // Helper method to setup common mocks for markAttachmentAsDeleted exception tests
- private AttachmentMarkAsDeletedEventContext setupMarkAsDeletedContext(
- OSClient mockOsClient, OSSAttachmentsServiceHandler handler, Exception exceptionToThrow)
- throws NoSuchFieldException,
- IllegalAccessException,
- InterruptedException,
- ExecutionException {
+ var context = AttachmentReadEventContext.create();
+ context.setContentId("doc123");
+ context.setData(MediaData.create());
- var field = OSSAttachmentsServiceHandler.class.getDeclaredField("osClient");
- field.setAccessible(true);
- field.set(handler, mockOsClient);
+ assertThrows(ServiceException.class, () -> handler.readAttachment(context));
+ assertThat(context.isCompleted()).isTrue();
+ }
- AttachmentMarkAsDeletedEventContext context = mock(AttachmentMarkAsDeletedEventContext.class);
- String contentId = "test-content-id";
+ @Test
+ void testMarkAttachmentAsDeletedDeletesContent() {
+ when(mockOsClient.deleteContent("doc123"))
+ .thenReturn(CompletableFuture.completedFuture(null));
- when(context.getContentId()).thenReturn(contentId);
+ var context = AttachmentMarkAsDeletedEventContext.create();
+ context.setContentId("doc123");
- @SuppressWarnings("unchecked")
- CompletableFuture future = mock(CompletableFuture.class);
- when(mockOsClient.deleteContent(contentId)).thenReturn(future);
- when(future.get()).thenThrow(exceptionToThrow);
+ handler.markAttachmentAsDeleted(context);
- return context;
+ verify(mockOsClient).deleteContent("doc123");
+ assertThat(context.isCompleted()).isTrue();
+ }
}
- @Test
- void testMarkAttachmentAsDeletedExceptionHandling()
- throws NoSuchFieldException,
- IllegalAccessException,
- InterruptedException,
- ExecutionException {
- OSClient mockOsClient = mock(OSClient.class);
- OSSAttachmentsServiceHandler handler =
- mock(OSSAttachmentsServiceHandler.class, CALLS_REAL_METHODS);
-
- // Test InterruptedException
- AttachmentMarkAsDeletedEventContext context1 =
- setupMarkAsDeletedContext(
- mockOsClient, handler, new InterruptedException("Thread interrupted"));
- assertThrows(ServiceException.class, () -> handler.markAttachmentAsDeleted(context1));
- verify(context1).setCompleted();
-
- // Test ObjectStoreServiceException
- AttachmentMarkAsDeletedEventContext context2 =
- setupMarkAsDeletedContext(
- mockOsClient, handler, new ObjectStoreServiceException("Delete failed"));
- assertThrows(ServiceException.class, () -> handler.markAttachmentAsDeleted(context2));
- verify(context2).setCompleted();
-
- // Test ExecutionException
- AttachmentMarkAsDeletedEventContext context3 =
- setupMarkAsDeletedContext(
- mockOsClient, handler, new ExecutionException("Delete failed", new RuntimeException()));
- assertThrows(ServiceException.class, () -> handler.markAttachmentAsDeleted(context3));
- verify(context3).setCompleted();
+ @Nested
+ class ExceptionHandling {
+
+ @BeforeEach
+ void setup() {
+ mockOsClient = mock(OSClient.class);
+ handler = new OSSAttachmentsServiceHandler(mockOsClient, false, null);
+ }
+
+ @Test
+ void testCreateAttachmentHandlesInterruptedException() throws Exception {
+ var context = createContextForUploadException(new InterruptedException("Thread interrupted"));
+ assertThrows(ServiceException.class, () -> handler.createAttachment(context));
+ assertThat(context.isCompleted()).isTrue();
+ }
+
+ @Test
+ void testCreateAttachmentHandlesObjectStoreServiceException() throws Exception {
+ var context =
+ createContextForUploadException(new ObjectStoreServiceException("Upload failed"));
+ assertThrows(ServiceException.class, () -> handler.createAttachment(context));
+ assertThat(context.isCompleted()).isTrue();
+ }
+
+ @Test
+ void testCreateAttachmentHandlesExecutionException() throws Exception {
+ var context =
+ createContextForUploadException(
+ new ExecutionException("Upload failed", new RuntimeException()));
+ assertThrows(ServiceException.class, () -> handler.createAttachment(context));
+ assertThat(context.isCompleted()).isTrue();
+ }
+
+ @Test
+ void testMarkAsDeletedHandlesInterruptedException() throws Exception {
+ var context = createContextForDeleteException(new InterruptedException("Thread interrupted"));
+ assertThrows(ServiceException.class, () -> handler.markAttachmentAsDeleted(context));
+ assertThat(context.isCompleted()).isTrue();
+ }
+
+ @Test
+ void testMarkAsDeletedHandlesObjectStoreServiceException() throws Exception {
+ var context =
+ createContextForDeleteException(new ObjectStoreServiceException("Delete failed"));
+ assertThrows(ServiceException.class, () -> handler.markAttachmentAsDeleted(context));
+ assertThat(context.isCompleted()).isTrue();
+ }
+
+ @Test
+ void testMarkAsDeletedHandlesExecutionException() throws Exception {
+ var context =
+ createContextForDeleteException(
+ new ExecutionException("Delete failed", new RuntimeException()));
+ assertThrows(ServiceException.class, () -> handler.markAttachmentAsDeleted(context));
+ assertThat(context.isCompleted()).isTrue();
+ }
+
+ @Test
+ void testReadAttachmentHandlesInterruptedException() throws Exception {
+ var context = createContextForReadException(new InterruptedException("Thread interrupted"));
+ assertThrows(ServiceException.class, () -> handler.readAttachment(context));
+ assertThat(context.isCompleted()).isTrue();
+ }
+
+ @Test
+ void testReadAttachmentHandlesExecutionException() throws Exception {
+ var context =
+ createContextForReadException(new ExecutionException("failed", new RuntimeException()));
+ assertThrows(ServiceException.class, () -> handler.readAttachment(context));
+ assertThat(context.isCompleted()).isTrue();
+ }
+
+ private AttachmentCreateEventContext createContextForUploadException(Exception exception)
+ throws Exception {
+ @SuppressWarnings("unchecked")
+ CompletableFuture future = mock(CompletableFuture.class);
+ when(mockOsClient.uploadContent(any(InputStream.class), anyString(), anyString()))
+ .thenReturn(future);
+ when(future.get()).thenThrow(exception);
+
+ return createContext("test-id", "text/plain", "test.txt", "test".getBytes());
+ }
+
+ private AttachmentMarkAsDeletedEventContext createContextForDeleteException(Exception exception)
+ throws Exception {
+ @SuppressWarnings("unchecked")
+ CompletableFuture future = mock(CompletableFuture.class);
+ when(mockOsClient.deleteContent("test-content-id")).thenReturn(future);
+ when(future.get()).thenThrow(exception);
+
+ var context = AttachmentMarkAsDeletedEventContext.create();
+ context.setContentId("test-content-id");
+ return context;
+ }
+
+ private AttachmentReadEventContext createContextForReadException(Exception exception)
+ throws Exception {
+ @SuppressWarnings("unchecked")
+ CompletableFuture future = mock(CompletableFuture.class);
+ when(mockOsClient.readContent("doc123")).thenReturn(future);
+ when(future.get()).thenThrow(exception);
+
+ var context = AttachmentReadEventContext.create();
+ context.setContentId("doc123");
+ context.setData(MediaData.create());
+ return context;
+ }
}
- @Test
- void testReadAttachmentHandlesInterruptedException()
- throws NoSuchFieldException,
- IllegalAccessException,
- InterruptedException,
- ExecutionException {
- OSClient mockOsClient = mock(OSClient.class);
- OSSAttachmentsServiceHandler handler =
- mock(OSSAttachmentsServiceHandler.class, CALLS_REAL_METHODS);
- AttachmentReadEventContext context = mock(AttachmentReadEventContext.class);
-
- var field = OSSAttachmentsServiceHandler.class.getDeclaredField("osClient");
- field.setAccessible(true);
- field.set(handler, mockOsClient);
-
- String contentId = "doc123";
- MediaData mockMediaData = mock(MediaData.class);
-
- when(context.getContentId()).thenReturn(contentId);
- when(context.getData()).thenReturn(mockMediaData);
-
- @SuppressWarnings("unchecked")
- CompletableFuture future = mock(CompletableFuture.class);
- when(mockOsClient.readContent(contentId)).thenReturn(future);
- when(future.get()).thenThrow(new InterruptedException("Thread interrupted"));
-
- assertThrows(ServiceException.class, () -> handler.readAttachment(context));
- verify(context).setCompleted();
+ @Nested
+ class MultitenancyTests {
+
+ @BeforeEach
+ void setup() {
+ mockOsClient = mock(OSClient.class);
+ handler = new OSSAttachmentsServiceHandler(mockOsClient, true, "shared");
+ }
+
+ @Test
+ void testCreateAttachmentWithMultitenancyBuildsObjectKey() {
+ when(mockOsClient.uploadContent(any(), anyString(), anyString()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ // For multitenancy, getUserInfo() requires a RequestContext, so we mock
+ // the event context to provide tenant info
+ CdsEntity entity = stubEntity("TestEntity");
+ UserInfo userInfo = userInfoWithTenant("myTenant");
+ MediaData data = MediaData.create();
+ data.setContent(new ByteArrayInputStream("test".getBytes()));
+ data.setMimeType("text/plain");
+ data.setFileName("file.txt");
+
+ AttachmentCreateEventContext context = mock(AttachmentCreateEventContext.class);
+ when(context.getAttachmentEntity()).thenReturn(entity);
+ when(context.getAttachmentIds()).thenReturn(Map.of(Attachments.ID, "content123"));
+ when(context.getData()).thenReturn(data);
+ when(context.getUserInfo()).thenReturn(userInfo);
+
+ handler.createAttachment(context);
+
+ verify(mockOsClient).uploadContent(any(), eq("myTenant/content123"), anyString());
+ }
+
+ @Test
+ void testReadAttachmentWithMultitenancyBuildsObjectKey() {
+ when(mockOsClient.readContent("myTenant/content123"))
+ .thenReturn(
+ CompletableFuture.completedFuture(new ByteArrayInputStream("test".getBytes())));
+
+ AttachmentReadEventContext context = mock(AttachmentReadEventContext.class);
+ when(context.getContentId()).thenReturn("content123");
+ when(context.getData()).thenReturn(MediaData.create());
+ when(context.getUserInfo()).thenReturn(userInfoWithTenant("myTenant"));
+
+ handler.readAttachment(context);
+
+ verify(mockOsClient).readContent("myTenant/content123");
+ }
+
+ @Test
+ void testMarkAsDeletedWithMultitenancyBuildsObjectKey() {
+ when(mockOsClient.deleteContent("myTenant/content123"))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ AttachmentMarkAsDeletedEventContext context = mock(AttachmentMarkAsDeletedEventContext.class);
+ when(context.getContentId()).thenReturn("content123");
+ when(context.getUserInfo()).thenReturn(userInfoWithTenant("myTenant"));
+
+ handler.markAttachmentAsDeleted(context);
+
+ verify(mockOsClient).deleteContent("myTenant/content123");
+ }
+
+ @Test
+ void testMultitenancyWithNullTenantThrows() {
+ AttachmentReadEventContext context = mock(AttachmentReadEventContext.class);
+ when(context.getContentId()).thenReturn("content123");
+ when(context.getUserInfo()).thenReturn(userInfoWithTenant(null));
+
+ assertThrows(ServiceException.class, () -> handler.readAttachment(context));
+ }
+
+ @Test
+ void testValidateTenantIdWithSlashThrows() {
+ AttachmentReadEventContext context = mock(AttachmentReadEventContext.class);
+ when(context.getContentId()).thenReturn("content123");
+ when(context.getUserInfo()).thenReturn(userInfoWithTenant("tenant/evil"));
+
+ assertThrows(ServiceException.class, () -> handler.readAttachment(context));
+ }
+
+ @Test
+ void testValidateTenantIdWithBackslashThrows() {
+ AttachmentReadEventContext context = mock(AttachmentReadEventContext.class);
+ when(context.getContentId()).thenReturn("content123");
+ when(context.getUserInfo()).thenReturn(userInfoWithTenant("tenant\\evil"));
+
+ assertThrows(ServiceException.class, () -> handler.readAttachment(context));
+ }
+
+ @Test
+ void testValidateTenantIdWithDotsThrows() {
+ AttachmentReadEventContext context = mock(AttachmentReadEventContext.class);
+ when(context.getContentId()).thenReturn("content123");
+ when(context.getUserInfo()).thenReturn(userInfoWithTenant("..evil"));
+
+ assertThrows(ServiceException.class, () -> handler.readAttachment(context));
+ }
+
+ @Test
+ void testValidateEmptyTenantIdThrows() {
+ AttachmentReadEventContext context = mock(AttachmentReadEventContext.class);
+ when(context.getContentId()).thenReturn("content123");
+ when(context.getUserInfo()).thenReturn(userInfoWithTenant(""));
+
+ assertThrows(ServiceException.class, () -> handler.readAttachment(context));
+ }
+
+ @Test
+ void testValidateContentIdWithSlashThrows() {
+ AttachmentReadEventContext context = mock(AttachmentReadEventContext.class);
+ when(context.getContentId()).thenReturn("content/evil");
+ when(context.getUserInfo()).thenReturn(userInfoWithTenant("validTenant"));
+
+ assertThrows(ServiceException.class, () -> handler.readAttachment(context));
+ }
+
+ @Test
+ void testValidateContentIdWithNullThrows() {
+ AttachmentReadEventContext context = mock(AttachmentReadEventContext.class);
+ when(context.getContentId()).thenReturn(null);
+ when(context.getUserInfo()).thenReturn(userInfoWithTenant("validTenant"));
+
+ assertThrows(ServiceException.class, () -> handler.readAttachment(context));
+ }
+
+ @Test
+ void testValidateContentIdWithBackslashThrows() {
+ AttachmentReadEventContext context = mock(AttachmentReadEventContext.class);
+ when(context.getContentId()).thenReturn("content\\evil");
+ when(context.getUserInfo()).thenReturn(userInfoWithTenant("validTenant"));
+
+ assertThrows(ServiceException.class, () -> handler.readAttachment(context));
+ }
+
+ @Test
+ void testValidateContentIdWithDotsThrows() {
+ AttachmentReadEventContext context = mock(AttachmentReadEventContext.class);
+ when(context.getContentId()).thenReturn("..evil");
+ when(context.getUserInfo()).thenReturn(userInfoWithTenant("validTenant"));
+
+ assertThrows(ServiceException.class, () -> handler.readAttachment(context));
+ }
+
+ @Test
+ void testValidateEmptyContentIdThrows() {
+ AttachmentReadEventContext context = mock(AttachmentReadEventContext.class);
+ when(context.getContentId()).thenReturn("");
+ when(context.getUserInfo()).thenReturn(userInfoWithTenant("validTenant"));
+
+ assertThrows(ServiceException.class, () -> handler.readAttachment(context));
+ }
}
}
diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandlerTestUtils.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandlerTestUtils.java
index 615aba16c..e0fbc6a00 100644
--- a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandlerTestUtils.java
+++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandlerTestUtils.java
@@ -15,6 +15,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.oss.client.OSClient;
+import com.sap.cds.feature.attachments.oss.client.OSClientFactory;
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;
@@ -39,7 +40,8 @@ public static void testCreateReadDeleteAttachmentFlow(
String testFileName = "testFileName-" + System.currentTimeMillis() + ".txt";
String testFileContent = "test";
- OSSAttachmentsServiceHandler handler = new OSSAttachmentsServiceHandler(binding, executor);
+ OSClient osClient = OSClientFactory.create(binding, executor);
+ OSSAttachmentsServiceHandler handler = new OSSAttachmentsServiceHandler(osClient, false, null);
// Create an AttachmentCreateEventContext with mocked data - to upload a test attachment
MediaData createMediaData = mock(MediaData.class);
diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/TenantCleanupHandlerTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/TenantCleanupHandlerTest.java
new file mode 100644
index 000000000..818d3f86d
--- /dev/null
+++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/TenantCleanupHandlerTest.java
@@ -0,0 +1,93 @@
+/*
+ * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
+ */
+package com.sap.cds.feature.attachments.oss.handler;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.sap.cds.feature.attachments.oss.client.OSClient;
+import com.sap.cds.services.ServiceException;
+import com.sap.cds.services.mt.UnsubscribeEventContext;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class TenantCleanupHandlerTest {
+
+ private OSClient mockOsClient;
+ private TenantCleanupHandler handler;
+
+ @BeforeEach
+ void setup() {
+ mockOsClient = mock(OSClient.class);
+ handler = new TenantCleanupHandler(mockOsClient);
+ }
+
+ @Test
+ void testCleanupTenantDataCallsDeleteByPrefix() throws Exception {
+ var context = UnsubscribeEventContext.create();
+ context.setTenant("tenant1");
+
+ when(mockOsClient.deleteContentByPrefix("tenant1/"))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ handler.cleanupTenantData(context);
+
+ verify(mockOsClient).deleteContentByPrefix("tenant1/");
+ }
+
+ @Test
+ void testCleanupTenantDataHandlesInterruptedException() throws Exception {
+ var context = UnsubscribeEventContext.create();
+ context.setTenant("tenant2");
+
+ @SuppressWarnings("unchecked")
+ CompletableFuture future = mock(CompletableFuture.class);
+ when(mockOsClient.deleteContentByPrefix("tenant2/")).thenReturn(future);
+ when(future.get()).thenThrow(new InterruptedException("interrupted"));
+
+ handler.cleanupTenantData(context);
+
+ verify(mockOsClient).deleteContentByPrefix("tenant2/");
+ }
+
+ @Test
+ void testCleanupTenantDataHandlesRuntimeException() throws Exception {
+ var context = UnsubscribeEventContext.create();
+ context.setTenant("tenant3");
+
+ when(mockOsClient.deleteContentByPrefix("tenant3/"))
+ .thenReturn(CompletableFuture.failedFuture(new RuntimeException("fail")));
+
+ handler.cleanupTenantData(context);
+
+ verify(mockOsClient).deleteContentByPrefix("tenant3/");
+ }
+
+ @Test
+ void testCleanupNullTenantThrowsServiceException() {
+ var context = UnsubscribeEventContext.create();
+ // tenant is null by default
+
+ assertThrows(ServiceException.class, () -> handler.cleanupTenantData(context));
+ }
+
+ @Test
+ void testCleanupHandlesExecutionException() throws Exception {
+ var context = UnsubscribeEventContext.create();
+ context.setTenant("tenant4");
+
+ @SuppressWarnings("unchecked")
+ CompletableFuture future = mock(CompletableFuture.class);
+ when(mockOsClient.deleteContentByPrefix("tenant4/")).thenReturn(future);
+ when(future.get()).thenThrow(new ExecutionException("fail", new RuntimeException("cause")));
+
+ handler.cleanupTenantData(context);
+
+ verify(mockOsClient).deleteContentByPrefix("tenant4/");
+ }
+}