From 5399492d4e0cb276f5fb062080a356d2d2906c6e Mon Sep 17 00:00:00 2001
From: OZGCloud <ozgcloud@mgm-tp.com>
Date: Mon, 11 Mar 2024 19:54:06 +0100
Subject: [PATCH] OZG-5121 [wip] implemented file upload

---
 .../apilib/file/OzgCloudFileService.java      |   3 +
 .../apilib/file/OzgCloudUploadFile.java       |  14 +
 .../file/dummy/DummyOzgCloudFileService.java  |   9 +
 .../file/grpc/GrpcOzgCloudFileService.java    |  62 +++-
 .../file/OzgCloudUploadFileTestFactory.java   |  24 ++
 .../grpc/GrpcOzgCloudFileServiceTest.java     | 297 +++++++++++++++++-
 ...pcUploadBinaryFileMetaDataTestFactory.java |  20 ++
 7 files changed, 424 insertions(+), 5 deletions(-)
 create mode 100644 api-lib-core/src/main/java/de/ozgcloud/apilib/file/OzgCloudUploadFile.java
 create mode 100644 api-lib-core/src/test/java/de/ozgcloud/apilib/file/OzgCloudUploadFileTestFactory.java
 create mode 100644 api-lib-core/src/test/java/de/ozgcloud/apilib/file/grpc/GrpcUploadBinaryFileMetaDataTestFactory.java

diff --git a/api-lib-core/src/main/java/de/ozgcloud/apilib/file/OzgCloudFileService.java b/api-lib-core/src/main/java/de/ozgcloud/apilib/file/OzgCloudFileService.java
index dc5f385..e9b82ef 100644
--- a/api-lib-core/src/main/java/de/ozgcloud/apilib/file/OzgCloudFileService.java
+++ b/api-lib-core/src/main/java/de/ozgcloud/apilib/file/OzgCloudFileService.java
@@ -1,5 +1,6 @@
 package de.ozgcloud.apilib.file;
 
+import java.io.InputStream;
 import java.io.OutputStream;
 
 public interface OzgCloudFileService {
@@ -7,4 +8,6 @@ public interface OzgCloudFileService {
 	OzgCloudFile getFile(OzgCloudFileId id);
 
 	void writeFileDataToStream(OzgCloudFileId id, OutputStream streamToWriteData);
+
+	OzgCloudFileId uploadFile(OzgCloudUploadFile file, InputStream dataStream);
 }
diff --git a/api-lib-core/src/main/java/de/ozgcloud/apilib/file/OzgCloudUploadFile.java b/api-lib-core/src/main/java/de/ozgcloud/apilib/file/OzgCloudUploadFile.java
new file mode 100644
index 0000000..fed2503
--- /dev/null
+++ b/api-lib-core/src/main/java/de/ozgcloud/apilib/file/OzgCloudUploadFile.java
@@ -0,0 +1,14 @@
+package de.ozgcloud.apilib.file;
+
+import lombok.Builder;
+import lombok.Getter;
+
+@Builder
+@Getter
+public class OzgCloudUploadFile {
+
+	private String fileName;
+	private String contentType;
+	private String vorgangId;
+	private String fieldName;
+}
diff --git a/api-lib-core/src/main/java/de/ozgcloud/apilib/file/dummy/DummyOzgCloudFileService.java b/api-lib-core/src/main/java/de/ozgcloud/apilib/file/dummy/DummyOzgCloudFileService.java
index 367db8f..4026859 100644
--- a/api-lib-core/src/main/java/de/ozgcloud/apilib/file/dummy/DummyOzgCloudFileService.java
+++ b/api-lib-core/src/main/java/de/ozgcloud/apilib/file/dummy/DummyOzgCloudFileService.java
@@ -1,14 +1,17 @@
 package de.ozgcloud.apilib.file.dummy;
 
 import java.io.IOException;
+import java.io.InputStream;
 import java.io.OutputStream;
 
+import org.apache.commons.io.IOUtils;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.stereotype.Service;
 
 import de.ozgcloud.apilib.file.OzgCloudFile;
 import de.ozgcloud.apilib.file.OzgCloudFileId;
 import de.ozgcloud.apilib.file.OzgCloudFileService;
+import de.ozgcloud.apilib.file.OzgCloudUploadFile;
 import de.ozgcloud.common.errorhandling.TechnicalException;
 
 @Service
@@ -38,4 +41,10 @@ public class DummyOzgCloudFileService implements OzgCloudFileService {
 		}
 	}
 
