From 07b70e633df00a876f421c65bd819ec732e3c400 Mon Sep 17 00:00:00 2001 From: hitanshu310 Date: Mon, 6 Jul 2026 23:32:01 +0530 Subject: [PATCH] Hithomelabs/CFTunnels#88: Add Phase 1 tests for env round-trip, model serialization, and error handling --- .../client/PortainerApiClientTest.java | 281 ++++++++++++++++++ .../portainer/model/EnvVariableTest.java | 66 ++++ .../model/ModelSerializationTest.java | 121 ++++++++ .../portainer/model/PortainerStackTest.java | 101 +++++++ .../portainer/service/DeployServiceTest.java | 97 ++++++ 5 files changed, 666 insertions(+) create mode 100644 common/src/test/java/com/hithomelabs/common/portainer/model/EnvVariableTest.java create mode 100644 common/src/test/java/com/hithomelabs/common/portainer/model/ModelSerializationTest.java create mode 100644 common/src/test/java/com/hithomelabs/common/portainer/model/PortainerStackTest.java create mode 100644 portainer-automation/src/test/java/com/hithomelabs/portainer/service/DeployServiceTest.java diff --git a/common/src/test/java/com/hithomelabs/common/portainer/client/PortainerApiClientTest.java b/common/src/test/java/com/hithomelabs/common/portainer/client/PortainerApiClientTest.java index 1e7da28..300ed7c 100644 --- a/common/src/test/java/com/hithomelabs/common/portainer/client/PortainerApiClientTest.java +++ b/common/src/test/java/com/hithomelabs/common/portainer/client/PortainerApiClientTest.java @@ -2,11 +2,16 @@ package com.hithomelabs.common.portainer.client; import com.hithomelabs.common.portainer.client.exception.PortainerAuthenticationException; import com.hithomelabs.common.portainer.client.exception.PortainerConnectionException; +import com.hithomelabs.common.portainer.client.exception.PortainerResourceNotFoundException; +import com.hithomelabs.common.portainer.model.EnvVariable; import com.hithomelabs.common.portainer.model.PortainerAuthRequest; import com.hithomelabs.common.portainer.model.PortainerAuthResponse; +import com.hithomelabs.common.portainer.model.PortainerRedeployRequest; +import com.hithomelabs.common.portainer.model.PortainerStack; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.*; @@ -15,6 +20,7 @@ import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestTemplate; import java.nio.charset.StandardCharsets; +import java.util.List; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -220,4 +226,279 @@ class PortainerApiClientTest { // Should not throw; the client is created with an empty base URL assertDoesNotThrow(() -> clientNullUrl.authenticateWithApiKey("key")); } + + // ----------------------------------------------------------- + // getStack + // ----------------------------------------------------------- + + @Test + void getStack_success_returnsPortainerStack() { + PortainerStack expected = new PortainerStack(); + expected.setId(1L); + expected.setName("test-stack"); + ResponseEntity responseEntity = new ResponseEntity<>(expected, HttpStatus.OK); + + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(PortainerStack.class), + anyLong())) + .thenReturn(responseEntity); + + PortainerStack result = client.getStack(1L); + assertNotNull(result); + assertEquals(1L, result.getId()); + assertEquals("test-stack", result.getName()); + } + + @Test + void getStack_nullId_throwsException() { + assertThrows(com.hithomelabs.common.portainer.client.exception.PortainerDeploymentException.class, + () -> client.getStack(null)); + verifyNoInteractions(restTemplate); + } + + @Test + void getStack_http404_throwsResourceNotFoundException() { + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(PortainerStack.class), + anyLong())) + .thenThrow(HttpClientErrorException.create( + HttpStatus.NOT_FOUND, + "Not Found", + HttpHeaders.EMPTY, + "Stack not found".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8)); + + assertThrows(PortainerResourceNotFoundException.class, + () -> client.getStack(1L)); + } + + @Test + void getStack_http401_throwsAuthenticationException() { + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(PortainerStack.class), + anyLong())) + .thenThrow(HttpClientErrorException.create( + HttpStatus.UNAUTHORIZED, + "Unauthorized", + HttpHeaders.EMPTY, + "Not authenticated".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8)); + + assertThrows(PortainerAuthenticationException.class, + () -> client.getStack(1L)); + } + + @Test + void getStack_connectionError_throwsConnectionException() { + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(PortainerStack.class), + anyLong())) + .thenThrow(new ResourceAccessException("Connection refused")); + + PortainerConnectionException ex = assertThrows(PortainerConnectionException.class, + () -> client.getStack(1L)); + assertTrue(ex.getMessage().contains(BASE_URL)); + } + + @Test + void getStack_nonOkStatus_throwsResourceNotFoundException() { + ResponseEntity responseEntity = new ResponseEntity<>(null, HttpStatus.NO_CONTENT); + + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(PortainerStack.class), + anyLong())) + .thenReturn(responseEntity); + + assertThrows(PortainerResourceNotFoundException.class, + () -> client.getStack(1L)); + } + + // ----------------------------------------------------------- + // redeployGitStack + // ----------------------------------------------------------- + + @Test + void redeployGitStack_success_returnsNormally() { + ResponseEntity responseEntity = new ResponseEntity<>(HttpStatus.OK); + + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.PUT), + any(HttpEntity.class), + eq(Void.class), + anyLong(), + anyLong())) + .thenReturn(responseEntity); + + assertDoesNotThrow(() -> client.redeployGitStack(1L, 2L, true, null)); + } + + @Test + void redeployGitStack_withEnvList_containsEnvInRequestBody() { + List envList = List.of(new EnvVariable("DB_HOST", "postgres")); + ResponseEntity responseEntity = new ResponseEntity<>(HttpStatus.OK); + + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.PUT), + any(HttpEntity.class), + eq(Void.class), + anyLong(), + anyLong())) + .thenReturn(responseEntity); + + client.redeployGitStack(1L, 2L, true, envList); + + // Capture the request body to verify Env is included + ArgumentCaptor> captor = ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate).exchange( + anyString(), + eq(HttpMethod.PUT), + captor.capture(), + eq(Void.class), + anyLong(), + anyLong()); + + PortainerRedeployRequest capturedBody = captor.getValue().getBody(); + assertNotNull(capturedBody); + assertTrue(capturedBody.isPullImage()); + assertFalse(capturedBody.isPrune()); + assertNotNull(capturedBody.getEnv()); + assertEquals(1, capturedBody.getEnv().size()); + assertEquals("DB_HOST", capturedBody.getEnv().get(0).getName()); + assertEquals("postgres", capturedBody.getEnv().get(0).getValue()); + } + + @Test + void redeployGitStack_withNullEnv_requestBodyHasNullEnv() { + ResponseEntity responseEntity = new ResponseEntity<>(HttpStatus.OK); + + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.PUT), + any(HttpEntity.class), + eq(Void.class), + anyLong(), + anyLong())) + .thenReturn(responseEntity); + + client.redeployGitStack(1L, 2L, false, null); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate).exchange( + anyString(), + eq(HttpMethod.PUT), + captor.capture(), + eq(Void.class), + anyLong(), + anyLong()); + + PortainerRedeployRequest capturedBody = captor.getValue().getBody(); + assertNotNull(capturedBody); + assertFalse(capturedBody.isPullImage()); + assertFalse(capturedBody.isPrune()); + assertNull(capturedBody.getEnv()); + } + + @Test + void redeployGitStack_nullId_throwsException() { + assertThrows(com.hithomelabs.common.portainer.client.exception.PortainerDeploymentException.class, + () -> client.redeployGitStack(null, 2L, true, null)); + verifyNoInteractions(restTemplate); + } + + @Test + void redeployGitStack_nullEndpointId_throwsException() { + assertThrows(com.hithomelabs.common.portainer.client.exception.PortainerDeploymentException.class, + () -> client.redeployGitStack(1L, null, true, null)); + verifyNoInteractions(restTemplate); + } + + @Test + void redeployGitStack_http404_throwsResourceNotFoundException() { + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.PUT), + any(HttpEntity.class), + eq(Void.class), + anyLong(), + anyLong())) + .thenThrow(HttpClientErrorException.create( + HttpStatus.NOT_FOUND, + "Not Found", + HttpHeaders.EMPTY, + "Stack not found".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8)); + + assertThrows(PortainerResourceNotFoundException.class, + () -> client.redeployGitStack(1L, 2L, true, null)); + } + + @Test + void redeployGitStack_http401_throwsAuthenticationException() { + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.PUT), + any(HttpEntity.class), + eq(Void.class), + anyLong(), + anyLong())) + .thenThrow(HttpClientErrorException.create( + HttpStatus.UNAUTHORIZED, + "Unauthorized", + HttpHeaders.EMPTY, + "Not authenticated".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8)); + + assertThrows(PortainerAuthenticationException.class, + () -> client.redeployGitStack(1L, 2L, true, null)); + } + + @Test + void redeployGitStack_connectionError_throwsConnectionException() { + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.PUT), + any(HttpEntity.class), + eq(Void.class), + anyLong(), + anyLong())) + .thenThrow(new ResourceAccessException("Connection refused")); + + PortainerConnectionException ex = assertThrows(PortainerConnectionException.class, + () -> client.redeployGitStack(1L, 2L, true, null)); + assertTrue(ex.getMessage().contains(BASE_URL)); + } + + @Test + void redeployGitStack_nonOkStatus_throwsDeploymentException() { + ResponseEntity responseEntity = new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.PUT), + any(HttpEntity.class), + eq(Void.class), + anyLong(), + anyLong())) + .thenReturn(responseEntity); + + assertThrows(com.hithomelabs.common.portainer.client.exception.PortainerDeploymentException.class, + () -> client.redeployGitStack(1L, 2L, true, null)); + } } diff --git a/common/src/test/java/com/hithomelabs/common/portainer/model/EnvVariableTest.java b/common/src/test/java/com/hithomelabs/common/portainer/model/EnvVariableTest.java new file mode 100644 index 0000000..788b48d --- /dev/null +++ b/common/src/test/java/com/hithomelabs/common/portainer/model/EnvVariableTest.java @@ -0,0 +1,66 @@ +package com.hithomelabs.common.portainer.model; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class EnvVariableTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void serialize() throws JsonProcessingException { + EnvVariable env = new EnvVariable("FOO", "bar"); + String json = mapper.writeValueAsString(env); + assertEquals("{\"name\":\"FOO\",\"value\":\"bar\"}", json); + } + + @Test + void deserialize() throws JsonProcessingException { + String json = "{\"name\":\"FOO\",\"value\":\"bar\"}"; + EnvVariable env = mapper.readValue(json, EnvVariable.class); + assertEquals("FOO", env.getName()); + assertEquals("bar", env.getValue()); + } + + @Test + void nullName() throws JsonProcessingException { + EnvVariable env = new EnvVariable(null, "value"); + String json = mapper.writeValueAsString(env); + assertTrue(json.contains("\"value\":\"value\"")); + } + + @Test + void nullValue() throws JsonProcessingException { + EnvVariable env = new EnvVariable("name", null); + String json = mapper.writeValueAsString(env); + assertTrue(json.contains("\"name\":\"name\"")); + } + + @Test + void roundTripWithNulls() throws JsonProcessingException { + EnvVariable env = new EnvVariable(null, null); + String json = mapper.writeValueAsString(env); + EnvVariable deserialized = mapper.readValue(json, EnvVariable.class); + assertNull(deserialized.getName()); + assertNull(deserialized.getValue()); + } + + @Test + void constructorAndGetters() { + EnvVariable env = new EnvVariable("KEY", "val"); + assertEquals("KEY", env.getName()); + assertEquals("val", env.getValue()); + } + + @Test + void setters() { + EnvVariable env = new EnvVariable(); + env.setName("A"); + env.setValue("B"); + assertEquals("A", env.getName()); + assertEquals("B", env.getValue()); + } +} diff --git a/common/src/test/java/com/hithomelabs/common/portainer/model/ModelSerializationTest.java b/common/src/test/java/com/hithomelabs/common/portainer/model/ModelSerializationTest.java new file mode 100644 index 0000000..1dda01f --- /dev/null +++ b/common/src/test/java/com/hithomelabs/common/portainer/model/ModelSerializationTest.java @@ -0,0 +1,121 @@ +package com.hithomelabs.common.portainer.model; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Full round-trip contract test for Portainer model serialization. + *

