diff --git a/src/main/java/dev/tomr/hcloud/HetznerCloud.java b/src/main/java/dev/tomr/hcloud/HetznerCloud.java index 386d930..a21965c 100644 --- a/src/main/java/dev/tomr/hcloud/HetznerCloud.java +++ b/src/main/java/dev/tomr/hcloud/HetznerCloud.java @@ -1,6 +1,8 @@ package dev.tomr.hcloud; +import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; import dev.tomr.hcloud.listener.ListenerManager; import dev.tomr.hcloud.resources.server.Server; import dev.tomr.hcloud.service.ServiceManager; @@ -15,7 +17,7 @@ public class HetznerCloud { protected static final Logger logger = LogManager.getLogger(); - private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final ObjectMapper objectMapper = JsonMapper.builder().configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true).build(); private static final String HETZNER_CLOUD_HOST = "https://api.hetzner.cloud/v1/"; private static HetznerCloud instance; diff --git a/src/main/java/dev/tomr/hcloud/http/HetznerCloudHttpClient.java b/src/main/java/dev/tomr/hcloud/http/HetznerCloudHttpClient.java index 0a3874a..174acea 100644 --- a/src/main/java/dev/tomr/hcloud/http/HetznerCloudHttpClient.java +++ b/src/main/java/dev/tomr/hcloud/http/HetznerCloudHttpClient.java @@ -1,6 +1,5 @@ package dev.tomr.hcloud.http; -import dev.tomr.hcloud.HetznerCloud; import dev.tomr.hcloud.http.exception.HetznerApiException; import dev.tomr.hcloud.http.model.HetznerErrorResponse; import org.slf4j.Logger; @@ -98,6 +97,7 @@ private HttpRequest createHttpRequest(String uri, RequestVerb requestVerb, Strin case GET -> builder.GET(); case POST -> builder.POST(HttpRequest.BodyPublishers.ofString(body)); case PUT -> builder.PUT(HttpRequest.BodyPublishers.ofString(body)); + case DELETE -> builder.DELETE(); }; return builder.build(); } diff --git a/src/main/java/dev/tomr/hcloud/http/RequestVerb.java b/src/main/java/dev/tomr/hcloud/http/RequestVerb.java index a9b4e23..06f064d 100644 --- a/src/main/java/dev/tomr/hcloud/http/RequestVerb.java +++ b/src/main/java/dev/tomr/hcloud/http/RequestVerb.java @@ -4,5 +4,5 @@ * Request Verb's for the HTTP Client */ public enum RequestVerb { - GET, POST, PUT + GET, POST, PUT, DELETE } diff --git a/src/main/java/dev/tomr/hcloud/http/model/Action.java b/src/main/java/dev/tomr/hcloud/http/model/Action.java new file mode 100644 index 0000000..b224ce6 --- /dev/null +++ b/src/main/java/dev/tomr/hcloud/http/model/Action.java @@ -0,0 +1,102 @@ +package dev.tomr.hcloud.http.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonRootName; +import dev.tomr.hcloud.http.HetznerJsonObject; +import dev.tomr.hcloud.http.model.enums.Status; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonRootName("action") +public class Action extends HetznerJsonObject { + + private String command; + private String finished; + private Integer id; + private Integer progress; + private String started; + private Error error; + private Status status; + private List resources; + + public Action(String command, String finished, Integer id, Integer progress, String started, Error error, Status status, List resources) { + this.command = command; + this.finished = finished; + this.id = id; + this.progress = progress; + this.started = started; + this.error = error; + this.status = status; + this.resources = resources; + } + + public Action() { + } + + public String getCommand() { + return command; + } + + public void setCommand(String command) { + this.command = command; + } + + public String getFinished() { + return finished; + } + + public void setFinished(String finished) { + this.finished = finished; + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public Integer getProgress() { + return progress; + } + + public void setProgress(Integer progress) { + this.progress = progress; + } + + public String getStarted() { + return started; + } + + public void setStarted(String started) { + this.started = started; + } + + public Error getError() { + return error; + } + + public void setError(Error error) { + this.error = error; + } + + public Status getStatus() { + return status; + } + + public void setStatus(Status status) { + this.status = status; + } + + public List getResources() { + return resources; + } + + public void setResources(List resources) { + this.resources = resources; + } +} diff --git a/src/main/java/dev/tomr/hcloud/http/model/ActionWrapper.java b/src/main/java/dev/tomr/hcloud/http/model/ActionWrapper.java new file mode 100644 index 0000000..1a418eb --- /dev/null +++ b/src/main/java/dev/tomr/hcloud/http/model/ActionWrapper.java @@ -0,0 +1,24 @@ +package dev.tomr.hcloud.http.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import dev.tomr.hcloud.http.HetznerJsonObject; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class ActionWrapper extends HetznerJsonObject { + private Action action; + + public ActionWrapper() { + } + + public ActionWrapper(Action action) { + this.action = action; + } + + public Action getAction() { + return action; + } + + public void setAction(Action action) { + this.action = action; + } +} diff --git a/src/main/java/dev/tomr/hcloud/http/model/Error.java b/src/main/java/dev/tomr/hcloud/http/model/Error.java new file mode 100644 index 0000000..6cd6f56 --- /dev/null +++ b/src/main/java/dev/tomr/hcloud/http/model/Error.java @@ -0,0 +1,32 @@ +package dev.tomr.hcloud.http.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Error { + private String code; + private String message; + + public Error(String code, String message) { + this.code = code; + this.message = message; + } + + public Error() {} + + public String getCode() { + return code; + } + public String getMessage() { + return message; + } + + public void setCode(String code) { + this.code = code; + } + public void setMessage(String message) { + this.message = message; + } +} diff --git a/src/main/java/dev/tomr/hcloud/http/model/HetznerErrorResponse.java b/src/main/java/dev/tomr/hcloud/http/model/HetznerErrorResponse.java index 09ec8cb..bf7b123 100644 --- a/src/main/java/dev/tomr/hcloud/http/model/HetznerErrorResponse.java +++ b/src/main/java/dev/tomr/hcloud/http/model/HetznerErrorResponse.java @@ -1,7 +1,5 @@ package dev.tomr.hcloud.http.model; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonInclude; import dev.tomr.hcloud.http.HetznerJsonObject; public class HetznerErrorResponse extends HetznerJsonObject { @@ -21,31 +19,4 @@ public String toString() { return "HetznerErrorResponse [code=" + error.getCode() + ", message=" + error.getMessage() + "]"; } - @JsonIgnoreProperties(ignoreUnknown = true) - @JsonInclude(JsonInclude.Include.NON_NULL) - public static class Error { - private String code; - private String message; - - public Error(String code, String message) { - this.code = code; - this.message = message; - } - - public Error() {} - - public String getCode() { - return code; - } - public String getMessage() { - return message; - } - - public void setCode(String code) { - this.code = code; - } - public void setMessage(String message) { - this.message = message; - } - } } diff --git a/src/main/java/dev/tomr/hcloud/http/model/Resource.java b/src/main/java/dev/tomr/hcloud/http/model/Resource.java new file mode 100644 index 0000000..93cd9e6 --- /dev/null +++ b/src/main/java/dev/tomr/hcloud/http/model/Resource.java @@ -0,0 +1,30 @@ +package dev.tomr.hcloud.http.model; + +public class Resource { + private Integer id; + private String type; + + public Resource(Integer id, String type) { + this.id = id; + this.type = type; + } + + public Resource() { + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } +} diff --git a/src/main/java/dev/tomr/hcloud/http/model/enums/Status.java b/src/main/java/dev/tomr/hcloud/http/model/enums/Status.java new file mode 100644 index 0000000..ea92094 --- /dev/null +++ b/src/main/java/dev/tomr/hcloud/http/model/enums/Status.java @@ -0,0 +1,5 @@ +package dev.tomr.hcloud.http.model.enums; + +public enum Status { + RUNNING, SUCCESS, ERROR +} diff --git a/src/main/java/dev/tomr/hcloud/listener/ListenerManager.java b/src/main/java/dev/tomr/hcloud/listener/ListenerManager.java index c8e2f03..216aa8d 100644 --- a/src/main/java/dev/tomr/hcloud/listener/ListenerManager.java +++ b/src/main/java/dev/tomr/hcloud/listener/ListenerManager.java @@ -11,7 +11,7 @@ private ListenerManager() { } /** - * Get's the ListenerManager Instance, creates one if it doesn't exist + * Gets the ListenerManager Instance, creates one if it doesn't exist * @return {@code ListenerManager} in use */ public static ListenerManager getInstance() { diff --git a/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java b/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java index 658fd44..e490819 100644 --- a/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java +++ b/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java @@ -19,8 +19,13 @@ public class ServerChangeListener implements PropertyChangeListener { @Override public void propertyChange(PropertyChangeEvent evt) { Server server = (Server) evt.getSource(); - logger.info("Server changed: " + evt.getPropertyName()); - logger.info("Server: " + evt.getOldValue() + " -> " + evt.getNewValue()); - HetznerCloud.getInstance().getServiceManager().getServerService().serverNameOrLabelUpdate(evt.getPropertyName(), evt.getNewValue(), server); + if (evt.getPropertyName().equals("delete")) { + logger.warn("Server delete has been called. Instructing Hetzner to delete"); + HetznerCloud.getInstance().getServiceManager().getServerService().deleteServerFromHetzner(server); + } else { + logger.info("Server changed: " + evt.getPropertyName()); + logger.info("Server: " + evt.getOldValue() + " -> " + evt.getNewValue()); + HetznerCloud.getInstance().getServiceManager().getServerService().serverNameOrLabelUpdate(evt.getPropertyName(), evt.getNewValue(), server); + } } } diff --git a/src/main/java/dev/tomr/hcloud/resources/server/Server.java b/src/main/java/dev/tomr/hcloud/resources/server/Server.java index 4f77c89..dd18eb4 100644 --- a/src/main/java/dev/tomr/hcloud/resources/server/Server.java +++ b/src/main/java/dev/tomr/hcloud/resources/server/Server.java @@ -97,6 +97,13 @@ private void setupPropertyChangeListener() { propertyChangeSupport.addPropertyChangeListener(HetznerCloud.getInstance().getListenerManager().getServerChangeListener()); } + /** + * Deletes a Server from Hetzner. Note, this is immediate and destructive. Ensure you want to delete the server before calling. + */ + public void delete() { + propertyChangeSupport.firePropertyChange("delete", null, null); + } + // These are the current setters that will send an API request (PUT /servers) when actions begin to be added, they will also likely be triggered by setters diff --git a/src/main/java/dev/tomr/hcloud/service/ServiceManager.java b/src/main/java/dev/tomr/hcloud/service/ServiceManager.java index 8ebf057..2a34769 100644 --- a/src/main/java/dev/tomr/hcloud/service/ServiceManager.java +++ b/src/main/java/dev/tomr/hcloud/service/ServiceManager.java @@ -1,5 +1,6 @@ package dev.tomr.hcloud.service; +import dev.tomr.hcloud.service.action.ActionService; import dev.tomr.hcloud.service.server.ServerService; import java.util.concurrent.ExecutorService; @@ -9,12 +10,14 @@ public class ServiceManager { private static ServiceManager instance; private final ServerService serverService; + private final ActionService actionService; private ExecutorService executor; private ServiceManager() { instance = this; this.serverService = new ServerService(this); + this.actionService = new ActionService(); } /** @@ -36,6 +39,14 @@ public ServerService getServerService() { return serverService; } + /** + * Get ActionService Instance + * @return the {@code ActionService} instance + */ + public ActionService getActionService() { + return actionService; + } + /** * Get an Executor for threaded tasks * @return The Existing or a new {@code ExecutorService} diff --git a/src/main/java/dev/tomr/hcloud/service/action/ActionService.java b/src/main/java/dev/tomr/hcloud/service/action/ActionService.java new file mode 100644 index 0000000..5026250 --- /dev/null +++ b/src/main/java/dev/tomr/hcloud/service/action/ActionService.java @@ -0,0 +1,74 @@ +package dev.tomr.hcloud.service.action; + +import dev.tomr.hcloud.HetznerCloud; +import dev.tomr.hcloud.http.HetznerCloudHttpClient; +import dev.tomr.hcloud.http.RequestVerb; +import dev.tomr.hcloud.http.model.Action; +import dev.tomr.hcloud.http.model.ActionWrapper; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +public class ActionService { + protected static final Logger logger = LogManager.getLogger(); + + private final HetznerCloudHttpClient client = HetznerCloudHttpClient.getInstance(); + + public ActionService() { + } + + public CompletableFuture waitForActionToComplete(Action action) { + List hostAndKey = HetznerCloud.getInstance().getHttpDetails(); + return CompletableFuture.supplyAsync(() -> { + ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + List> futures = new ArrayList<>(); + AtomicReference completedAction = new AtomicReference<>(); + futures.add(scheduler.schedule(createCheckCallable(action, hostAndKey), 0, TimeUnit.MILLISECONDS)); + + try { + for (int i = 0; i < futures.size(); i++) { + Future future = futures.get(i); + if (future.isDone()) { + if (future.get() != null) { + completedAction.set(future.get()); + } else { + futures.add(scheduler.schedule(createCheckCallable(action, hostAndKey), 1000L * (i + 1), TimeUnit.MILLISECONDS)); + } + } else { + i--; + } + } + } catch (Exception e) { + throw new RuntimeException(e); + } + futures.forEach((f) -> { + f.cancel(true); + }); + return completedAction.get(); + }); + } + + private Callable createCheckCallable(Action action, List hostAndKey) { + String url = String.format("%sactions/%d", hostAndKey.get(0), action.getId()); + + return () -> { + try { + Action newAction = client.sendHttpRequest(ActionWrapper.class, url, RequestVerb.GET, hostAndKey.get(1)).getAction(); + if (newAction.getError() != null) { + throw new Exception(String.format("Error from Hetzner: %s, %s", newAction.getError().getMessage(), newAction.getError().getCode())); + }else if (newAction.getProgress() == 100 && newAction.getFinished() != null) { + return newAction; + } else { + return null; + } + } catch (Exception e) { + throw new RuntimeException(e); + } + }; + } +} diff --git a/src/main/java/dev/tomr/hcloud/service/server/ServerService.java b/src/main/java/dev/tomr/hcloud/service/server/ServerService.java index dcfdb4a..4b0ef32 100644 --- a/src/main/java/dev/tomr/hcloud/service/server/ServerService.java +++ b/src/main/java/dev/tomr/hcloud/service/server/ServerService.java @@ -3,6 +3,8 @@ import dev.tomr.hcloud.HetznerCloud; import dev.tomr.hcloud.http.HetznerCloudHttpClient; import dev.tomr.hcloud.http.converter.ServerConverterUtil; +import dev.tomr.hcloud.http.model.Action; +import dev.tomr.hcloud.http.model.ActionWrapper; import dev.tomr.hcloud.http.model.ServerDTO; import dev.tomr.hcloud.http.model.ServerDTOList; import dev.tomr.hcloud.resources.server.Server; @@ -21,9 +23,9 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; -import static dev.tomr.hcloud.http.RequestVerb.GET; -import static dev.tomr.hcloud.http.RequestVerb.PUT; +import static dev.tomr.hcloud.http.RequestVerb.*; public class ServerService { protected static final Logger logger = LogManager.getLogger(); @@ -98,6 +100,33 @@ public void cancelServerNameOrLabelUpdate() { updatedFields.clear(); } + public void deleteServerFromHetzner(Server server) { + List hostAndKey = HetznerCloud.getInstance().getHttpDetails(); + String httpUrl = String.format("%sservers/%d", hostAndKey.get(0), server.getId()); + AtomicReference exceptionMsg = new AtomicReference<>(); + try { + Action action = client.sendHttpRequest(ActionWrapper.class, httpUrl, DELETE, hostAndKey.get(1)).getAction(); + CompletableFuture completedActionFuture = serviceManager.getActionService().waitForActionToComplete(action).thenApplyAsync((completedAction) -> { + if (completedAction == null) { + throw new NullPointerException(); + } + logger.info("Server confirmed deleted at {}", completedAction.getFinished()); + return completedAction; + }).exceptionally((e) -> { + logger.error("Server delete failed"); + logger.error(e.getMessage()); + exceptionMsg.set(e.getMessage()); + return null; + }); + if (completedActionFuture.get() == null) { + throw new RuntimeException(exceptionMsg.get()); + } + } catch (Exception e) { + logger.error("Failed to delete the Server"); + throw new RuntimeException(e); + } + } + private void updateAllRemoteServers() { Map newServerMap = new HashMap<>(); List hostAndKey = HetznerCloud.getInstance().getHttpDetails(); @@ -116,6 +145,11 @@ private void updateAllRemoteServers() { lastFullRefresh = Date.from(Instant.now()); } + /** + * Get a server, will refresh the cache if the cache is out of date + * @param id ID of the wanted server + * @return Server object from Hetzner + */ public Server getServer(Integer id) { verifyCacheUpToDate(); for (var entry : remoteServers.entrySet()) { diff --git a/src/test/java/dev/tomr/hcloud/component/http/HttpClientComponentTest.java b/src/test/java/dev/tomr/hcloud/component/http/HttpClientComponentTest.java index 29d8d99..bce4894 100644 --- a/src/test/java/dev/tomr/hcloud/component/http/HttpClientComponentTest.java +++ b/src/test/java/dev/tomr/hcloud/component/http/HttpClientComponentTest.java @@ -129,4 +129,19 @@ void httpClientHandles204NoContent() { assertDoesNotThrow(() -> client.sendHttpRequest(TestModel.class, HOST + "test", RequestVerb.GET, "")); } + + @Test + @DisplayName("HTTP Client can make a successful DELETE request and map to class") + public void testHttpClientAndMappingDelete() throws IOException, InterruptedException, IllegalAccessException { + wireMockExtension.stubFor(delete("/test").willReturn(ok(objectMapper.writeValueAsString(new TestModel(1, 1, "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"))))); + + HetznerCloudHttpClient client = new HetznerCloudHttpClient(); + + TestModel testModel = client.sendHttpRequest(TestModel.class, HOST + "test", RequestVerb.DELETE, ""); + + assertEquals(1, testModel.getId()); + assertEquals(1, testModel.getUserId()); + assertEquals("sunt aut facere repellat provident occaecati excepturi optio reprehenderit", testModel.getTitle()); + assertEquals("quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto", testModel.getBody()); + } } diff --git a/src/test/java/dev/tomr/hcloud/http/exception/HetznerApiExceptionTest.java b/src/test/java/dev/tomr/hcloud/http/exception/HetznerApiExceptionTest.java index 75990aa..11d4c24 100644 --- a/src/test/java/dev/tomr/hcloud/http/exception/HetznerApiExceptionTest.java +++ b/src/test/java/dev/tomr/hcloud/http/exception/HetznerApiExceptionTest.java @@ -1,5 +1,6 @@ package dev.tomr.hcloud.http.exception; +import dev.tomr.hcloud.http.model.Error; import dev.tomr.hcloud.http.model.HetznerErrorResponse; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -12,7 +13,7 @@ public class HetznerApiExceptionTest { @Test @DisplayName("Throws and outputs correct string") void throwsWithCorrectParams() { - HetznerErrorResponse.Error error = new HetznerErrorResponse.Error("code", "message"); + Error error = new Error("code", "message"); HetznerErrorResponse errorResponse = new HetznerErrorResponse(error); HetznerApiException hetznerApiException = assertThrows(HetznerApiException.class, () -> { throw new HetznerApiException(errorResponse); diff --git a/src/test/java/dev/tomr/hcloud/http/model/ActionTest.java b/src/test/java/dev/tomr/hcloud/http/model/ActionTest.java new file mode 100644 index 0000000..21265fe --- /dev/null +++ b/src/test/java/dev/tomr/hcloud/http/model/ActionTest.java @@ -0,0 +1,105 @@ +package dev.tomr.hcloud.http.model; + +import dev.tomr.hcloud.http.model.enums.Status; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ActionTest { + + private Action action; + + @BeforeEach + public void setUp() { + action = new Action(); + } + + @Test + @DisplayName("setCommand updates the command attribute") + void setCommand() { + action.setCommand("command"); + assertEquals("command", action.getCommand()); + } + + @Test + @DisplayName("setFinished updates the finished attribute") + void setFinished() { + action.setFinished("finished"); + assertEquals("finished", action.getFinished()); + } + + @Test + @DisplayName("setId updates the id attribute") + void setId() { + action.setId(1); + assertEquals(1, action.getId()); + } + + @Test + @DisplayName("setProgress updates the progress attribute") + void setProgress() { + action.setProgress(100); + assertEquals(100, action.getProgress()); + } + + @Test + @DisplayName("setStarted updates the progress attribute") + void setStarted() { + action.setStarted("started"); + assertEquals("started", action.getStarted()); + } + + @Test + @DisplayName("setError updates the error attribute") + void setError() { + Error error = new Error("code", "message"); + action.setError(error); + assertEquals(error, action.getError()); + } + + @Test + @DisplayName("setStatus updates the status attribute") + void setStatus() { + action.setStatus(Status.RUNNING); + assertEquals(Status.RUNNING, action.getStatus()); + action.setStatus(Status.ERROR); + assertEquals(Status.ERROR, action.getStatus()); + action.setStatus(Status.SUCCESS); + assertEquals(Status.SUCCESS, action.getStatus()); + } + + @Test + @DisplayName("setResources updates the resources attribute") + void setResources() { + Resource resource = new Resource(1, "type"); + action.setResources(List.of(resource)); + assertEquals(List.of(resource), action.getResources()); + } + + @Test + @DisplayName("constructor updates all attributes") + void constructor() { + Error error = new Error("code", "message"); + Resource resource = new Resource(1, "type"); + action = new Action("command", + "finished", + 1, + 100, + "started", + error, + Status.SUCCESS, + List.of(resource)); + assertEquals(List.of(resource), action.getResources()); + assertEquals("command", action.getCommand()); + assertEquals("finished", action.getFinished()); + assertEquals(Status.SUCCESS, action.getStatus()); + assertEquals(100, action.getProgress()); + assertEquals(error, action.getError()); + assertEquals(1, action.getId()); + assertEquals("finished", action.getFinished()); + } +} diff --git a/src/test/java/dev/tomr/hcloud/http/model/ActionWrapperTest.java b/src/test/java/dev/tomr/hcloud/http/model/ActionWrapperTest.java new file mode 100644 index 0000000..9763eb1 --- /dev/null +++ b/src/test/java/dev/tomr/hcloud/http/model/ActionWrapperTest.java @@ -0,0 +1,19 @@ +package dev.tomr.hcloud.http.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ActionWrapperTest { + + @Test + @DisplayName("calling setAction set's the action") + void setAction() { + ActionWrapper actionWrapper = new ActionWrapper(); + Action action = new Action(); + action.setId(1); + actionWrapper.setAction(action); + assertEquals(action, actionWrapper.getAction()); + } +} diff --git a/src/test/java/dev/tomr/hcloud/http/model/HetznerErrorResponseTest.java b/src/test/java/dev/tomr/hcloud/http/model/HetznerErrorResponseTest.java index 37dd110..af01a1b 100644 --- a/src/test/java/dev/tomr/hcloud/http/model/HetznerErrorResponseTest.java +++ b/src/test/java/dev/tomr/hcloud/http/model/HetznerErrorResponseTest.java @@ -11,14 +11,14 @@ public class HetznerErrorResponseTest { @Test @DisplayName("Constructor creates a HetznerErrorResponse") public void constructorWorksCorrectly() { - HetznerErrorResponse.Error error = new HetznerErrorResponse.Error("code", "message"); + Error error = new Error("code", "message"); assertInstanceOf(HetznerErrorResponse.class, new HetznerErrorResponse(error)); } @Test @DisplayName("Setters and Getters work on sub Error class") void errorSettersGettersWorkCorrectly() { - HetznerErrorResponse.Error error = new HetznerErrorResponse.Error("code", "message"); + Error error = new Error("code", "message"); error.setCode("newCode"); assertEquals("newCode", error.getCode()); error.setMessage("newMessage"); @@ -28,7 +28,7 @@ void errorSettersGettersWorkCorrectly() { @Test @DisplayName("getError returns as expected") void getErrorReturnsExpected() { - HetznerErrorResponse.Error error = new HetznerErrorResponse.Error("code", "message"); + Error error = new Error("code", "message"); HetznerErrorResponse errorResponse = new HetznerErrorResponse(error); assertEquals(error, errorResponse.getError()); } @@ -36,7 +36,7 @@ void getErrorReturnsExpected() { @Test @DisplayName("toString returns an expected string") void toStringWorksCorrectly() { - HetznerErrorResponse.Error error = new HetznerErrorResponse.Error("code", "message"); + Error error = new Error("code", "message"); HetznerErrorResponse errorResponse = new HetznerErrorResponse(error); assertEquals("HetznerErrorResponse [code=code, message=message]", errorResponse.toString()); } diff --git a/src/test/java/dev/tomr/hcloud/http/model/ResourceTest.java b/src/test/java/dev/tomr/hcloud/http/model/ResourceTest.java new file mode 100644 index 0000000..544ddc2 --- /dev/null +++ b/src/test/java/dev/tomr/hcloud/http/model/ResourceTest.java @@ -0,0 +1,34 @@ +package dev.tomr.hcloud.http.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ResourceTest { + + @Test + @DisplayName("setID changes ID of object") + public void setId() { + Resource resource = new Resource(); + resource.setId(1); + assertEquals(1, resource.getId()); + } + + @Test + @DisplayName("setType changes type of object") + public void setType() { + Resource resource = new Resource(); + resource.setType("type"); + assertEquals("type", resource.getType()); + } + + @Test + @DisplayName("Constructor sets attributes") + public void constructorWorks() { + Resource resource = new Resource(1, "type"); + assertEquals("type", resource.getType()); + assertEquals(1, resource.getId()); + } + +} diff --git a/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java b/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java index 7580dc0..92fd40a 100644 --- a/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java +++ b/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java @@ -196,5 +196,31 @@ void callingSetNameSendsAnEventToTheServerChangeListener() { } + @Test + @DisplayName("calling delete sends an event to the ServerChangeListener") + void callingDeleteSendsAnEventToTheServerChangeListener() { + try (MockedStatic hetznerCloud = mockStatic(HetznerCloud.class)) { + HetznerCloud hetznerCloudMock = mock(HetznerCloud.class); + ServerChangeListener scl = new ServerChangeListener(); + ServerChangeListener serverChangeListener = spy(scl); + ListenerManager listenerManager = mock(ListenerManager.class); + ServiceManager serviceManager = mock(ServiceManager.class); + ServerService serverService = mock(ServerService.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(PropertyChangeEvent.class); + + hetznerCloud.when(HetznerCloud::getInstance).thenReturn(hetznerCloudMock); + when(hetznerCloudMock.getListenerManager()).thenReturn(listenerManager); + when(hetznerCloudMock.getServiceManager()).thenReturn(serviceManager); + when(listenerManager.getServerChangeListener()).thenReturn(serverChangeListener); + when(serviceManager.getServerService()).thenReturn(serverService); + + Server server = new Server(); + server.delete(); + verify(serverChangeListener, times(1)).propertyChange(captor.capture()); + assertEquals("delete", captor.getValue().getPropertyName()); + } + + } + } \ No newline at end of file diff --git a/src/test/java/dev/tomr/hcloud/service/ServiceManagerTest.java b/src/test/java/dev/tomr/hcloud/service/ServiceManagerTest.java index ded6de5..e5399ec 100644 --- a/src/test/java/dev/tomr/hcloud/service/ServiceManagerTest.java +++ b/src/test/java/dev/tomr/hcloud/service/ServiceManagerTest.java @@ -1,5 +1,6 @@ package dev.tomr.hcloud.service; +import dev.tomr.hcloud.service.action.ActionService; import dev.tomr.hcloud.service.server.ServerService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -76,4 +77,12 @@ void callingCloseExecutorDoesNothingIfExecutorIsNull() { assertDoesNotThrow(serviceManager::closeExecutor); } + @Test + @DisplayName("Calling getActionService returns the ActionService instance") + void callingGetActionServiceReturnsTheActionServiceInstance() { + ServiceManager serviceManager = ServiceManager.getInstance(); + assertInstanceOf(ActionService.class, serviceManager.getActionService()); + assertNotNull(serviceManager.getActionService()); + } + } \ No newline at end of file diff --git a/src/test/java/dev/tomr/hcloud/service/action/ActionServiceTest.java b/src/test/java/dev/tomr/hcloud/service/action/ActionServiceTest.java new file mode 100644 index 0000000..3710741 --- /dev/null +++ b/src/test/java/dev/tomr/hcloud/service/action/ActionServiceTest.java @@ -0,0 +1,176 @@ +package dev.tomr.hcloud.service.action; + +import dev.tomr.hcloud.HetznerCloud; +import dev.tomr.hcloud.http.HetznerCloudHttpClient; +import dev.tomr.hcloud.http.RequestVerb; +import dev.tomr.hcloud.http.model.Action; +import dev.tomr.hcloud.http.model.ActionWrapper; +import dev.tomr.hcloud.http.model.Error; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.util.Date; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class ActionServiceTest { + + @Test + @DisplayName("Wait for action to complete returns a CompletableFuture with the finished action when the progress is 100 and finished has a date string") + void waitForActionToCompleteReturnsWhenProgressIs100() throws IOException, InterruptedException, IllegalAccessException { + HetznerCloud hetznerCloud = mock(HetznerCloud.class); + HetznerCloudHttpClient hetznerCloudHttpClient = mock(HetznerCloudHttpClient.class); + + try (MockedStatic hetznerCloudMockedStatic = mockStatic(HetznerCloud.class); + MockedStatic hetznerCloudHttpClientMockedStatic = mockStatic(HetznerCloudHttpClient.class)) { + + hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); + hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); + + when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); + + Action returnedAction = new Action(); + returnedAction.setProgress(100); + returnedAction.setFinished(new Date().toString()); + returnedAction.setId(1); + returnedAction.setCommand("delete_resource"); + + Action originalAction = new Action(); + originalAction.setId(1); + originalAction.setCommand("delete_resource"); + + when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString())).thenReturn(new ActionWrapper(returnedAction)); + + ActionService actionService = new ActionService(); + + CompletableFuture actionCompletableFuture = actionService.waitForActionToComplete(originalAction); + + assertEquals(1, actionCompletableFuture.join().getId()); + } + } + + @Test + @DisplayName("Wait for action to complete returns a CompletableFuture with the finished action after trying again when the action is not finished the first time") + void waitForActionToCompleteReturnsACompletableFutureWithTheFinishedActionWithRetries() throws IOException, InterruptedException, IllegalAccessException { + HetznerCloud hetznerCloud = mock(HetznerCloud.class); + HetznerCloudHttpClient hetznerCloudHttpClient = mock(HetznerCloudHttpClient.class); + + try (MockedStatic hetznerCloudMockedStatic = mockStatic(HetznerCloud.class); + MockedStatic hetznerCloudHttpClientMockedStatic = mockStatic(HetznerCloudHttpClient.class)) { + + hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); + hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); + + when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); + + Action returnedAction = new Action(); + returnedAction.setProgress(100); + returnedAction.setFinished(new Date().toString()); + returnedAction.setId(1); + returnedAction.setCommand("delete_resource"); + + Action unfinishedAction = new Action(); + unfinishedAction.setProgress(50); + unfinishedAction.setId(1); + unfinishedAction.setCommand("delete_resource"); + + Action originalAction = new Action(); + originalAction.setId(1); + originalAction.setCommand("delete_resource"); + + when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString())).thenReturn( + new ActionWrapper(unfinishedAction), + new ActionWrapper(unfinishedAction), + new ActionWrapper(returnedAction)); + + ActionService actionService = new ActionService(); + + CompletableFuture actionCompletableFuture = actionService.waitForActionToComplete(originalAction); + + assertEquals(1, actionCompletableFuture.join().getId()); + verify(hetznerCloudHttpClient, times(3)).sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString()); + } + } + + @Test + @DisplayName("Wait for action to complete returns a CompletableFuture with the finished action after the first http request is 100 on progress, but not 'finished', the second is") + void waitForActionToCompleteReturnsACompletableFutureWithTheFinishedActionWithABadReturnFirstTryGoodSecond() throws IOException, InterruptedException, IllegalAccessException { + HetznerCloud hetznerCloud = mock(HetznerCloud.class); + HetznerCloudHttpClient hetznerCloudHttpClient = mock(HetznerCloudHttpClient.class); + + try (MockedStatic hetznerCloudMockedStatic = mockStatic(HetznerCloud.class); + MockedStatic hetznerCloudHttpClientMockedStatic = mockStatic(HetznerCloudHttpClient.class)) { + + hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); + hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); + + when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); + + Action returnedAction = new Action(); + returnedAction.setProgress(100); + returnedAction.setFinished(new Date().toString()); + returnedAction.setId(1); + returnedAction.setCommand("delete_resource"); + + Action unfinishedAction = new Action(); + unfinishedAction.setProgress(100); + unfinishedAction.setId(1); + unfinishedAction.setCommand("delete_resource"); + + Action originalAction = new Action(); + originalAction.setId(1); + originalAction.setCommand("delete_resource"); + + when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString())).thenReturn( + new ActionWrapper(unfinishedAction), + new ActionWrapper(returnedAction)); + + ActionService actionService = new ActionService(); + + CompletableFuture actionCompletableFuture = actionService.waitForActionToComplete(originalAction); + + assertEquals(1, actionCompletableFuture.join().getId()); + verify(hetznerCloudHttpClient, times(2)).sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString()); + } + } + + @Test + @DisplayName("Wait for action to complete throws a Runtime exception when the Hetzner Action has the error field present") + void waitForActionToCompleteThrowsRuntimeWhenHetznerReturnsAnError() throws IOException, InterruptedException, IllegalAccessException { + HetznerCloud hetznerCloud = mock(HetznerCloud.class); + HetznerCloudHttpClient hetznerCloudHttpClient = mock(HetznerCloudHttpClient.class); + + try (MockedStatic hetznerCloudMockedStatic = mockStatic(HetznerCloud.class); + MockedStatic hetznerCloudHttpClientMockedStatic = mockStatic(HetznerCloudHttpClient.class)) { + + hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); + hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); + + when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); + + + Action errorAction = new Action(); + errorAction.setId(1); + errorAction.setCommand("delete_resource"); + errorAction.setError(new Error("HETZNER_01", "This is the error from Hetzner")); + + Action originalAction = new Action(); + originalAction.setId(1); + originalAction.setCommand("delete_resource"); + + when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString())).thenReturn(new ActionWrapper(errorAction)); + + ActionService actionService = new ActionService(); + + RuntimeException runtimeException = assertThrows(RuntimeException.class, () -> actionService.waitForActionToComplete(originalAction).join()); + + assertTrue(runtimeException.getMessage().contains("HETZNER_01")); + assertTrue(runtimeException.getMessage().contains("This is the error from Hetzner")); + } + } +} diff --git a/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java b/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java index aab886a..61bc00d 100644 --- a/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java +++ b/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java @@ -3,27 +3,29 @@ import dev.tomr.hcloud.HetznerCloud; import dev.tomr.hcloud.http.HetznerCloudHttpClient; import dev.tomr.hcloud.http.RequestVerb; +import dev.tomr.hcloud.http.model.Action; +import dev.tomr.hcloud.http.model.ActionWrapper; import dev.tomr.hcloud.http.model.ServerDTO; import dev.tomr.hcloud.http.model.ServerDTOList; import dev.tomr.hcloud.listener.ListenerManager; import dev.tomr.hcloud.resources.common.*; import dev.tomr.hcloud.resources.server.Server; import dev.tomr.hcloud.service.ServiceManager; +import dev.tomr.hcloud.service.action.ActionService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; import org.mockito.Mockito; import java.io.IOException; -import java.lang.reflect.Field; -import java.time.Clock; import java.time.Instant; import java.util.Date; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeoutException; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -542,4 +544,129 @@ void cacheIsRefreshedIfItWasLastRefreshedOver10MinutesAgo() throws IOException, } + @Test + @DisplayName("Delete Server From Hetzner calls Hetzner and tracks the action") + void deleteServerFromHetznerAndTracksTheAction() throws IOException, InterruptedException, IllegalAccessException { + HetznerCloud hetznerCloud = mock(HetznerCloud.class); + HetznerCloudHttpClient hetznerCloudHttpClient = mock(HetznerCloudHttpClient.class); + ListenerManager listenerManager = mock(ListenerManager.class); + ServiceManager serviceManager = mock(ServiceManager.class); + ActionService actionService = mock(ActionService.class); + + try (MockedStatic hetznerCloudMockedStatic = mockStatic(HetznerCloud.class); + MockedStatic hetznerCloudHttpClientMockedStatic = mockStatic(HetznerCloudHttpClient.class)) { + + Action action = new Action(); + action.setFinished(Date.from(Instant.now()).toString()); + + hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); + hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); + when(hetznerCloud.getListenerManager()).thenReturn(listenerManager); + when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); + when(serviceManager.getActionService()).thenReturn(actionService); + when(actionService.waitForActionToComplete(any(Action.class))).thenReturn(CompletableFuture.completedFuture(action)); + + when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString())).thenReturn(new ActionWrapper(action)); + + ServerService serverService = new ServerService(serviceManager); + serverService.deleteServerFromHetzner(new Server()); + + verify(hetznerCloudHttpClient, times(1)).sendHttpRequest(any(), anyString(), eq(RequestVerb.DELETE), eq("key1234")); + verify(actionService, times(1)).waitForActionToComplete(any(Action.class)); + } + } + + @Test + @DisplayName("When actionService throws, then delete Server from Hetzner also throws a Runtime exception") + void whenActionServiceThrowsDeleteServerAlsoThrows() throws IOException, InterruptedException, IllegalAccessException { + HetznerCloud hetznerCloud = mock(HetznerCloud.class); + HetznerCloudHttpClient hetznerCloudHttpClient = mock(HetznerCloudHttpClient.class); + ListenerManager listenerManager = mock(ListenerManager.class); + ServiceManager serviceManager = mock(ServiceManager.class); + ActionService actionService = mock(ActionService.class); + + try (MockedStatic hetznerCloudMockedStatic = mockStatic(HetznerCloud.class); + MockedStatic hetznerCloudHttpClientMockedStatic = mockStatic(HetznerCloudHttpClient.class)) { + + hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); + hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); + when(hetznerCloud.getListenerManager()).thenReturn(listenerManager); + when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); + when(serviceManager.getActionService()).thenReturn(actionService); + + when(actionService.waitForActionToComplete(any(Action.class))).thenReturn(CompletableFuture.failedFuture(new RuntimeException(new TimeoutException()))); + + when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString())).thenReturn(new ActionWrapper(new Action())); + + ServerService serverService = new ServerService(serviceManager); + + RuntimeException runtimeException = assertThrows(RuntimeException.class, () -> serverService.deleteServerFromHetzner(new Server())); + + verify(hetznerCloudHttpClient, times(1)).sendHttpRequest(any(), anyString(), eq(RequestVerb.DELETE), eq("key1234")); + verify(actionService, times(1)).waitForActionToComplete(any(Action.class)); + + assertTrue(runtimeException.getMessage().contains("TimeoutException")); + } + } + + @Test + @DisplayName("When Action returned from Hetzner is Null, server service throws a null pointer exception") + void whenActionReturnedFromHetznerIsNullServerServiceThrowsANullPointer() throws IOException, InterruptedException, IllegalAccessException { + HetznerCloud hetznerCloud = mock(HetznerCloud.class); + HetznerCloudHttpClient hetznerCloudHttpClient = mock(HetznerCloudHttpClient.class); + ListenerManager listenerManager = mock(ListenerManager.class); + ServiceManager serviceManager = mock(ServiceManager.class); + ActionService actionService = mock(ActionService.class); + + try (MockedStatic hetznerCloudMockedStatic = mockStatic(HetznerCloud.class); + MockedStatic hetznerCloudHttpClientMockedStatic = mockStatic(HetznerCloudHttpClient.class)) { + + hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); + hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); + when(hetznerCloud.getListenerManager()).thenReturn(listenerManager); + when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); + when(serviceManager.getActionService()).thenReturn(actionService); + + when(actionService.waitForActionToComplete(any(Action.class))).thenReturn(CompletableFuture.completedFuture(null)); + + when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString())).thenReturn(new ActionWrapper(new Action())); + + ServerService serverService = new ServerService(serviceManager); + + RuntimeException runtimeException = assertThrows(RuntimeException.class, () -> serverService.deleteServerFromHetzner(new Server())); + + verify(hetznerCloudHttpClient, times(1)).sendHttpRequest(any(), anyString(), eq(RequestVerb.DELETE), eq("key1234")); + verify(actionService, times(1)).waitForActionToComplete(any(Action.class)); + + assertTrue(runtimeException.getMessage().contains("NullPointerException")); + } + } + + @Test + @DisplayName("When httpclient throws, then delete Server From Hetzner also throws a Runtime exception") + void deleteServerFromHetznerHandlesException() throws IOException, InterruptedException, IllegalAccessException { + HetznerCloud hetznerCloud = mock(HetznerCloud.class); + HetznerCloudHttpClient hetznerCloudHttpClient = mock(HetznerCloudHttpClient.class); + ListenerManager listenerManager = mock(ListenerManager.class); + + try (MockedStatic hetznerCloudMockedStatic = mockStatic(HetznerCloud.class); + MockedStatic hetznerCloudHttpClientMockedStatic = mockStatic(HetznerCloudHttpClient.class)) { + + hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); + hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); + when(hetznerCloud.getListenerManager()).thenReturn(listenerManager); + when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); + + when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString())).thenThrow(new IOException()); + + ServerService serverService = new ServerService(); + + RuntimeException runtimeException = assertThrows(RuntimeException.class, () -> serverService.deleteServerFromHetzner(new Server())); + + verify(hetznerCloudHttpClient, times(1)).sendHttpRequest(any(), anyString(), eq(RequestVerb.DELETE), eq("key1234")); + + assertTrue(runtimeException.getMessage().contains("IOException")); + } + } + } \ No newline at end of file