+	@Override
+	public OzgCloudFileId uploadFile(OzgCloudUploadFile file, InputStream dataStream) {
+		IOUtils.closeQuietly(dataStream);
+		return OzgCloudFileId.from("%s-%s".formatted(file.getFileName(), file.getVorgangId()));
+	}
+
 }
diff --git a/api-lib-core/src/main/java/de/ozgcloud/apilib/file/grpc/GrpcOzgCloudFileService.java b/api-lib-core/src/main/java/de/ozgcloud/apilib/file/grpc/GrpcOzgCloudFileService.java
index 139931e..a3a6af6 100644
--- a/api-lib-core/src/main/java/de/ozgcloud/apilib/file/grpc/GrpcOzgCloudFileService.java
+++ b/api-lib-core/src/main/java/de/ozgcloud/apilib/file/grpc/GrpcOzgCloudFileService.java
@@ -1,5 +1,6 @@
 package de.ozgcloud.apilib.file.grpc;
 
+import java.io.InputStream;
 import java.io.OutputStream;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
@@ -7,23 +8,34 @@ import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.logging.Level;
 
+import org.apache.commons.io.IOUtils;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.stereotype.Service;
 
+import com.google.protobuf.ByteString;
+
 import de.ozgcloud.apilib.common.callcontext.OzgCloudCallContextAttachingInterceptor;
 import de.ozgcloud.apilib.common.callcontext.OzgCloudCallContextProvider;
 import de.ozgcloud.apilib.common.errorhandling.NotFoundException;
 import de.ozgcloud.apilib.file.OzgCloudFile;
 import de.ozgcloud.apilib.file.OzgCloudFileId;
 import de.ozgcloud.apilib.file.OzgCloudFileService;
+import de.ozgcloud.apilib.file.OzgCloudUploadFile;
 import de.ozgcloud.common.binaryfile.FileId;
+import de.ozgcloud.common.binaryfile.GrpcFileUploadUtils;
+import de.ozgcloud.common.binaryfile.GrpcFileUploadUtils.FileSender;
 import de.ozgcloud.common.errorhandling.TechnicalException;
 import de.ozgcloud.vorgang.grpc.binaryFile.BinaryFileServiceGrpc.BinaryFileServiceBlockingStub;
 import de.ozgcloud.vorgang.grpc.binaryFile.BinaryFileServiceGrpc.BinaryFileServiceStub;
 import de.ozgcloud.vorgang.grpc.binaryFile.GrpcBinaryFilesRequest;
 import de.ozgcloud.vorgang.grpc.binaryFile.GrpcFindFilesResponse;
 import de.ozgcloud.vorgang.grpc.binaryFile.GrpcGetBinaryFileDataRequest;
+import de.ozgcloud.vorgang.grpc.binaryFile.GrpcUploadBinaryFileMetaData;
+import de.ozgcloud.vorgang.grpc.binaryFile.GrpcUploadBinaryFileRequest;
+import de.ozgcloud.vorgang.grpc.binaryFile.GrpcUploadBinaryFileResponse;
 import de.ozgcloud.vorgang.grpc.file.GrpcOzgFile;
+import io.grpc.stub.CallStreamObserver;
+import io.grpc.stub.StreamObserver;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.java.Log;
 import net.devh.boot.grpc.client.inject.GrpcClient;
