From 05a60eb47ba203e233f7b747cfeba6a88c773b49 Mon Sep 17 00:00:00 2001
From: Jan Zickermann <jan.zickermann@dataport.de>
Date: Mon, 17 Feb 2025 14:39:41 +0100
Subject: [PATCH] OZG-4097 send-attachment: Wait until upload completed

---
 .../osiv2/exception/Osi2UploadException.java  |   2 +-
 .../postfach/osiv2/model/Osi2FileUpload.java  |   3 +
 .../osiv2/transfer/Osi2QuarantineService.java |  44 ++-----
 .../osiv2/transfer/Osi2ResponseMapper.java    |   2 +-
 .../transfer/PostfachApiFacadeService.java    |   3 +-
 .../postfach/osiv2/transfer/WaitUtil.java     |  42 +++++++
 .../osiv2/OsiPostfachRemoteServiceITCase.java |  20 ++-
 .../transfer/Osi2QuarantineServiceTest.java   | 119 ++++++++++++++++++
 .../transfer/Osi2ResponseMapperTest.java      |   3 +
 .../PostfachApiFacadeServiceTest.java         |   5 +
 .../postfach/osiv2/transfer/WaitUtilTest.java |  81 ++++++++++++
 11 files changed, 288 insertions(+), 36 deletions(-)
 create mode 100644 src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/WaitUtil.java
 create mode 100644 src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/WaitUtilTest.java

diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2UploadException.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2UploadException.java
index 447a048..10b9f2a 100644
--- a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2UploadException.java
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2UploadException.java
@@ -2,7 +2,7 @@ package de.ozgcloud.nachrichten.postfach.osiv2.exception;
 
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.QuarantineStatus;
 
