From e416f6c1d70ac09635733e3b031e09db9c59ba18 Mon Sep 17 00:00:00 2001
From: Jan Zickermann <jan.zickermann@dataport.de>
Date: Wed, 12 Feb 2025 17:29:19 +0100
Subject: [PATCH] OZG-4097 send-attachment: Add error handler

---
 pom.xml                                       |   5 +
 .../postfach/osiv2/OsiPostfachException.java  |   9 -
 .../osiv2/OsiPostfachRemoteService.java       |   8 +-
 .../osiv2/exception/Osi2ExceptionHandler.java |  45 +++++
 .../exception/Osi2PostfachException.java      |  10 ++
 .../osiv2/exception/Osi2RuntimeException.java |  12 ++
 .../osiv2/exception/Osi2UploadException.java  |  13 ++
 .../osiv2/transfer/Osi2QuarantineService.java |  12 +-
 .../osiv2/transfer/Osi2ResponseMapper.java    |  14 +-
 .../transfer/PostfachApiFacadeService.java    |   6 +
 .../osiv2/OsiPostfachRemoteServiceTest.java   |  49 ++++-
 .../exception/Osi2ExceptionHandlerTest.java   | 168 ++++++++++++++++++
 ...ngeReceiveMessagesResponseTestFactory.java |   2 +-
 .../transfer/Osi2QuarantineServiceTest.java   |  11 +-
 .../transfer/Osi2ResponseMapperTest.java      |  76 ++++++++
 .../PostfachApiFacadeServiceTest.java         |  40 +++++
 16 files changed, 450 insertions(+), 30 deletions(-)
 delete mode 100644 src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachException.java
 create mode 100644 src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2ExceptionHandler.java
 create mode 100644 src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2PostfachException.java
 create mode 100644 src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2RuntimeException.java
 create mode 100644 src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2UploadException.java
 create mode 100644 src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2ExceptionHandlerTest.java

diff --git a/pom.xml b/pom.xml
index 0130dea..57d54c0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -35,6 +35,11 @@
 			<artifactId>nachrichten-manager-postfach-interface</artifactId>
 			<version>${nachrichten-manager.version}</version>
 		</dependency>
+		<dependency>
+			<groupId>de.ozgcloud.nachrichten</groupId>
+			<artifactId>nachrichten-manager-server</artifactId>
+			<version>${nachrichten-manager.version}</version>
+		</dependency>
 
 		<dependency>
 			<groupId>org.springframework.boot</groupId>
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachException.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachException.java
deleted file mode 100644
index f518762..0000000
--- a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachException.java
+++ /dev/null
@@ -1,9 +0,0 @@
-package de.ozgcloud.nachrichten.postfach.osiv2;
-
-import de.ozgcloud.common.errorhandling.TechnicalException;
-
-public class OsiPostfachException extends TechnicalException {
-	public OsiPostfachException(String msg, Throwable cause) {
-		super(msg, cause);
-	}
-}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteService.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteService.java
index c0c1f70..8d34d7d 100644
--- a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteService.java
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteService.java
@@ -4,6 +4,7 @@ import java.util.stream.Stream;
 
 import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
 import de.ozgcloud.nachrichten.postfach.PostfachRemoteService;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2ExceptionHandler;
 import de.ozgcloud.nachrichten.postfach.osiv2.transfer.Osi2PostfachService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.log4j.Log4j2;
