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 447a048689a9b31c489b88650536a9bfe828ebad..10b9f2a97c575b102427ead2234cd3e77e244b3a 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 5c3ddb497dfa91c0bb1e468414d6428b8c5cb6a5..b5bfd23eb5310927f9032046d312feeb92cc2eff 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 ea638e32961c045f1f31b34ad4b2b6dab9f0d432..d673927df255f6e3692f3b3728677f87a2f50b84 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 0a29139847121cca2a8394506998f5cf8208605e..f64b1b1787337a5e95f309e0aea980ee6afd4b83 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 476b3c5cd7e69dd7e8bfadbd99a3f1fed49f8676..c27b95d8ee8d9bae0676ddfb80417cb2a76bc13a 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 0000000000000000000000000000000000000000..5acad335c58b9927e9970f04c4ef2634f22fb9fe
--- /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 b698bf76c476a15cf217e7c99653d347649a941e..04bef851099121b376b03d3c58aad0135f44382c 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 b6ead12402218c5fd18cf4bf5069a2208dcc18ee..8794260fc4b9339e007b949b99eccbacc6e04ba6 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 1c82d188046d48ec8014f6324ee3dbade797d75f..0bc446ff315bc6e282b3c6d57e0445a216a70ecf 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 06f94a98c1a2e106816362c0f03f9df2b0e39687..a0e6ba9488cae35fd5595ae558c5b211de9da329 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 0000000000000000000000000000000000000000..c33e3e9d55fed284c6f9cd3037c226a344d9844a
--- /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