From c98b2808778e254d22fb608bd7bfe6c0af53f4e3 Mon Sep 17 00:00:00 2001 From: Tom Roman Date: Sat, 23 Nov 2024 18:41:29 +0000 Subject: [PATCH 1/3] feat: Setup deleting a server from hetzner - Need to setup an Action Service to track and notify when Action's are complete - Refactor Error subclass to be it's own - HTTP Client handles DELETE now --- .../hcloud/http/HetznerCloudHttpClient.java | 2 +- .../dev/tomr/hcloud/http/RequestVerb.java | 2 +- .../dev/tomr/hcloud/http/model/Action.java | 102 +++++++++++++++++ .../dev/tomr/hcloud/http/model/Error.java | 32 ++++++ .../http/model/HetznerErrorResponse.java | 29 ----- .../dev/tomr/hcloud/http/model/Resource.java | 30 +++++ .../tomr/hcloud/http/model/enums/Status.java | 5 + .../tomr/hcloud/listener/ListenerManager.java | 2 +- .../hcloud/listener/ServerChangeListener.java | 11 +- .../tomr/hcloud/resources/server/Server.java | 7 ++ .../hcloud/service/server/ServerService.java | 21 +++- .../http/HttpClientComponentTest.java | 15 +++ .../exception/HetznerApiExceptionTest.java | 3 +- .../tomr/hcloud/http/model/ActionTest.java | 105 ++++++++++++++++++ .../http/model/HetznerErrorResponseTest.java | 8 +- .../tomr/hcloud/http/model/ResourceTest.java | 34 ++++++ .../hcloud/resources/server/ServerTest.java | 26 +++++ .../service/server/ServerServiceTest.java | 52 +++++++++ 18 files changed, 444 insertions(+), 42 deletions(-) create mode 100644 src/main/java/dev/tomr/hcloud/http/model/Action.java create mode 100644 src/main/java/dev/tomr/hcloud/http/model/Error.java create mode 100644 src/main/java/dev/tomr/hcloud/http/model/Resource.java create mode 100644 src/main/java/dev/tomr/hcloud/http/model/enums/Status.java create mode 100644 src/test/java/dev/tomr/hcloud/http/model/ActionTest.java create mode 100644 src/test/java/dev/tomr/hcloud/http/model/ResourceTest.java 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/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/server/ServerService.java b/src/main/java/dev/tomr/hcloud/service/server/ServerService.java index dcfdb4a..9b42149 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,7 @@ 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.ServerDTO; import dev.tomr.hcloud.http.model.ServerDTOList; import dev.tomr.hcloud.resources.server.Server; @@ -22,8 +23,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; -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 +98,18 @@ 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()); + try { + Action action = client.sendHttpRequest(Action.class, httpUrl, DELETE, hostAndKey.get(1)); + + } catch (IOException | IllegalAccessException | InterruptedException 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 +128,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/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/server/ServerServiceTest.java b/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java index aab886a..418606a 100644 --- a/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java +++ b/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java @@ -3,6 +3,7 @@ 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.ServerDTO; import dev.tomr.hcloud.http.model.ServerDTOList; import dev.tomr.hcloud.listener.ListenerManager; @@ -542,4 +543,55 @@ 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); + + 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())).thenReturn(new Action()); + + ServerService serverService = new ServerService(); + serverService.deleteServerFromHetzner(new Server()); + + verify(hetznerCloudHttpClient, times(1)).sendHttpRequest(any(), anyString(), eq(RequestVerb.DELETE), eq("key1234")); + } + } + + @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 From 72cd701398c56c6d2cd92f734d7cac93ffab6de5 Mon Sep 17 00:00:00 2001 From: Tom Roman Date: Sat, 30 Nov 2024 23:58:01 +0000 Subject: [PATCH 2/3] feat: Add supporting action service - Checks and monitors an action completing - Needs an accompanying notification service, to let a program be told it has happened (not just logs) - Also needs tests --- .../java/dev/tomr/hcloud/HetznerCloud.java | 4 +- .../tomr/hcloud/http/model/ActionWrapper.java | 24 ++++++ .../tomr/hcloud/service/ServiceManager.java | 11 +++ .../hcloud/service/action/ActionService.java | 74 +++++++++++++++++++ .../hcloud/service/server/ServerService.java | 23 +++++- .../hcloud/service/ServiceManagerTest.java | 9 +++ .../service/action/ActionServiceTest.java | 4 + .../service/server/ServerServiceTest.java | 46 +++++++++++- 8 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 src/main/java/dev/tomr/hcloud/http/model/ActionWrapper.java create mode 100644 src/main/java/dev/tomr/hcloud/service/action/ActionService.java create mode 100644 src/test/java/dev/tomr/hcloud/service/action/ActionServiceTest.java 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/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/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..99a8c98 --- /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) { + return CompletableFuture.supplyAsync(() -> { + ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + List hostAndKey = HetznerCloud.getInstance().getHttpDetails(); + 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 9b42149..ecded7c 100644 --- a/src/main/java/dev/tomr/hcloud/service/server/ServerService.java +++ b/src/main/java/dev/tomr/hcloud/service/server/ServerService.java @@ -4,6 +4,7 @@ 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; @@ -22,6 +23,7 @@ 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.*; @@ -101,10 +103,25 @@ public void cancelServerNameOrLabelUpdate() { 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(Action.class, httpUrl, DELETE, hostAndKey.get(1)); - - } catch (IOException | IllegalAccessException | InterruptedException e) { + Action action = client.sendHttpRequest(ActionWrapper.class, httpUrl, DELETE, hostAndKey.get(1)).getAction(); + CompletableFuture completedActionFuture = serviceManager.getActionService().waitForActionToComplete(action).thenApplyAsync((completedAction) -> { + if (action == 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); } 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..783420d --- /dev/null +++ b/src/test/java/dev/tomr/hcloud/service/action/ActionServiceTest.java @@ -0,0 +1,4 @@ +package dev.tomr.hcloud.service.action; + +public class ActionServiceTest { +} 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 418606a..fe0c0fa 100644 --- a/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java +++ b/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java @@ -10,6 +10,7 @@ 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; @@ -25,6 +26,8 @@ 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.*; @@ -549,21 +552,62 @@ void deleteServerFromHetznerAndTracksTheAction() throws IOException, Interrupted 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 Action()); - ServerService serverService = new ServerService(); + 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 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")); } } From 92ebdd9cbc0963bfee68c2059d04cedcd4696138 Mon Sep 17 00:00:00 2001 From: Tom Roman Date: Wed, 1 Jan 2025 15:59:38 +0000 Subject: [PATCH 3/3] chore: Add supporting tests for Action Service - Fixed checking the wrong action for null - Move getting the HTTP details to outside the completable future --- .../hcloud/service/action/ActionService.java | 2 +- .../hcloud/service/server/ServerService.java | 2 +- .../hcloud/http/model/ActionWrapperTest.java | 19 ++ .../service/action/ActionServiceTest.java | 172 ++++++++++++++++++ .../service/server/ServerServiceTest.java | 41 ++++- 5 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 src/test/java/dev/tomr/hcloud/http/model/ActionWrapperTest.java diff --git a/src/main/java/dev/tomr/hcloud/service/action/ActionService.java b/src/main/java/dev/tomr/hcloud/service/action/ActionService.java index 99a8c98..5026250 100644 --- a/src/main/java/dev/tomr/hcloud/service/action/ActionService.java +++ b/src/main/java/dev/tomr/hcloud/service/action/ActionService.java @@ -23,9 +23,9 @@ public ActionService() { } public CompletableFuture waitForActionToComplete(Action action) { + List hostAndKey = HetznerCloud.getInstance().getHttpDetails(); return CompletableFuture.supplyAsync(() -> { ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); - List hostAndKey = HetznerCloud.getInstance().getHttpDetails(); List> futures = new ArrayList<>(); AtomicReference completedAction = new AtomicReference<>(); futures.add(scheduler.schedule(createCheckCallable(action, hostAndKey), 0, TimeUnit.MILLISECONDS)); 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 ecded7c..4b0ef32 100644 --- a/src/main/java/dev/tomr/hcloud/service/server/ServerService.java +++ b/src/main/java/dev/tomr/hcloud/service/server/ServerService.java @@ -107,7 +107,7 @@ public void deleteServerFromHetzner(Server server) { try { Action action = client.sendHttpRequest(ActionWrapper.class, httpUrl, DELETE, hostAndKey.get(1)).getAction(); CompletableFuture completedActionFuture = serviceManager.getActionService().waitForActionToComplete(action).thenApplyAsync((completedAction) -> { - if (action == null) { + if (completedAction == null) { throw new NullPointerException(); } logger.info("Server confirmed deleted at {}", completedAction.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/service/action/ActionServiceTest.java b/src/test/java/dev/tomr/hcloud/service/action/ActionServiceTest.java index 783420d..3710741 100644 --- a/src/test/java/dev/tomr/hcloud/service/action/ActionServiceTest.java +++ b/src/test/java/dev/tomr/hcloud/service/action/ActionServiceTest.java @@ -1,4 +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 fe0c0fa..61bc00d 100644 --- a/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java +++ b/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java @@ -4,6 +4,7 @@ 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; @@ -15,13 +16,10 @@ 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; @@ -568,7 +566,7 @@ void deleteServerFromHetznerAndTracksTheAction() throws IOException, Interrupted 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 Action()); + when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString())).thenReturn(new ActionWrapper(action)); ServerService serverService = new ServerService(serviceManager); serverService.deleteServerFromHetzner(new Server()); @@ -598,7 +596,7 @@ void whenActionServiceThrowsDeleteServerAlsoThrows() throws IOException, Interru when(actionService.waitForActionToComplete(any(Action.class))).thenReturn(CompletableFuture.failedFuture(new RuntimeException(new TimeoutException()))); - when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString())).thenReturn(new Action()); + when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString())).thenReturn(new ActionWrapper(new Action())); ServerService serverService = new ServerService(serviceManager); @@ -611,6 +609,39 @@ void whenActionServiceThrowsDeleteServerAlsoThrows() throws IOException, Interru } } + @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 {