@@ -13,6 +14,7 @@ import lombok.extern.log4j.Log4j2;
 @RequiredArgsConstructor
 public class OsiPostfachRemoteService implements PostfachRemoteService {
 	private final Osi2PostfachService osi2PostfachService;
+	private final Osi2ExceptionHandler exceptionHandler;
 
 	public static final String POSTFACH_TYPE_OSI = "OSI";
 
@@ -21,7 +23,7 @@ public class OsiPostfachRemoteService implements PostfachRemoteService {
 		try {
 			osi2PostfachService.sendMessage(nachricht);
 		} catch (RuntimeException e) {
-			throw new OsiPostfachException("Failed to send message", e);
+			throw exceptionHandler.derivePostfachException(e);
 		}
 	}
 
@@ -30,7 +32,7 @@ public class OsiPostfachRemoteService implements PostfachRemoteService {
 		try {
 			return osi2PostfachService.receiveMessages();
 		} catch (RuntimeException e) {
-			throw new OsiPostfachException("Failed to get messages", e);
+			throw exceptionHandler.derivePostfachException(e);
 		}
 	}
 
@@ -39,7 +41,7 @@ public class OsiPostfachRemoteService implements PostfachRemoteService {
 		try {
 			osi2PostfachService.deleteMessage(messageId);
 		} catch (RuntimeException e) {
-			throw new OsiPostfachException("Failed to delete message", e);
+			throw exceptionHandler.derivePostfachException(e);
 		}
 	}
 
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2ExceptionHandler.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2ExceptionHandler.java
new file mode 100644
index 0000000..db9c4f0
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2ExceptionHandler.java
@@ -0,0 +1,45 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.exception;
+
+import java.io.IOException;
+
+import jakarta.annotation.Nullable;
+
+import org.springframework.web.client.RestClientResponseException;
+
+import de.ozgcloud.nachrichten.postfach.PostfachMessageCode;
+import de.ozgcloud.nachrichten.postfach.osiv2.ServiceIfOsi2Enabled;
+
+@ServiceIfOsi2Enabled
+public class Osi2ExceptionHandler {
+
+	static final String UNEXPECTED_ERROR_MESSAGE = "An unexpected error occurred. Please report this to the osiv2-postfach maintainers.";
+
+	public Osi2PostfachException derivePostfachException(RuntimeException exception) {
+		if (exception instanceof Osi2RuntimeException osi2RuntimeException) {
+			return derivePostfachExceptionFromOsi2PostfachException(osi2RuntimeException);
+		}
+		return new Osi2PostfachException(UNEXPECTED_ERROR_MESSAGE, PostfachMessageCode.PROCESS_FAILED_MESSAGE_CODE, exception);
+	}
+
+	Osi2PostfachException derivePostfachExceptionFromOsi2PostfachException(Osi2RuntimeException osi2RuntimeException) {
+		var cause = osi2RuntimeException.getCause();
+		return new Osi2PostfachException(osi2RuntimeException.getMessage(), deriveMessageCode(cause), cause);
+	}
+
+	PostfachMessageCode deriveMessageCode(@Nullable Throwable cause) {
+		if (cause instanceof RestClientResponseException restClientResponseException) {
+			return deriveMessageCodeFromRestClientResponseException(restClientResponseException);
+		}
+		// TODO KOP-3021 add message code for unsafe upload file
+		return PostfachMessageCode.PROCESS_FAILED_MESSAGE_CODE;
+	}
+
+	PostfachMessageCode deriveMessageCodeFromRestClientResponseException(RestClientResponseException restClientResponseException) {
+		var cause = restClientResponseException.getCause();
+		if (cause instanceof IOException) {
+			return PostfachMessageCode.SERVER_CONNECTION_FAILED_MESSAGE_CODE;
+		}
+		return PostfachMessageCode.PROCESS_FAILED_MESSAGE_CODE;
+	}
+
+}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2PostfachException.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2PostfachException.java
new file mode 100644
index 0000000..1fae9a0
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2PostfachException.java
@@ -0,0 +1,10 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.exception;
+
+import de.ozgcloud.nachrichten.postfach.PostfachException;
+import de.ozgcloud.nachrichten.postfach.PostfachMessageCode;
+
+public class Osi2PostfachException extends PostfachException {
+	public Osi2PostfachException(String msg, PostfachMessageCode messageCode, Throwable cause) {
+		super(msg, messageCode, cause);
+	}
+}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2RuntimeException.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2RuntimeException.java
new file mode 100644
index 0000000..6f1966d
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2RuntimeException.java
@@ -0,0 +1,12 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.exception;
+
+public class Osi2RuntimeException extends RuntimeException {
+	public Osi2RuntimeException(String msg) {
+		super(msg);
+	}
+
+	public Osi2RuntimeException(String msg, Throwable cause) {
+		super(msg, cause);
+	}
+
+}
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
new file mode 100644
index 0000000..447a048
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2UploadException.java
@@ -0,0 +1,13 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.exception;
+
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.QuarantineStatus;
+
+public class Osi2UploadException extends RuntimeException {
+	public Osi2UploadException(QuarantineStatus uploadStatus) {
+		super(uploadStatus.toString());
+	}
+
+	public boolean isUnsafe() {
+		return QuarantineStatus.fromValue(getMessage()) == QuarantineStatus.UNSAFE;
+	}
+}
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 df620a8..325c202 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
@@ -15,8 +15,8 @@ import java.util.stream.LongStream;
 import java.util.stream.Stream;
 
 import de.ozgcloud.apilib.file.OzgCloudFile;
-import de.ozgcloud.nachrichten.postfach.osiv2.OsiPostfachException;
 import de.ozgcloud.nachrichten.postfach.osiv2.ServiceIfOsi2Enabled;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2RuntimeException;
 import de.ozgcloud.nachrichten.postfach.osiv2.model.FileChunkInfo;
 import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2FileUpload;
 import de.ozgcloud.nachrichten.postfach.osiv2.storage.Osi2BinaryFileRemoteService;
@@ -51,7 +51,7 @@ public class Osi2QuarantineService {
 		try (var fileInputStream = binaryFileService.streamFileContent(file.getId().toString())) {
 			return uploadInputStreamToQuarantine(file, fileInputStream);
 		} catch (IOException e) {
-			throw new OsiPostfachException("Failed to close input stream!", e);
+			throw new Osi2RuntimeException("Failed to close input stream!", e);
 		}
 	}
 
@@ -82,7 +82,6 @@ public class Osi2QuarantineService {
 
 
 	synchronized boolean checkVirusScanCompleted(List<Osi2FileUpload> osi2FileMetadata) {
-		// TODO
 		return true;
 	}
 
@@ -90,9 +89,12 @@ public class Osi2QuarantineService {
 		try {
 			waitUntil(() -> checkVirusScanCompleted(osi2FileMetadata), POLLING_INTERVAL, POLLING_TIMEOUT);
 		} catch (ExecutionException e) {
-			throw new OsiPostfachException("Expect the scan to complete successfully!", e.getCause());
+			if (e.getCause() instanceof Osi2RuntimeException osi2RuntimeException) {
+				throw osi2RuntimeException;
+			}
+			throw new IllegalStateException("Unexpected exception Expect the scan to complete successfully!", e.getCause());
 		} catch (TimeoutException e) {
-			throw new OsiPostfachException("Expect the scan to complete after %d seconds!".formatted(POLLING_TIMEOUT.getSeconds()), 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();
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 fbd75f8..0a29139 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
@@ -16,10 +16,12 @@ import org.mapstruct.ReportingPolicy;
 import de.ozgcloud.nachrichten.postfach.PostfachAddress;
 import de.ozgcloud.nachrichten.postfach.PostfachAddressIdentifier;
 import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
-import de.ozgcloud.nachrichten.postfach.osiv2.OsiPostfachException;
 import de.ozgcloud.nachrichten.postfach.osiv2.OsiPostfachRemoteService;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2RuntimeException;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2UploadException;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.MessageExchangeReceiveMessage;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.MessageExchangeReceiveMessagesResponse;
+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.Builder;
@@ -95,7 +97,7 @@ public interface Osi2ResponseMapper {
 
 	default List<String> toMessageIds(MessageExchangeReceiveMessagesResponse response) {
 		if (response == null) {
-			throw new OsiPostfachException("Expect non empty response!", null);
+			throw new Osi2RuntimeException("Expect non empty response!", null);
 		}
 
 		return Optional.ofNullable(response.getMessages())
@@ -107,6 +109,14 @@ public interface Osi2ResponseMapper {
 				.toList();
 	}
 
+	default boolean isSafe(QuarantineStatus uploadStatus) {
+		return switch (uploadStatus) {
+			case NONE, UNSAFE, CORRUPT, MISSING -> throw new Osi2UploadException(uploadStatus);
+			case LEGACY, COMMITED -> false;
+			case SAFE -> true;
+		};
+	}
+
 	@Builder
 	@Getter
 	class StringBasedIdentifier implements PostfachAddressIdentifier {
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 bd43b7d..f40594d 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
@@ -44,6 +44,12 @@ public class PostfachApiFacadeService {
 			);
 	}
 
+	public boolean checkUploadSuccessful(final String messageId) {
+		return responseMapper.isSafe(
+				quarantineApi.getUploadStatus(UUID.fromString(messageId))
+		);
+	}
+
 	public List<String> fetchPendingMessageIds() {
 		return responseMapper.toMessageIds(
 				messageExchangeApi.receiveMessages(MAX_NUMBER_RECEIVED_MESSAGES, 0)
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceTest.java
index c21d0be..6c95d87 100644
--- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceTest.java
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceTest.java
@@ -1,9 +1,9 @@
 package de.ozgcloud.nachrichten.postfach.osiv2;
 
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.MessageExchangeReceiveMessagesResponseTestFactory.*;
 import static org.assertj.core.api.Assertions.*;
 import static org.mockito.Mockito.*;
 
-import java.util.UUID;
 import java.util.stream.Stream;
 
 import org.junit.jupiter.api.DisplayName;
@@ -14,18 +14,23 @@ import org.mockito.Mock;
 import org.mockito.Spy;
 
 import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2ExceptionHandler;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2PostfachException;
 import de.ozgcloud.nachrichten.postfach.osiv2.factory.PostfachNachrichtTestFactory;
 import de.ozgcloud.nachrichten.postfach.osiv2.transfer.Osi2PostfachService;
 
 class OsiPostfachRemoteServiceTest {
 
-	@Spy
 	@InjectMocks
+	@Spy
 	private OsiPostfachRemoteService osiPostfachRemoteService;
 
 	@Mock
 	private Osi2PostfachService osi2PostfachService;
 
+	@Mock
+	private Osi2ExceptionHandler exceptionHandler;
+
 	private final PostfachNachricht nachricht1 = PostfachNachrichtTestFactory.createBuilder()
 			.subject("Nachricht 1")
 			.build();
@@ -48,11 +53,15 @@ class OsiPostfachRemoteServiceTest {
 		@DisplayName("should throw osi postfach exception on runtime exception")
 		@Test
 		void shouldThrowOsiPostfachExceptionOnRuntimeException() {
-			doThrow(new RuntimeException()).when(osi2PostfachService).sendMessage(nachricht1);
+			var runtimeException = createRuntimeException();
+			var osi2PostfachException = createOsi2PostfachException();
+			doThrow(runtimeException).when(osi2PostfachService).sendMessage(nachricht1);
+			when(exceptionHandler.derivePostfachException(runtimeException)).thenReturn(osi2PostfachException);
 
 			assertThatThrownBy(() -> osiPostfachRemoteService.sendMessage(nachricht1))
-					.isInstanceOf(OsiPostfachException.class);
+					.isEqualTo(osi2PostfachException);
 		}
+
 	}
 
 	@DisplayName("get all messages")
@@ -68,6 +77,18 @@ class OsiPostfachRemoteServiceTest {
 
 			assertThat(result).containsExactly(nachricht1, nachricht2);
 		}
+
+		@DisplayName("should throw osi postfach exception on runtime exception")
+		@Test
+		void shouldThrowOsiPostfachExceptionOnRuntimeException() {
+			var runtimeException = createRuntimeException();
+			var osi2PostfachException = createOsi2PostfachException();
+			doThrow(runtimeException).when(osi2PostfachService).receiveMessages();
+			when(exceptionHandler.derivePostfachException(runtimeException)).thenReturn(osi2PostfachException);
+
+			assertThatThrownBy(() -> osiPostfachRemoteService.getAllMessages())
+					.isEqualTo(osi2PostfachException);
+		}
 	}
 
 	@DisplayName("delete message")
@@ -77,17 +98,21 @@ class OsiPostfachRemoteServiceTest {
 		@DisplayName("should call deleteMessage")
 		@Test
 		void shouldCallDeleteMessage() {
-			osi2PostfachService.deleteMessage(UUID.randomUUID().toString());
+			osiPostfachRemoteService.deleteMessage(MESSAGE_ID_1);
 
-			verify(osi2PostfachService).deleteMessage(any());
+			verify(osi2PostfachService).deleteMessage(MESSAGE_ID_1);
 		}
 
 		@DisplayName("should throw osi postfach exception on runtime exception")
 		@Test
 		void shouldThrowOsiPostfachExceptionOnRuntimeException() {
-			doThrow(new RuntimeException()).when(osi2PostfachService).deleteMessage(UUID.randomUUID().toString());
+			var runtimeException = createRuntimeException();
+			var osi2PostfachException = createOsi2PostfachException();
+			doThrow(runtimeException).when(osi2PostfachService).deleteMessage(any());
+			when(exceptionHandler.derivePostfachException(runtimeException)).thenReturn(osi2PostfachException);
 
-			assertThatThrownBy(() -> osiPostfachRemoteService.deleteMessage(anyString())).isInstanceOf(OsiPostfachException.class);
+			assertThatThrownBy(() -> osiPostfachRemoteService.deleteMessage(MESSAGE_ID_1))
+					.isEqualTo(osi2PostfachException);
 		}
 	}
 
@@ -114,4 +139,12 @@ class OsiPostfachRemoteServiceTest {
 			assertThat(result).isTrue();
 		}
 	}
+
+	private RuntimeException createRuntimeException() {
+		return new RuntimeException();
+	}
+
+	private Osi2PostfachException createOsi2PostfachException() {
+		return new Osi2PostfachException("abc", null, null);
+	}
 }
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2ExceptionHandlerTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2ExceptionHandlerTest.java
new file mode 100644
index 0000000..446c19b
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2ExceptionHandlerTest.java
@@ -0,0 +1,168 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.exception;
+
+import static de.ozgcloud.nachrichten.postfach.PostfachMessageCode.*;
+import static de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2ExceptionHandler.*;
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.io.IOException;
+
+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.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.springframework.web.client.RestClientResponseException;
+
+class Osi2ExceptionHandlerTest {
+
+	@InjectMocks
+	@Spy
+	private Osi2ExceptionHandler handler;
+
+	@DisplayName("derive postfach exception")
+	@Nested
+	class TestDerivePostfachException {
+
+		@Mock
+		Osi2RuntimeException osi2RuntimeException;
+
+		@Mock
+		Osi2PostfachException postfachException;
+
+		@Mock
+		RuntimeException exception;
+
+		@DisplayName("should derive postfach exception from osi2 postfach exception")
+		@Test
+		void shouldDerivePostfachExceptionFromOsi2PostfachException() {
+			doReturn(postfachException).when(handler).derivePostfachExceptionFromOsi2PostfachException(osi2RuntimeException);
+
+			var result = handler.derivePostfachException(osi2RuntimeException);
+
+			assertThat(result).isEqualTo(postfachException);
+		}
+
+
+		@DisplayName("should return unexpected error exception with message by default")
+		@Test
+		void shouldReturnUnexpectedErrorException() {
+			var result = handler.derivePostfachException(exception);
+
+			assertThat(result.getMessage()).startsWith(UNEXPECTED_ERROR_MESSAGE);
+		}
+
+		@DisplayName("should return unexpected error exception with cause by default")
+		@Test
+		void shouldReturnUnexpectedErrorExceptionWithCauseByDefault() {
+			var result = handler.derivePostfachException(exception);
+
+			assertThat(result.getCause()).isEqualTo(exception);
+		}
+	}
+
+	@DisplayName("derive postfach exception from osi2 postfach exception")
+	@Nested
+	class TestDerivePostfachExceptionFromOsi2PostfachException {
+
+		private Osi2RuntimeException osi2RuntimeException;
+
+		static final String TEST_MESSAGE = "test-message";
+
+		@Mock
+		RuntimeException cause;
+
+		@BeforeEach
+		void mock() {
+			osi2RuntimeException = new Osi2RuntimeException(TEST_MESSAGE, cause);
+			doReturn(PROCESS_FAILED_MESSAGE_CODE).when(handler).deriveMessageCode(cause);
+		}
+
+		@DisplayName("should return with message")
+		@Test
+		void shouldReturnWithMessage() {
+			var result = handler.derivePostfachExceptionFromOsi2PostfachException(osi2RuntimeException);
+
+			assertThat(result.getMessage()).startsWith(TEST_MESSAGE);
+		}
+
+		@DisplayName("should return with cause")
+		@Test
+		void shouldReturnWithCause() {
+			var result = handler.derivePostfachExceptionFromOsi2PostfachException(osi2RuntimeException);
+
+			assertThat(result)
+					.extracting(Exception::getCause)
+					.isEqualTo(cause);
+		}
+
+		@DisplayName("should return with message code")
+		@Test
+		void shouldReturnWithMessageCode() {
+			var result = handler.derivePostfachExceptionFromOsi2PostfachException(osi2RuntimeException);
+
+			assertThat(result)
+					.extracting(Osi2PostfachException::getMessageCode)
+					.isEqualTo(PROCESS_FAILED_MESSAGE_CODE);
+		}
+	}
+
+	@DisplayName("derive message code")
+	@Nested
+	class TestDeriveMessageCode {
+		@Mock
+		RestClientResponseException restClientResponseException;
+
+		@DisplayName("should derive message code from rest client response exception")
+		@Test
+		void shouldDeriveMessageCodeFromRestClientResponseException() {
+			doReturn(SERVER_CONNECTION_FAILED_MESSAGE_CODE).when(handler)
+					.deriveMessageCodeFromRestClientResponseException(restClientResponseException);
+
+			var result = handler.deriveMessageCode(restClientResponseException);
+
+			assertThat(result).isEqualTo(SERVER_CONNECTION_FAILED_MESSAGE_CODE);
+		}
+
+		@DisplayName("should return processed failed error code by default")
+		@Test
+		void shouldReturnProcessedFailedErrorCodeByDefault() {
+			var result = handler.deriveMessageCode(null);
+
+			assertThat(result).isEqualTo(PROCESS_FAILED_MESSAGE_CODE);
+		}
+	}
+
+	@DisplayName("derive message code from rest client response exception")
+	@Nested
+	class TestDeriveMessageCodeFromRestClientResponseException {
+		@Mock
+		IOException ioException;
+
+		@Mock
+		private RestClientResponseException restClientResponseException;
+
+		@DisplayName("should return server connection failed message code")
+		@Test
+		void shouldReturnServerConnectionFailedMessageCode() {
+			when(restClientResponseException.getCause()).thenReturn(ioException);
+
+			var result = handler.deriveMessageCodeFromRestClientResponseException(restClientResponseException);
+
+			assertThat(result).isEqualTo(SERVER_CONNECTION_FAILED_MESSAGE_CODE);
+		}
+
+		@DisplayName("should return process failed message code by default")
+		@Test
+		void shouldReturnProcessFailedMessageCodeByDefault() {
+			when(restClientResponseException.getCause()).thenReturn(null);
+
+			var result = handler.deriveMessageCodeFromRestClientResponseException(restClientResponseException);
+
+			assertThat(result).isEqualTo(PROCESS_FAILED_MESSAGE_CODE);
+		}
+	}
+
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/MessageExchangeReceiveMessagesResponseTestFactory.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/MessageExchangeReceiveMessagesResponseTestFactory.java
index a104cfc..70525c8 100644
--- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/MessageExchangeReceiveMessagesResponseTestFactory.java
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/MessageExchangeReceiveMessagesResponseTestFactory.java
@@ -14,7 +14,7 @@ public class MessageExchangeReceiveMessagesResponseTestFactory {
 		return new MessageExchangeReceiveMessagesResponse()
 				.messages(Arrays.stream(uuids)
 						.map(uuid -> MessageExchangeReceiveMessageTestFactory.create()
-								.guid(UUID.fromString(uuid)))
+								.guid(uuid == null ? null : UUID.fromString(uuid)))
 						.toList());
 	}
 
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 811d5e8..df713f3 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
@@ -20,7 +20,8 @@ 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.OsiPostfachException;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2PostfachException;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2RuntimeException;
 import de.ozgcloud.nachrichten.postfach.osiv2.model.FileChunkInfo;
 import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2FileUpload;
 import de.ozgcloud.nachrichten.postfach.osiv2.storage.Osi2BinaryFileRemoteService;
@@ -178,7 +179,7 @@ class Osi2QuarantineServiceTest {
 			doThrow(new IOException("test")).when(fileInputStream).close();
 
 			assertThatThrownBy(this::uploadFileToQuarantine)
-					.isInstanceOf(OsiPostfachException.class);
+					.isInstanceOf(Osi2RuntimeException.class);
 		}
 
 		Osi2FileUpload uploadFileToQuarantine() {
@@ -214,4 +215,10 @@ class Osi2QuarantineServiceTest {
 		}
 	}
 
+	@DisplayName("check virus scan completed")
+	@Nested
+	class TestCheckVirusScanCompleted {
+
+	}
+
 }
\ 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 8806cc7..1c82d18 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
@@ -16,7 +16,11 @@ import org.mapstruct.factory.Mappers;
 import org.mockito.InjectMocks;
 
 import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2RuntimeException;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2UploadException;
+import de.ozgcloud.nachrichten.postfach.osiv2.factory.MessageExchangeReceiveMessagesResponseTestFactory;
 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;
 
@@ -149,6 +153,78 @@ class Osi2ResponseMapperTest {
 		private PostfachNachricht doMapping() {
 			return mapper.toPostfachNachricht(message);
 		}
+	}
+
+	@DisplayName("to message ids")
+	@Nested
+	class TestToMessageIds {
+
+		@DisplayName("should throw on null response")
+		@Test
+		void shouldThrowOnNullResponse() {
+			assertThatThrownBy(() -> mapper.toMessageIds(null))
+					.isInstanceOf(Osi2RuntimeException.class);
+		}
+
+		@DisplayName("should map empty response")
+		@Test
+		void shouldMapEmptyResponse() {
+			var response = MessageExchangeReceiveMessagesResponseTestFactory.create();
+
+			var result = mapper.toMessageIds(response);
+
+			assertThat(result).isEmpty();
+		}
+
+		@DisplayName("should map message ids")
+		@Test
+		void shouldMapMessageIds() {
+			var response = MessageExchangeReceiveMessagesResponseTestFactory.create(
+					MessageExchangeReceiveMessagesResponseTestFactory.MESSAGE_ID_1,
+					null,
+					MessageExchangeReceiveMessagesResponseTestFactory.MESSAGE_ID_2
+			);
+
+			var result = mapper.toMessageIds(response);
+
+			assertThat(result).containsExactly(
+					MessageExchangeReceiveMessagesResponseTestFactory.MESSAGE_ID_1,
+					MessageExchangeReceiveMessagesResponseTestFactory.MESSAGE_ID_2
+			);
+		}
+	}
+
+	@DisplayName("is safe")
+	@Nested
+	class TestIsSafe {
+		@DisplayName("should throw for bad end status")
+		@ParameterizedTest
+		@ValueSource(strings = { "None", "Unsafe", "Corrupt", "Missing" })
+		void shouldThrowForBadEndStatus(String status) {
+			var uploadStatus = QuarantineStatus.fromValue(status);
+
+			assertThatThrownBy(() -> mapper.isSafe(uploadStatus))
+					.isInstanceOf(Osi2UploadException.class)
+					.hasMessage(status);
+		}
 
+		@DisplayName("should return false for unfinished status")
+		@ParameterizedTest
+		@ValueSource(strings = { "Legacy", "Commited" })
+		void shouldReturnFalseForUnfinishedStatus(String status) {
+			var uploadStatus = QuarantineStatus.fromValue(status);
+
+			var result = mapper.isSafe(uploadStatus);
+
+			assertThat(result).isFalse();
+		}
+
+		@DisplayName("should return true for safe status")
+		@Test
+		void shouldReturnTrueForSafeStatus() {
+			var result = mapper.isSafe(QuarantineStatus.SAFE);
+
+			assertThat(result).isTrue();
+		}
 	}
 }
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 a89ecc7..2ce7a47 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
@@ -29,6 +29,7 @@ import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.MessageExchangeDeleteMes
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.MessageExchangeReceiveMessagesResponse;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.MessageExchangeSendMessageResponse;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.OutSendMessageRequestV2;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.QuarantineStatus;
 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;
@@ -229,6 +230,45 @@ class PostfachApiFacadeServiceTest {
 		}
 	}
 
+	@DisplayName("check upload successful")
+	@Nested
+	class TestCheckUploadSuccessful {
+
+
+		private final QuarantineStatus uploadStatus = QuarantineStatus.SAFE;
+
+		@BeforeEach
+		void mock() {
+			when(osi2ResponseMapper.isSafe(any())).thenReturn(true);
+			when(quarantineApi.getUploadStatus(any())).thenReturn(uploadStatus);
+		}
+
+		@DisplayName("should call getUploadStatus")
+		@Test
+		void shouldCallGetUploadStatus() {
+			postfachApiFacadeService.checkUploadSuccessful(MESSAGE_ID_1);
+
+			verify(quarantineApi).getUploadStatus(UUID.fromString(MESSAGE_ID_1));
+		}
+
+		@DisplayName("should call isSafe")
+		@Test
+		void shouldCallIsSafe() {
+			postfachApiFacadeService.checkUploadSuccessful(MESSAGE_ID_1);
+
+			verify(osi2ResponseMapper).isSafe(uploadStatus);
+		}
+
+		@DisplayName("should return true")
+		@Test
+		void shouldReturnTrue() {
+			var result = postfachApiFacadeService.checkUploadSuccessful(MESSAGE_ID_1);
+
+			assertThat(result).isTrue();
+		}
+
+	}
+
 	@DisplayName("Delete Message")
 	@Nested
 	class TestDeleteMessage {
-- 
GitLab