+ * Takes a realistic Portainer API JSON response, deserializes into + * {@link PortainerStack}, extracts the env list, creates a + * {@link PortainerRedeployRequest} with it, serializes back to JSON, + * and asserts the correct structure and values. + */ +class ModelSerializationTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void fullEnvRoundTrip() throws JsonProcessingException { + // Step 1: Realistic Portainer API JSON response with Env array and ResourceControl + String portainerJson = """ + { + "Id": 42, + "Name": "my-app-stack", + "EndpointId": 2, + "Status": 1, + "Env": [ + {"name":"DB_HOST","value":"postgres.internal"}, + {"name":"DB_PORT","value":"5432"}, + {"name":"REDIS_URL","value":"redis://cache:6379"} + ], + "ResourceControl": { + "Id": 1, + "Type": 2, + "Owner": "admin" + } + } + """; + + // Step 2: Deserialize into PortainerStack + PortainerStack stack = mapper.readValue(portainerJson, PortainerStack.class); + assertNotNull(stack, "Deserialized stack must not be null"); + assertEquals(42L, stack.getId().longValue()); + assertEquals("my-app-stack", stack.getName()); + assertNotNull(stack.getResourceControl()); + assertEquals(1, stack.getResourceControl().get("Id")); + + // Step 3: Extract the env list + List envList = stack.getEnv(); + assertNotNull(envList, "Env list must not be null"); + assertEquals(3, envList.size()); + assertEquals("DB_HOST", envList.get(0).getName()); + assertEquals("postgres.internal", envList.get(0).getValue()); + assertEquals("DB_PORT", envList.get(1).getName()); + assertEquals("5432", envList.get(1).getValue()); + assertEquals("REDIS_URL", envList.get(2).getName()); + assertEquals("redis://cache:6379", envList.get(2).getValue()); + + // Step 4: Create a PortainerRedeployRequest with that env list (3-arg constructor) + PortainerRedeployRequest redeployRequest = new PortainerRedeployRequest(true, false, envList); + + // Step 5: Serialize it back to JSON + String requestJson = mapper.writeValueAsString(redeployRequest); + + // Step 6: Assert JSON contains correct key/value pairs + // Assert top-level fields + assertTrue(requestJson.contains("\"PullImage\":true"), + "JSON must contain PullImage:true"); + assertTrue(requestJson.contains("\"Prune\":false"), + "JSON must contain Prune:false"); + + // Assert the Env array is present + assertTrue(requestJson.contains("\"Env\""), + "JSON must contain Env field"); + + // Assert correct name/value casing (JsonProperty annotations) + assertTrue(requestJson.contains("\"name\":\"DB_HOST\""), + "JSON must contain name:DB_HOST"); + assertTrue(requestJson.contains("\"value\":\"postgres.internal\""), + "JSON must contain value:postgres.internal"); + assertTrue(requestJson.contains("\"name\":\"DB_PORT\""), + "JSON must contain name:DB_PORT"); + assertTrue(requestJson.contains("\"value\":\"5432\""), + "JSON must contain value:5432"); + assertTrue(requestJson.contains("\"name\":\"REDIS_URL\""), + "JSON must contain name:REDIS_URL"); + assertTrue(requestJson.contains("\"value\":\"redis://cache:6379\""), + "JSON must contain value:redis://cache:6379"); + } + + @Test + void redeployRequestWithNullEnv() throws JsonProcessingException { + // Verify that a PortainerRedeployRequest with null env serializes without NPE + PortainerRedeployRequest request = new PortainerRedeployRequest(false, true, null); + String json = mapper.writeValueAsString(request); + assertTrue(json.contains("\"PullImage\":false")); + assertTrue(json.contains("\"Prune\":true")); + // Env may be serialized as null or omitted + } + + @Test + void redeployRequestRoundTrip() throws JsonProcessingException { + // Full round-trip: serialize PortainerRedeployRequest and deserialize back + List envList = List.of(new EnvVariable("KEY", "VALUE")); + PortainerRedeployRequest original = new PortainerRedeployRequest(true, true, envList); + String json = mapper.writeValueAsString(original); + PortainerRedeployRequest deserialized = mapper.readValue(json, PortainerRedeployRequest.class); + + assertTrue(deserialized.isPullImage()); + assertTrue(deserialized.isPrune()); + assertNotNull(deserialized.getEnv()); + assertEquals(1, deserialized.getEnv().size()); + assertEquals("KEY", deserialized.getEnv().get(0).getName()); + assertEquals("VALUE", deserialized.getEnv().get(0).getValue()); + } +} diff --git a/common/src/test/java/com/hithomelabs/common/portainer/model/PortainerStackTest.java b/common/src/test/java/com/hithomelabs/common/portainer/model/PortainerStackTest.java new file mode 100644 index 0000000..d91c9af --- /dev/null +++ b/common/src/test/java/com/hithomelabs/common/portainer/model/PortainerStackTest.java @@ -0,0 +1,101 @@ +package com.hithomelabs.common.portainer.model; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class PortainerStackTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void deserializeWithEnv() throws JsonProcessingException { + String json = """ + { + "Id": 1, + "Name": "test-stack", + "Env": [{"name":"DB_HOST","value":"postgres"}] + } + """; + PortainerStack stack = mapper.readValue(json, PortainerStack.class); + assertEquals(1L, stack.getId()); + assertEquals("test-stack", stack.getName()); + assertNotNull(stack.getEnv()); + assertEquals(1, stack.getEnv().size()); + assertEquals("DB_HOST", stack.getEnv().get(0).getName()); + assertEquals("postgres", stack.getEnv().get(0).getValue()); + } + + @Test + void deserializeWithResourceControl() throws JsonProcessingException { + String json = """ + { + "Id": 1, + "ResourceControl": {"Id":1,"Type":2} + } + """; + PortainerStack stack = mapper.readValue(json, PortainerStack.class); + assertNotNull(stack.getResourceControl()); + assertEquals(1, stack.getResourceControl().get("Id")); + assertEquals(2, stack.getResourceControl().get("Type")); + } + + @Test + void deserializeWithNullResourceControl() throws JsonProcessingException { + String json = """ + { + "Id": 1, + "ResourceControl": null + } + """; + PortainerStack stack = mapper.readValue(json, PortainerStack.class); + assertNull(stack.getResourceControl()); + } + + @Test + void deserializeWithoutEnv() throws JsonProcessingException { + String json = """ + { + "Id": 1, + "Name": "test-stack" + } + """; + PortainerStack stack = mapper.readValue(json, PortainerStack.class); + // When no "Env" field is present in JSON, the field should remain null + assertNull(stack.getEnv()); + } + + @Test + void deserializeWithMultipleEnvVars() throws JsonProcessingException { + String json = """ + { + "Id": 2, + "Env": [ + {"name":"DB_HOST","value":"postgres"}, + {"name":"DB_PORT","value":"5432"}, + {"name":"REDIS_URL","value":"redis://cache:6379"} + ] + } + """; + PortainerStack stack = mapper.readValue(json, PortainerStack.class); + assertNotNull(stack.getEnv()); + assertEquals(3, stack.getEnv().size()); + assertEquals("DB_PORT", stack.getEnv().get(1).getName()); + assertEquals("redis://cache:6379", stack.getEnv().get(2).getValue()); + } + + @Test + void deserializeWithEmptyEnv() throws JsonProcessingException { + String json = """ + { + "Id": 3, + "Env": [] + } + """; + PortainerStack stack = mapper.readValue(json, PortainerStack.class); + assertNotNull(stack.getEnv()); + assertTrue(stack.getEnv().isEmpty()); + } +} diff --git a/portainer-automation/src/test/java/com/hithomelabs/portainer/service/DeployServiceTest.java b/portainer-automation/src/test/java/com/hithomelabs/portainer/service/DeployServiceTest.java new file mode 100644 index 0000000..86afee4 --- /dev/null +++ b/portainer-automation/src/test/java/com/hithomelabs/portainer/service/DeployServiceTest.java @@ -0,0 +1,97 @@ +package com.hithomelabs.portainer.service; + +import com.hithomelabs.common.portainer.client.PortainerApiClient; +import com.hithomelabs.common.portainer.client.exception.PortainerConnectionException; +import com.hithomelabs.common.portainer.client.exception.PortainerResourceNotFoundException; +import com.hithomelabs.common.portainer.model.EnvVariable; +import com.hithomelabs.common.portainer.model.PortainerStack; +import com.hithomelabs.portainer.config.PortainerAutomationProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DeployServiceTest { + + @Mock + private PortainerApiClient portainerApiClient; + + @Mock + private PortainerAutomationProperties props; + + private DeployService deployService; + + private static final Long STACK_ID = 1L; + private static final Long ENDPOINT_ID = 2L; + + @BeforeEach + void setUp() { + when(props.getEndpointId()).thenReturn(ENDPOINT_ID); + deployService = new DeployService(portainerApiClient, props); + } + + @Test + void redeploy_withEnv_callsRedeployGitStackWithSameEnv() { + List envList = List.of(new EnvVariable("DB_HOST", "postgres")); + PortainerStack stack = new PortainerStack(); + stack.setEnv(envList); + + when(portainerApiClient.getStack(STACK_ID)).thenReturn(stack); + + deployService.redeploy(STACK_ID); + + verify(portainerApiClient).getStack(STACK_ID); + verify(portainerApiClient).redeployGitStack(STACK_ID, ENDPOINT_ID, true, envList); + } + + @Test + void redeploy_withNullEnv_callsRedeployGitStackWithNull() { + PortainerStack stack = new PortainerStack(); + stack.setEnv(null); + + when(portainerApiClient.getStack(STACK_ID)).thenReturn(stack); + + deployService.redeploy(STACK_ID); + + verify(portainerApiClient).getStack(STACK_ID); + // When getEnv() returns null, null is passed to redeployGitStack — no NPE + verify(portainerApiClient).redeployGitStack(STACK_ID, ENDPOINT_ID, true, null); + } + + @Test + void redeploy_whenGetStackThrowsResourceNotFound_propagatesException() { + when(portainerApiClient.getStack(STACK_ID)) + .thenThrow(new PortainerResourceNotFoundException("Stack not found")); + + assertThrows(PortainerResourceNotFoundException.class, + () -> deployService.redeploy(STACK_ID)); + } + + @Test + void redeploy_whenGetStackThrowsConnectionError_propagatesException() { + when(portainerApiClient.getStack(STACK_ID)) + .thenThrow(new PortainerConnectionException("Connection refused")); + + assertThrows(PortainerConnectionException.class, + () -> deployService.redeploy(STACK_ID)); + } + + @Test + void redeploy_whenRedeployThrows_propagatesException() { + PortainerStack stack = new PortainerStack(); + stack.setEnv(List.of(new EnvVariable("A", "B"))); + when(portainerApiClient.getStack(STACK_ID)).thenReturn(stack); + doThrow(new PortainerConnectionException("Connection refused")) + .when(portainerApiClient).redeployGitStack(STACK_ID, ENDPOINT_ID, true, stack.getEnv()); + + assertThrows(PortainerConnectionException.class, + () -> deployService.redeploy(STACK_ID)); + } +}