-public class Osi2UploadException extends RuntimeException {
+public class Osi2UploadException extends Exception {
 	public Osi2UploadException(QuarantineStatus uploadStatus) {
 		super(uploadStatus.toString());
 	}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/model/Osi2FileUpload.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/model/Osi2FileUpload.java
index 5c3ddb4..b5bfd23 100644
--- a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/model/Osi2FileUpload.java
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/model/Osi2FileUpload.java
@@ -23,4 +23,7 @@ public record Osi2FileUpload(
 		return (file.getSize() + CHUNK_SIZE - 1) / CHUNK_SIZE;
 	}
 
+	public String getLoggableString() {
+		return String.format("FileUpload(guid=%s, ozgFileId=%s, size=%d)", guid, file.getId(), file.getSize());
+	}
 }
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2QuarantineService.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2QuarantineService.java
index ea638e3..d673927 100644
--- a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2QuarantineService.java
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2QuarantineService.java
@@ -1,22 +1,19 @@
 package de.ozgcloud.nachrichten.postfach.osiv2.transfer;
 
+import static de.ozgcloud.nachrichten.postfach.osiv2.transfer.WaitUtil.*;
+
 import java.io.IOException;
 import java.io.InputStream;
 import java.time.Duration;
 import java.util.Comparator;
 import java.util.List;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Executors;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-import java.util.function.BooleanSupplier;
 import java.util.stream.LongStream;
 import java.util.stream.Stream;
 
 import de.ozgcloud.apilib.file.OzgCloudFile;
 import de.ozgcloud.nachrichten.postfach.osiv2.ServiceIfOsi2Enabled;
 import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2RuntimeException;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2UploadException;
 import de.ozgcloud.nachrichten.postfach.osiv2.model.FileChunkInfo;
 import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2FileUpload;
 import de.ozgcloud.nachrichten.postfach.osiv2.storage.Osi2AttachmentFileService;
@@ -80,37 +77,22 @@ public class Osi2QuarantineService {
 	}
 
 	synchronized boolean checkVirusScanCompleted(List<Osi2FileUpload> osi2FileMetadata) {
-		// TODO ...
-		return true;
+		return osi2FileMetadata.stream()
+				.allMatch(this::checkOneVirusScanCompleted);
 	}
 
-	void waitForVirusScan(List<Osi2FileUpload> osi2FileMetadata) {
+	boolean checkOneVirusScanCompleted(Osi2FileUpload osi2FileMetadata) {
 		try {
-			waitUntil(() -> checkVirusScanCompleted(osi2FileMetadata), POLLING_INTERVAL, POLLING_TIMEOUT);
-		} catch (ExecutionException e) {
-			if (e.getCause() instanceof RuntimeException runtimeException) {
-				throw runtimeException;
-			}
-			throw new IllegalStateException("Unexpected exception: Expect the scan to complete successfully!", e.getCause());
-		} catch (TimeoutException e) {
-			throw new Osi2RuntimeException("Expect the scan to complete after %d seconds!".formatted(POLLING_TIMEOUT.getSeconds()), e);
-		} catch (InterruptedException e) {
-			LOG.debug("[waitForVirusScan] Interrupt");
-			Thread.currentThread().interrupt();
+			return postfachApiFacadeService.checkUploadSuccessful(osi2FileMetadata.guid());
+		} catch (Osi2UploadException e) {
+			throw new Osi2RuntimeException("%s failed!".formatted(osi2FileMetadata.getLoggableString()), e);
 		}
 	}
 
-	void waitUntil(BooleanSupplier condition, Duration interval, Duration timeout)
-			throws ExecutionException, InterruptedException, TimeoutException {
-		CompletableFuture.runAsync(() -> {
-			try (var executor = Executors.newSingleThreadScheduledExecutor()) {
-				executor.scheduleAtFixedRate(() -> {
-					if (condition.getAsBoolean()) {
-						executor.shutdown();
-					}
-				}, 0, interval.getSeconds(), TimeUnit.SECONDS);
-			}
-		}).get(timeout.getSeconds(), TimeUnit.SECONDS);
+	void waitForVirusScan(List<Osi2FileUpload> osi2FileMetadata) {
+		if (!waitUntil(() -> checkVirusScanCompleted(osi2FileMetadata), POLLING_INTERVAL, POLLING_TIMEOUT)) {
+			throw new Osi2RuntimeException("Expect the scan to complete after %d seconds!".formatted(POLLING_TIMEOUT.getSeconds()));
+		}
 	}
 
 	public void deleteAttachments(List<Osi2FileUpload> osi2FileMetadata) {
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2ResponseMapper.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2ResponseMapper.java
index 0a29139..f64b1b1 100644
--- a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2ResponseMapper.java
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2ResponseMapper.java
@@ -109,7 +109,7 @@ public interface Osi2ResponseMapper {
 				.toList();
 	}
 
-	default boolean isSafe(QuarantineStatus uploadStatus) {
+	default boolean isSafe(QuarantineStatus uploadStatus) throws Osi2UploadException {
 		return switch (uploadStatus) {
 			case NONE, UNSAFE, CORRUPT, MISSING -> throw new Osi2UploadException(uploadStatus);
 			case LEGACY, COMMITED -> false;
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/PostfachApiFacadeService.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/PostfachApiFacadeService.java
index 476b3c5..c27b95d 100644
--- a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/PostfachApiFacadeService.java
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/PostfachApiFacadeService.java
@@ -10,6 +10,7 @@ import org.springframework.core.io.AbstractResource;
 import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
 import de.ozgcloud.nachrichten.postfach.osiv2.ServiceIfOsi2Enabled;
 import de.ozgcloud.nachrichten.postfach.osiv2.config.Osi2PostfachProperties;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2UploadException;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.api.MessageExchangeApi;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.api.QuarantineApi;
 import de.ozgcloud.nachrichten.postfach.osiv2.model.FileChunkInfo;
@@ -44,7 +45,7 @@ public class PostfachApiFacadeService {
 		);
 	}
 
-	public boolean checkUploadSuccessful(final String messageId) {
+	public boolean checkUploadSuccessful(final String messageId) throws Osi2UploadException {
 		return responseMapper.isSafe(
 				quarantineApi.getUploadStatus(UUID.fromString(messageId))
 		);
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/WaitUtil.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/WaitUtil.java
new file mode 100644
index 0000000..5acad33
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/WaitUtil.java
@@ -0,0 +1,42 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.transfer;
+
+import java.time.Duration;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.BooleanSupplier;
+
+import lombok.AccessLevel;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
+public class WaitUtil {
+
+	public static boolean waitUntil(BooleanSupplier condition, Duration interval, Duration timeout) {
+		var waitResult = new AtomicBoolean(false);
+		try (var executor = Executors.newSingleThreadScheduledExecutor()) {
+			executor.scheduleAtFixedRate(() -> {
+						if (condition.getAsBoolean()) {
+							waitResult.set(true);
+							executor.shutdown();
+						}
+					}, 0, interval.toMillis(), TimeUnit.MILLISECONDS)
+					.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
+		} catch (InterruptedException e) {
+			Thread.currentThread().interrupt();
+			throw new IllegalStateException("[waitUntil] Interrupt!", e.getCause());
+		} catch (TimeoutException | CancellationException e) {
+			// ignore
+		} catch (ExecutionException e) {
+			if (e.getCause() instanceof RuntimeException runtimeException) {
+				throw runtimeException;
+			} else {
+				throw new IllegalStateException("Unexpected exception while waiting for condition!", e.getCause());
+			}
+		}
+		return waitResult.get();
+	}
+}
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceITCase.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceITCase.java
index b698bf7..04bef85 100644
--- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceITCase.java
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceITCase.java
@@ -34,6 +34,8 @@ import de.ozgcloud.nachrichten.postfach.osiv2.factory.MessageExchangeReceiveMess
 import de.ozgcloud.nachrichten.postfach.osiv2.factory.MessageExchangeSendMessageResponseTestFactory;
 import de.ozgcloud.nachrichten.postfach.osiv2.factory.PostfachNachrichtTestFactory;
 import de.ozgcloud.nachrichten.postfach.osiv2.factory.V1ReplyMessageTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.QuarantineFileResult;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.QuarantineStatus;
 import de.ozgcloud.nachrichten.postfach.osiv2.storage.Osi2AttachmentFileService;
 import lombok.SneakyThrows;
 
@@ -107,8 +109,14 @@ class OsiPostfachRemoteServiceITCase {
 	@Test
 	void shouldSendMessageWithAttachment() {
 		var textFileId = AttachmentExampleUploadUtil.uploadTextFile(osi2AttachmentFileService);
-		// TODO mock quarantine upload requests
-
+		postfachFacadeMockServer.stubFor(post(urlPathTemplate("/Quarantine/v1/Upload/Chunked"))
+				.willReturn(okJsonObj(QuarantineFileResult.builder()
+						.success(true)
+						.build()))
+		);
+		postfachFacadeMockServer.stubFor(get(urlPathTemplate("/Quarantine/v1/Upload/{guid}"))
+				.willReturn(okJsonObj(QuarantineStatus.SAFE))
+		);
 		var postfachNachrichtWithAttachment = PostfachNachrichtTestFactory.createBuilder()
 				.attachments(List.of(textFileId))
 				.build();
@@ -116,6 +124,14 @@ class OsiPostfachRemoteServiceITCase {
 
 		osiPostfachRemoteService.sendMessage(postfachNachrichtWithAttachment);
 
+		postfachFacadeMockServer.verify(
+				exactly(1),
+				postRequestedFor(urlPathTemplate("/Quarantine/v1/Upload/Chunked"))
+		);
+		postfachFacadeMockServer.verify(
+				exactly(1),
+				postRequestedFor(urlPathTemplate("/Quarantine/v1/Upload/{guid}"))
+		);
 		postfachFacadeMockServer.verify(
 				exactly(1),
 				postRequestedFor(urlPathTemplate("/MessageExchange/v1/Send/{mailboxId}"))
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2QuarantineServiceTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2QuarantineServiceTest.java
index b6ead12..8794260 100644
--- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2QuarantineServiceTest.java
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2QuarantineServiceTest.java
@@ -2,25 +2,32 @@ package de.ozgcloud.nachrichten.postfach.osiv2.transfer;
 
 import static de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2FileUpload.*;
 import static de.ozgcloud.nachrichten.postfach.osiv2.transfer.Osi2FileUploadTestFactory.*;
+import static de.ozgcloud.nachrichten.postfach.osiv2.transfer.Osi2QuarantineService.*;
 import static org.assertj.core.api.Assertions.*;
 import static org.mockito.Mockito.*;
 
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.List;
+import java.util.UUID;
 
 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.mockito.InjectMocks;
 import org.mockito.Mock;
+import org.mockito.MockedStatic;
 import org.mockito.Spy;
 
 import de.ozgcloud.apilib.file.OzgCloudFile;
 import de.ozgcloud.apilib.file.OzgCloudFileId;
 import de.ozgcloud.apilib.file.OzgCloudFileTestFactory;
 import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2RuntimeException;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2UploadException;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.QuarantineStatus;
 import de.ozgcloud.nachrichten.postfach.osiv2.model.FileChunkInfo;
 import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2FileUpload;
 import de.ozgcloud.nachrichten.postfach.osiv2.storage.Osi2AttachmentFileService;
@@ -218,6 +225,118 @@ class Osi2QuarantineServiceTest {
 	@Nested
 	class TestCheckVirusScanCompleted {
 
+		private final Osi2FileUpload upload1 = Osi2FileUploadTestFactory.create();
+		private final Osi2FileUpload upload2 = Osi2FileUploadTestFactory.createBuilder()
+				.guid(UUID.randomUUID().toString())
+				.build();
+		private final Osi2FileUpload upload3 = Osi2FileUploadTestFactory.createBuilder()
+				.guid(UUID.randomUUID().toString())
+				.build();
+
+		private final List<Osi2FileUpload> uploads = List.of(upload1, upload2, upload3);
+
+		@DisplayName("should return true if all virus scans completed")
+		@Test
+		void shouldReturnTrueIfAllVirusScansCompleted() {
+			doReturn(true).when(service).checkOneVirusScanCompleted(upload1);
+			doReturn(true).when(service).checkOneVirusScanCompleted(upload2);
+			doReturn(true).when(service).checkOneVirusScanCompleted(upload3);
+
+			var result = service.checkVirusScanCompleted(uploads);
+
+			assertThat(result).isTrue();
+		}
+
+		@DisplayName("should return false if one virus scan not completed")
+		@Test
+		void shouldReturnFalseIfOneVirusScanNotCompleted() {
+			doReturn(true).when(service).checkOneVirusScanCompleted(upload1);
+			doReturn(false).when(service).checkOneVirusScanCompleted(upload2);
+
+			var result = service.checkVirusScanCompleted(uploads);
+
+			assertThat(result).isFalse();
+		}
+	}
+
+	@DisplayName("check one virus scan completed")
+	@Nested
+	class TestCheckOneVirusScanCompleted {
+		private final Osi2FileUpload upload1 = Osi2FileUploadTestFactory.create();
+
+		@DisplayName("should call checkUploadSuccessful")
+		@Test
+		@SneakyThrows
+		void shouldCallCheckUploadSuccessful() {
+			service.checkOneVirusScanCompleted(upload1);
+
+			verify(postfachApiFacadeService).checkUploadSuccessful(UPLOAD_GUID);
+		}
+
+		@DisplayName("should return")
+		@ParameterizedTest
+		@ValueSource(booleans = { true, false })
+		@SneakyThrows
+		void shouldReturn(boolean value) {
+			when(postfachApiFacadeService.checkUploadSuccessful(any())).thenReturn(value);
+
+			var result = service.checkOneVirusScanCompleted(upload1);
+
+			assertThat(result).isEqualTo(value);
+		}
+
+		@DisplayName("should throw OsiRuntimeException with file metadata")
+		@Test
+		@SneakyThrows
+		void shouldThrowOsiRuntimeExceptionWithFileMetadata() {
+			var uploadException = new Osi2UploadException(QuarantineStatus.UNSAFE);
+			when(postfachApiFacadeService.checkUploadSuccessful(any())).thenThrow(uploadException);
+
+			assertThatThrownBy(() -> service.checkOneVirusScanCompleted(upload1))
+					.isInstanceOf(Osi2RuntimeException.class)
+					.hasMessageContaining(upload1.getLoggableString())
+					.hasCause(uploadException);
+		}
+	}
+
+	@DisplayName("wait for virus scan")
+	@Nested
+	class TestWaitForVirusScan {
+		private final Osi2FileUpload upload1 = Osi2FileUploadTestFactory.create();
+		private final List<Osi2FileUpload> uploads = List.of(upload1);
+
+		@DisplayName("should call waitUntil")
+		@Test
+		void shouldCallWaitUntil() {
+			doReturn(true).when(service).checkVirusScanCompleted(any());
+
+			service.waitForVirusScan(uploads);
+
+			verify(service).checkVirusScanCompleted(uploads);
+		}
+
+		@DisplayName("should call with polling interval and timeout")
+		@Test
+		void shouldCallWithPollingIntervalAndTimeout() {
+			try (MockedStatic<WaitUtil> mockedStatic = mockStatic(WaitUtil.class)) {
+				mockedStatic.when(() -> WaitUtil.waitUntil(any(), any(), any())).thenReturn(true);
+
+				service.waitForVirusScan(uploads);
+
+				mockedStatic.verify(() -> WaitUtil.waitUntil(any(), eq(POLLING_INTERVAL), eq(POLLING_TIMEOUT)));
+			}
+		}
+
+		@DisplayName("should throw if scan not completed before timeout")
+		@Test
+		void shouldThrowIfScanNotCompletedBeforeTimeout() {
+			try (MockedStatic<WaitUtil> mockedStatic = mockStatic(WaitUtil.class)) {
+				mockedStatic.when(() -> WaitUtil.waitUntil(any(), any(), any())).thenReturn(false);
+
+				assertThatThrownBy(() -> service.waitForVirusScan(uploads))
+						.isInstanceOf(Osi2RuntimeException.class);
+			}
+		}
 	}
 
 }
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2ResponseMapperTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2ResponseMapperTest.java
index 1c82d18..0bc446f 100644
--- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2ResponseMapperTest.java
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2ResponseMapperTest.java
@@ -23,6 +23,7 @@ import de.ozgcloud.nachrichten.postfach.osiv2.factory.V1ReplyMessageTestFactory;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.QuarantineStatus;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.V1ReplyBehavior;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.V1ReplyMessage;
+import lombok.SneakyThrows;
 
 class Osi2ResponseMapperTest {
 
@@ -211,6 +212,7 @@ class Osi2ResponseMapperTest {
 		@DisplayName("should return false for unfinished status")
 		@ParameterizedTest
 		@ValueSource(strings = { "Legacy", "Commited" })
+		@SneakyThrows
 		void shouldReturnFalseForUnfinishedStatus(String status) {
 			var uploadStatus = QuarantineStatus.fromValue(status);
 
@@ -221,6 +223,7 @@ class Osi2ResponseMapperTest {
 
 		@DisplayName("should return true for safe status")
 		@Test
+		@SneakyThrows
 		void shouldReturnTrueForSafeStatus() {
 			var result = mapper.isSafe(QuarantineStatus.SAFE);
 
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/PostfachApiFacadeServiceTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/PostfachApiFacadeServiceTest.java
index 06f94a9..a0e6ba9 100644
--- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/PostfachApiFacadeServiceTest.java
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/PostfachApiFacadeServiceTest.java
@@ -34,6 +34,7 @@ import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.V1ReplyMessage;
 import de.ozgcloud.nachrichten.postfach.osiv2.model.FileChunkInfo;
 import de.ozgcloud.nachrichten.postfach.osiv2.model.FileChunkResource;
 import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2FileUpload;
+import lombok.SneakyThrows;
 
 class PostfachApiFacadeServiceTest {
 
@@ -254,6 +255,7 @@ class PostfachApiFacadeServiceTest {
 		private final QuarantineStatus uploadStatus = QuarantineStatus.SAFE;
 
 		@BeforeEach
+		@SneakyThrows
 		void mock() {
 			when(osi2ResponseMapper.isSafe(any())).thenReturn(true);
 			when(quarantineApi.getUploadStatus(any())).thenReturn(uploadStatus);
@@ -261,6 +263,7 @@ class PostfachApiFacadeServiceTest {
 
 		@DisplayName("should call getUploadStatus")
 		@Test
+		@SneakyThrows
 		void shouldCallGetUploadStatus() {
 			service.checkUploadSuccessful(MESSAGE_ID_1);
 
@@ -269,6 +272,7 @@ class PostfachApiFacadeServiceTest {
 
 		@DisplayName("should call isSafe")
 		@Test
+		@SneakyThrows
 		void shouldCallIsSafe() {
 			service.checkUploadSuccessful(MESSAGE_ID_1);
 
@@ -277,6 +281,7 @@ class PostfachApiFacadeServiceTest {
 
 		@DisplayName("should return true")
 		@Test
+		@SneakyThrows
 		void shouldReturnTrue() {
 			var result = service.checkUploadSuccessful(MESSAGE_ID_1);
 
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/WaitUtilTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/WaitUtilTest.java
new file mode 100644
index 0000000..c33e3e9
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/WaitUtilTest.java
@@ -0,0 +1,81 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.transfer;
+
+import static de.ozgcloud.nachrichten.postfach.osiv2.transfer.WaitUtil.*;
+import static org.assertj.core.api.Assertions.*;
+import static org.awaitility.Awaitility.*;
+
+import java.time.Duration;
+import java.util.concurrent.CompletableFuture;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import lombok.SneakyThrows;
+
+class WaitUtilTest {
+
+	@DisplayName("wait until")
+	@Nested
+	class TestWaitUntil {
+		@DisplayName("should return true if check condition is eventually true")
+		@Test
+		void shouldReturnTrueIfCheckConditionIsEventuallyTrue() {
+			var delayFuture = createDelayedFuture(100);
+
+			var conditionFuture = CompletableFuture.supplyAsync(() -> waitForDelayedCondition(delayFuture));
+
+			await()
+					.between(Duration.ofMillis(100), Duration.ofMillis(500))
+					.until(conditionFuture::isDone);
+			assertThat(conditionFuture.getNow(false)).isTrue();
+		}
+
+		@DisplayName("should return false if check condition is not true before timeout")
+		@Test
+		void shouldReturnFalseIfCheckConditionIsNotTrueBeforeTimeout() {
+			var delayCondition = createDelayedFuture(1000);
+
+			var conditionFuture = CompletableFuture.supplyAsync(() -> waitForDelayedCondition(delayCondition));
+
+			await()
+					.between(Duration.ofMillis(500), Duration.ofMillis(1000))
+					.until(conditionFuture::isDone);
+			assertThat(conditionFuture.getNow(true)).isFalse();
+		}
+
+		@DisplayName("should rethrow runtime exceptions of condition")
+		@Test
+		void shouldRethrowRuntimeExceptionsOfCondition() {
+			var exception = new RuntimeException("test");
+
+			var conditionFuture = CompletableFuture.supplyAsync(() ->
+					waitUntil(() -> {
+						throw exception;
+					}, Duration.ofMillis(50), Duration.ofMillis(500)));
+
+			await()
+					.atMost(Duration.ofMillis(500))
+					.until(conditionFuture::isDone);
+			assertThatThrownBy(() -> conditionFuture.getNow(false))
+					.hasCause(exception);
+		}
+
+		private CompletableFuture<Boolean> createDelayedFuture(long milliseconds) {
+			return CompletableFuture.supplyAsync(() -> {
+				try {
+					Thread.sleep(milliseconds);
+					return true;
+				} catch (InterruptedException e) {
+					throw new RuntimeException(e);
+				}
+			});
+		}
+
+		@SneakyThrows
+		private boolean waitForDelayedCondition(CompletableFuture<Boolean> future) {
+			return waitUntil(future::isDone, Duration.ofMillis(50), Duration.ofMillis(500));
+		}
+	}
+
+}
\ No newline at end of file
-- 
GitLab