@@ -54,8 +66,8 @@ public class GrpcOzgCloudFileService implements OzgCloudFileService {
 
 	GrpcBinaryFilesRequest buildFindFileRequest(OzgCloudFileId id) {
 		return GrpcBinaryFilesRequest.newBuilder()
-				.addFileId(id.toString())
-				.build();
+			.addFileId(id.toString())
+			.build();
 	}
 
 	GrpcOzgFile getSingleResponse(GrpcFindFilesResponse response, OzgCloudFileId id) {
@@ -99,7 +111,53 @@ public class GrpcOzgCloudFileService implements OzgCloudFileService {
 		return blockingStub.withInterceptors(new OzgCloudCallContextAttachingInterceptor(contextProvider));
 	}
 
+	@Override
+	public OzgCloudFileId uploadFile(OzgCloudUploadFile uploadFile, InputStream dataStream) {
+		var resultFuture = GrpcFileUploadUtils.createSender(this::buildChunkRequest, dataStream, this::buildCallStreamObserver)
+			.withMetaData(buildMetaDataRequest(uploadFile))
+			.send();
+		var uploadBinaryFileResponse = waitUntilFutureToComplete(resultFuture, dataStream);
+		return OzgCloudFileId.from(uploadBinaryFileResponse.getFileId());
+	}
+
+	GrpcUploadBinaryFileRequest buildChunkRequest(byte[] bytes, Integer length) {
+		return GrpcUploadBinaryFileRequest.newBuilder().setFileContent((ByteString.copyFrom(bytes, 0, length))).build();
+	}
+
+	CallStreamObserver<GrpcUploadBinaryFileRequest> buildCallStreamObserver(
+		StreamObserver<GrpcUploadBinaryFileResponse> responseObserver) {
+		return (CallStreamObserver<GrpcUploadBinaryFileRequest>) getAsyncServiceStub().uploadBinaryFileAsStream(responseObserver);
+	}
+
 	BinaryFileServiceStub getAsyncServiceStub() {
 		return asyncServiceStub.withInterceptors(new OzgCloudCallContextAttachingInterceptor(contextProvider));
 	}
+
+	GrpcUploadBinaryFileRequest buildMetaDataRequest(OzgCloudUploadFile uploadFile) {
+		return GrpcUploadBinaryFileRequest.newBuilder()
+			.setMetadata(GrpcUploadBinaryFileMetaData.newBuilder()
+				.setFileName(uploadFile.getFileName())
+				.setContentType(uploadFile.getContentType())
+				.setVorgangId(uploadFile.getVorgangId())
+				.setField(uploadFile.getFieldName())
+				.build())
+			.build();
+	}
+
+	GrpcUploadBinaryFileResponse waitUntilFutureToComplete(FileSender<GrpcUploadBinaryFileRequest, GrpcUploadBinaryFileResponse> fileSender,
+		InputStream fileContentStream) {
+		try {
+			return fileSender.getResultFuture().get(10, TimeUnit.MINUTES);
+		} catch (InterruptedException e) {
+			Thread.currentThread().interrupt();
+			fileSender.cancelOnError(e);
+			throw new TechnicalException("Waiting for finishing upload was interrupted.", e);
+		} catch (ExecutionException | TimeoutException e) {
+			fileSender.cancelOnTimeout();
+			throw new TechnicalException("Error / Timeout on uploading data.", e);
+		} finally {
+			IOUtils.closeQuietly(fileContentStream);
+		}
+	}
+
 }
diff --git a/api-lib-core/src/test/java/de/ozgcloud/apilib/file/OzgCloudUploadFileTestFactory.java b/api-lib-core/src/test/java/de/ozgcloud/apilib/file/OzgCloudUploadFileTestFactory.java
new file mode 100644
index 0000000..18ea650
--- /dev/null
+++ b/api-lib-core/src/test/java/de/ozgcloud/apilib/file/OzgCloudUploadFileTestFactory.java
@@ -0,0 +1,24 @@
+package de.ozgcloud.apilib.file;
+
+import de.ozgcloud.apilib.file.OzgCloudUploadFile.OzgCloudUploadFileBuilder;
+import de.ozgcloud.apilib.vorgang.OzgCloudVorgangTestFactory;
+
+public class OzgCloudUploadFileTestFactory {
+
+	public static final String FILE_NAME = "test.txt";
+	public static final String CONTENT_TYPE = "text/plain";
+	public static final String FIELD_NAME = "field";
+
+	public static OzgCloudUploadFile create() {
+		return createBuilder().build();
+	}
+
+	private static OzgCloudUploadFileBuilder createBuilder() {
+		return OzgCloudUploadFile.builder()
+			.fileName(FILE_NAME)
+			.contentType(CONTENT_TYPE)
+			.vorgangId(OzgCloudVorgangTestFactory.ID.toString())
+			.fieldName(FIELD_NAME);
+	}
+
+}
diff --git a/api-lib-core/src/test/java/de/ozgcloud/apilib/file/grpc/GrpcOzgCloudFileServiceTest.java b/api-lib-core/src/test/java/de/ozgcloud/apilib/file/grpc/GrpcOzgCloudFileServiceTest.java
index 7886016..8a648e3 100644
--- a/api-lib-core/src/test/java/de/ozgcloud/apilib/file/grpc/GrpcOzgCloudFileServiceTest.java
+++ b/api-lib-core/src/test/java/de/ozgcloud/apilib/file/grpc/GrpcOzgCloudFileServiceTest.java
@@ -1,29 +1,55 @@
 package de.ozgcloud.apilib.file.grpc;
 
 import static org.assertj.core.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.*;
 import static org.mockito.Mockito.*;
 
+import java.io.InputStream;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.apache.commons.io.IOUtils;
 import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.mapstruct.factory.Mappers;
 import org.mockito.InjectMocks;
 import org.mockito.Mock;
+import org.mockito.Mockito;
 import org.mockito.Spy;
 
 import de.ozgcloud.apilib.common.errorhandling.NotFoundException;
 import de.ozgcloud.apilib.file.OzgCloudFileTestFactory;
+import de.ozgcloud.apilib.file.OzgCloudUploadFileTestFactory;
+import de.ozgcloud.common.binaryfile.GrpcFileUploadUtils;
+import de.ozgcloud.common.binaryfile.GrpcFileUploadUtils.FileSender;
+import de.ozgcloud.common.errorhandling.TechnicalException;
 import de.ozgcloud.vorgang.grpc.binaryFile.BinaryFileServiceGrpc.BinaryFileServiceBlockingStub;
+import de.ozgcloud.vorgang.grpc.binaryFile.BinaryFileServiceGrpc.BinaryFileServiceStub;
 import de.ozgcloud.vorgang.grpc.binaryFile.GrpcFindFilesResponse;
+import de.ozgcloud.vorgang.grpc.binaryFile.GrpcUploadBinaryFileMetaData;
+import de.ozgcloud.vorgang.grpc.binaryFile.GrpcUploadBinaryFileRequest;
+import de.ozgcloud.vorgang.grpc.binaryFile.GrpcUploadBinaryFileResponse;
+import io.grpc.stub.CallStreamObserver;
+import io.grpc.stub.StreamObserver;
+import lombok.SneakyThrows;
 
 class GrpcOzgCloudFileServiceTest {
 
+	@Spy
 	@InjectMocks
 	private GrpcOzgCloudFileService service;
 
 	@Mock
 	private BinaryFileServiceBlockingStub blockingStub;
+	@Mock
+	private BinaryFileServiceStub asyncServiceStub;
 	@Spy
 	private OzgCloudFileMapper mapper = Mappers.getMapper(OzgCloudFileMapper.class);
 
@@ -35,7 +61,8 @@ class GrpcOzgCloudFileServiceTest {
 			@BeforeEach
 			void init() {
 				when(blockingStub.withInterceptors(any())).thenReturn(blockingStub);
-				when(blockingStub.findBinaryFilesMetaData(any())).thenReturn(GrpcFindFilesResponse.newBuilder().addFile(GrpcOzgFileTestFactory.create()).build());
+				when(blockingStub.findBinaryFilesMetaData(any())).thenReturn(
+					GrpcFindFilesResponse.newBuilder().addFile(GrpcOzgFileTestFactory.create()).build());
 			}
 
 			@Test
@@ -76,7 +103,7 @@ class GrpcOzgCloudFileServiceTest {
 			@Test
 			void shouldReturnFirstFile() {
 				var response = GrpcFindFilesResponseTestFactory.createBuilder().addFile(GrpcOzgFileTestFactory.createBuilder().setId("2").build())
-						.build();
+					.build();
 
 				var result = service.getSingleResponse(response, OzgCloudFileTestFactory.ID);
 
@@ -88,9 +115,273 @@ class GrpcOzgCloudFileServiceTest {
 				var response = GrpcFindFilesResponseTestFactory.createBuilder().clearFile().build();
 
 				assertThatThrownBy(() -> service.getSingleResponse(response, OzgCloudFileTestFactory.ID))
-						.isInstanceOf(NotFoundException.class);
+					.isInstanceOf(NotFoundException.class);
 			}
 		}
 	}
 
+	@Nested
+	class TestUploadFile {
+
+		@Nested
+		class TestUploadFileData {
+
+			@Mock
+			private FileSender<GrpcUploadBinaryFileRequest, GrpcUploadBinaryFileResponse> fileSender;
+			@Mock
+			private GrpcUploadBinaryFileRequest uploadBinaryFileRequest;
+
+			GrpcUploadBinaryFileResponse response = GrpcUploadBinaryFileResponse.newBuilder().setFileId(OzgCloudFileTestFactory.ID.toString())
+				.build();
+
+			@BeforeEach
+			void init() {
+				doReturn(response).when(service).waitUntilFutureToComplete(any(), any());
+			}
+
+			@Test
+			void shouldCallCreateSender() {
+				when(fileSender.withMetaData(any())).thenReturn(fileSender);
+				try (var uploadUtils = Mockito.mockStatic(GrpcFileUploadUtils.class)) {
+					uploadUtils.when(() -> GrpcFileUploadUtils.createSender(any(), any(), any())).thenReturn(fileSender);
+					var dataStream = InputStream.nullInputStream();
+
+					service.uploadFile(OzgCloudUploadFileTestFactory.create(), dataStream);
+
+					uploadUtils.verify(() -> GrpcFileUploadUtils.createSender(any(), eq(dataStream), any()));
+				}
+			}
+
+			@Test
+			void shouldCallBuildMetadataRequest() {
+				when(asyncServiceStub.withInterceptors(any())).thenReturn(asyncServiceStub);
+				var file = OzgCloudUploadFileTestFactory.create();
+
+				service.uploadFile(file, InputStream.nullInputStream());
+
+				verify(service).buildMetaDataRequest(file);
+			}
+
+			@Test
+			void shoudlSetMetadata() {
+				try (var uploadUtils = Mockito.mockStatic(GrpcFileUploadUtils.class)) {
+					uploadUtils.when(() -> GrpcFileUploadUtils.createSender(any(), any(), any())).thenReturn(fileSender);
+					when(fileSender.withMetaData(any())).thenReturn(fileSender);
+					doReturn(uploadBinaryFileRequest).when(service).buildMetaDataRequest(any());
+
+					service.uploadFile(OzgCloudUploadFileTestFactory.create(), InputStream.nullInputStream());
+
+					verify(fileSender).withMetaData(uploadBinaryFileRequest);
+				}
+			}
+
+			@Test
+			void shouldCallSend() {
+				try (var uploadUtils = Mockito.mockStatic(GrpcFileUploadUtils.class)) {
+					uploadUtils.when(() -> GrpcFileUploadUtils.createSender(any(), any(), any())).thenReturn(fileSender);
+					when(fileSender.withMetaData(any())).thenReturn(fileSender);
+
+					service.uploadFile(OzgCloudUploadFileTestFactory.create(), InputStream.nullInputStream());
+
+					verify(fileSender).send();
+				}
+			}
+
+			@Test
+			void shouldCallWaitUntilFutureToComplete() {
+				try (var uploadUtils = Mockito.mockStatic(GrpcFileUploadUtils.class)) {
+					uploadUtils.when(() -> GrpcFileUploadUtils.createSender(any(), any(), any())).thenReturn(fileSender);
+					when(fileSender.withMetaData(any())).thenReturn(fileSender);
+					when(fileSender.send()).thenReturn(fileSender);
+					var dataStream = InputStream.nullInputStream();
+
+					service.uploadFile(OzgCloudUploadFileTestFactory.create(), dataStream);
+
+					verify(service).waitUntilFutureToComplete(fileSender, dataStream);
+				}
+			}
+
+			@Test
+			void shouldReturnResult() {
+				when(asyncServiceStub.withInterceptors(any())).thenReturn(asyncServiceStub);
+
+				var result = service.uploadFile(OzgCloudUploadFileTestFactory.create(), InputStream.nullInputStream());
+
+				assertThat(result).isEqualTo(OzgCloudFileTestFactory.ID);
+			}
+
+			private GrpcUploadBinaryFileRequest createRequest() {
+				return GrpcUploadBinaryFileRequest.newBuilder()
+					.setMetadata(GrpcUploadBinaryFileMetaDataTestFactory.create())
+					.build();
+			}
+		}
+
+		@Nested
+		class TestBuildChunkResponse {
+
+			private static byte[] FILE_CONTENT = "file content".getBytes();
+
+			@Test
+			void shouldSetFileContent() {
+				var grpcUploadBinaryFileRequest = service.buildChunkRequest(FILE_CONTENT, FILE_CONTENT.length);
+
+				assertThat(grpcUploadBinaryFileRequest.getFileContent().toByteArray()).isEqualTo(FILE_CONTENT);
+			}
+		}
+
+		@Nested
+		class TestBuildCallStreamObserver {
+
+			@Mock
+			private StreamObserver<GrpcUploadBinaryFileResponse> responseObserver;
+			@Mock
+			private CallStreamObserver<GrpcUploadBinaryFileRequest> requestObserver;
+
+			@BeforeEach
+			void init() {
+				doReturn(asyncServiceStub).when(service).getAsyncServiceStub();
+			}
+
+			@Test
+			void shouldCallGetAsyncServiceStub() {
+				service.buildCallStreamObserver(responseObserver);
+
+				verify(service).getAsyncServiceStub();
+			}
+
+			@Test
+			void shouldCallUploadBinaryFileAsStream() {
+				 service.buildCallStreamObserver(responseObserver);
+
+				verify(asyncServiceStub).uploadBinaryFileAsStream(responseObserver);
+			}
+
+			@Test
+			void shouldReturnRequestObserver() {
+				when(asyncServiceStub.uploadBinaryFileAsStream(any())).thenReturn(requestObserver);
+
+				var result = service.buildCallStreamObserver(responseObserver);
+
+				assertThat(result).isEqualTo(requestObserver);
+			}
+		}
+
+		@Nested
+		class TestBuildMetadataRequest {
+
+			@Test
+			void shouldSetMetadata() {
+				var result = buildMetadataRequest();
+
+				assertThat(result.getMetadata()).isNotEqualTo(GrpcUploadBinaryFileMetaData.getDefaultInstance());
+			}
+
+			@Test
+			void shouldSetFileName() {
+				var uploadFile = OzgCloudUploadFileTestFactory.create();
+
+				var result = service.buildMetaDataRequest(uploadFile).getMetadata();
+
+				assertThat(result.getFileName()).isEqualTo(uploadFile.getFileName());
+			}
+
+			@Test
+			void shouldSetContentType() {
+				var uploadFile = OzgCloudUploadFileTestFactory.create();
+
+				var result = service.buildMetaDataRequest(uploadFile).getMetadata();
+
+				assertThat(result.getContentType()).isEqualTo(uploadFile.getContentType());
+			}
+
+			@Test
+			void shouldSetVorgangId() {
+				var uploadFile = OzgCloudUploadFileTestFactory.create();
+
+				var result = service.buildMetaDataRequest(uploadFile).getMetadata();
+
+				assertThat(result.getVorgangId()).isEqualTo(uploadFile.getVorgangId().toString());
+			}
+
+			@Test
+			void shouldSetField() {
+				var uploadFile = OzgCloudUploadFileTestFactory.create();
+
+				var result = service.buildMetaDataRequest(uploadFile).getMetadata();
+
+				assertThat(result.getField()).isEqualTo(uploadFile.getFieldName());
+			}
+
+			private GrpcUploadBinaryFileRequest buildMetadataRequest() {
+				return service.buildMetaDataRequest(OzgCloudUploadFileTestFactory.create());
+			}
+		}
+
+		@Nested
+		class TestWaitUntilFutureToComplete {
+
+			private static final InputStream DATA_STREAM = InputStream.nullInputStream();
+
+			@Mock
+			private FileSender<GrpcUploadBinaryFileRequest, GrpcUploadBinaryFileResponse> fileSender;
+
+			@Mock
+			private CompletableFuture<GrpcUploadBinaryFileResponse> future;
+
+			@Test
+			void shouldWaitUntilFutureToComplete() {
+				when(fileSender.getResultFuture()).thenReturn(future);
+
+				waitUntilFutureToComplete();
+
+				verify(fileSender).getResultFuture();
+			}
+
+			@SneakyThrows
+			@Test
+			void shouldWaitWithTimeout() {
+				when(fileSender.getResultFuture()).thenReturn(future);
+
+				waitUntilFutureToComplete();
+
+				verify(future).get(10, TimeUnit.MINUTES);
+			}
+
+			@Test
+			void shouldCloseDatastream() {
+				try(var ioUtils = Mockito.mockStatic(IOUtils.class)) {
+					when(fileSender.getResultFuture()).thenReturn(future);
+
+					waitUntilFutureToComplete();
+
+					ioUtils.verify(() -> IOUtils.closeQuietly(DATA_STREAM));
+				}
+			}
+
+			@Nested
+			class TestThrowException {
+
+				@BeforeEach
+				void init() {
+					when(fileSender.getResultFuture()).thenReturn(future);
+				}
+
+				@SneakyThrows
+				@DisplayName("should throw TechnicalException")
+				@ParameterizedTest(name = "when {0} is thrown")
+				@ValueSource(classes = { InterruptedException.class, ExecutionException.class, TimeoutException.class })
+				void shouldHandleInterruptedException(Class<? extends Exception> exceptionClass) {
+					when(future.get(anyLong(), any())).thenThrow(exceptionClass);
+
+					assertThrows(TechnicalException.class, TestWaitUntilFutureToComplete.this::waitUntilFutureToComplete);
+				}
+
+			}
+
+			void waitUntilFutureToComplete() {
+				service.waitUntilFutureToComplete(fileSender, DATA_STREAM);
+			}
+		}
+	}
 }
diff --git a/api-lib-core/src/test/java/de/ozgcloud/apilib/file/grpc/GrpcUploadBinaryFileMetaDataTestFactory.java b/api-lib-core/src/test/java/de/ozgcloud/apilib/file/grpc/GrpcUploadBinaryFileMetaDataTestFactory.java
new file mode 100644
index 0000000..67292cb
--- /dev/null
+++ b/api-lib-core/src/test/java/de/ozgcloud/apilib/file/grpc/GrpcUploadBinaryFileMetaDataTestFactory.java
@@ -0,0 +1,20 @@
+package de.ozgcloud.apilib.file.grpc;
+
+import de.ozgcloud.apilib.file.OzgCloudUploadFileTestFactory;
+import de.ozgcloud.apilib.vorgang.OzgCloudVorgangTestFactory;
+import de.ozgcloud.vorgang.grpc.binaryFile.GrpcUploadBinaryFileMetaData;
+
+public class GrpcUploadBinaryFileMetaDataTestFactory {
+
+	public static GrpcUploadBinaryFileMetaData create() {
+		return createBuilder().build();
+	}
+
+	private static GrpcUploadBinaryFileMetaData.Builder createBuilder() {
+		return GrpcUploadBinaryFileMetaData.newBuilder()
+			.setFileName(OzgCloudUploadFileTestFactory.FILE_NAME)
+			.setContentType(OzgCloudUploadFileTestFactory.CONTENT_TYPE)
+			.setVorgangId(OzgCloudVorgangTestFactory.ID.toString())
+			.setField(OzgCloudUploadFileTestFactory.FIELD_NAME);
+	}
+}
-- 
GitLab