diff --git a/src/main/java/de/rwth/idsg/steve/web/api/ApiControllerAdvice.java b/src/main/java/de/rwth/idsg/steve/web/api/ApiControllerAdvice.java index e26977bc4..a9d4dd800 100644 --- a/src/main/java/de/rwth/idsg/steve/web/api/ApiControllerAdvice.java +++ b/src/main/java/de/rwth/idsg/steve/web/api/ApiControllerAdvice.java @@ -30,6 +30,7 @@ import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.InitBinder; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.view.json.MappingJackson2JsonView; @@ -66,6 +67,13 @@ public ModelAndView handleResponseStatusException(HttpServletRequest req, Respon return createResponse(url, exception.getStatus(), exception.getReason()); } + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ModelAndView handleMethodArgumentTypeMismatchException(HttpServletRequest req, MethodArgumentTypeMismatchException exception) { + StringBuffer url = req.getRequestURL(); + log.error("Request: {} raised following exception.", url, exception); + return createResponse(url, HttpStatus.BAD_REQUEST, exception.getMessage()); + } + @ExceptionHandler(Exception.class) public ModelAndView handleException(HttpServletRequest req, Exception exception) { StringBuffer url = req.getRequestURL(); diff --git a/src/main/java/de/rwth/idsg/steve/web/api/OcppTagsRestController.java b/src/main/java/de/rwth/idsg/steve/web/api/OcppTagsRestController.java index 4b260282c..caa775d76 100644 --- a/src/main/java/de/rwth/idsg/steve/web/api/OcppTagsRestController.java +++ b/src/main/java/de/rwth/idsg/steve/web/api/OcppTagsRestController.java @@ -52,7 +52,7 @@ */ @Slf4j @RestController -@RequestMapping(value = "/api/v1/ocppTags", produces = MediaType.APPLICATION_JSON_VALUE) +@RequestMapping(value = "/api/v1/ocppTags", produces = MediaType.APPLICATION_JSON_UTF8_VALUE) @RequiredArgsConstructor public class OcppTagsRestController { diff --git a/src/main/java/de/rwth/idsg/steve/web/dto/OcppTagForm.java b/src/main/java/de/rwth/idsg/steve/web/dto/OcppTagForm.java index fc6f569f7..ff23a3281 100644 --- a/src/main/java/de/rwth/idsg/steve/web/dto/OcppTagForm.java +++ b/src/main/java/de/rwth/idsg/steve/web/dto/OcppTagForm.java @@ -19,6 +19,7 @@ package de.rwth.idsg.steve.web.dto; import de.rwth.idsg.steve.web.validation.IdTag; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; import lombok.ToString; @@ -35,6 +36,7 @@ @Getter @Setter @ToString +@EqualsAndHashCode public class OcppTagForm { // Internal database id diff --git a/src/test/java/de/rwth/idsg/steve/web/api/OcppTagsRestControllerTest.java b/src/test/java/de/rwth/idsg/steve/web/api/OcppTagsRestControllerTest.java new file mode 100644 index 000000000..888805dd6 --- /dev/null +++ b/src/test/java/de/rwth/idsg/steve/web/api/OcppTagsRestControllerTest.java @@ -0,0 +1,424 @@ +package de.rwth.idsg.steve.web.api; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.rwth.idsg.steve.SteveException; +import de.rwth.idsg.steve.repository.OcppTagRepository; +import de.rwth.idsg.steve.repository.dto.OcppTag; +import de.rwth.idsg.steve.service.OcppTagService; +import de.rwth.idsg.steve.utils.DateTimeUtils; +import de.rwth.idsg.steve.web.dto.OcppTagForm; +import org.joda.time.DateTime; +import org.joda.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.Collections; +import java.util.List; + +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Sevket Goekay + * @since 17.09.2022 + */ +@ExtendWith(MockitoExtension.class) +public class OcppTagsRestControllerTest { + + private static final String CONTENT_TYPE = "application/json;charset=UTF-8"; + + private final ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json().build(); + + @Mock + private OcppTagRepository ocppTagRepository; + @Mock + private OcppTagService ocppTagService; + + private MockMvc mockMvc; + + @BeforeEach + public void setup() { + mockMvc = MockMvcBuilders.standaloneSetup(new OcppTagsRestController(ocppTagRepository, ocppTagService)) + .setControllerAdvice(new ApiControllerAdvice()) + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .alwaysExpect(content().contentType(CONTENT_TYPE)) + .build(); + } + + @Test + @DisplayName("GET all: Test with empty results, expected 200") + public void test1() throws Exception { + // given + List results = Collections.emptyList(); + + // when + when(ocppTagRepository.getOverview(any())).thenReturn(results); + + // then + mockMvc.perform(get("/api/v1/ocppTags")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } + + @Test + @DisplayName("GET all: Test with one result, expected 200") + public void test2() throws Exception { + // given + List results = List.of(OcppTag.Overview.builder().ocppTagPk(96).build()); + + // when + when(ocppTagRepository.getOverview(any())).thenReturn(results); + + // then + mockMvc.perform(get("/api/v1/ocppTags")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].ocppTagPk").value("96")); + } + + @Test + @DisplayName("GET all: Downstream bean throws exception, expected 500") + public void test3() throws Exception { + // when + when(ocppTagRepository.getOverview(any())).thenThrow(new RuntimeException("failed")); + + // then + mockMvc.perform(get("/api/v1/ocppTags")) + .andExpect(status().isInternalServerError()) + .andExpectAll(errorJsonMatchers()); + } + + @Test + @DisplayName("GET all: Typo in param makes validation fail, expected 400") + public void test4() throws Exception { + mockMvc.perform(get("/api/v1/ocppTags") + .param("blocked", "FALSE1") + ) + .andExpect(status().isBadRequest()) + .andExpectAll(errorJsonMatchers()); + } + + @Test + @DisplayName("GET all: Sets all valid params, expected 200") + public void test5() throws Exception { + // given + DateTime someDate = DateTime.parse("2020-10-01T00:00Z"); + OcppTag.Overview result = OcppTag.Overview.builder() + .ocppTagPk(121) + .idTag("id-1") + .parentOcppTagPk(454) + .parentIdTag("parent-id-1") + .inTransaction(false) + .blocked(true) + .expiryDate(someDate) + .expiryDateFormatted(DateTimeUtils.humanize(someDate)) + .maxActiveTransactionCount(4) + .activeTransactionCount(0L) + .note("some note") + .build(); + + // when + when(ocppTagRepository.getOverview(any())).thenReturn(List.of(result)); + + // then + mockMvc.perform(get("/api/v1/ocppTags") + .param("ocppTagPk", String.valueOf(result.getOcppTagPk())) + .param("idTag", result.getIdTag()) + .param("parentIdTag", result.getParentIdTag()) + .param("expired", "FALSE") + .param("inTransaction", "ALL") + .param("blocked", "TRUE") + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].ocppTagPk").value("121")) + .andExpect(jsonPath("$[0].idTag").value("id-1")) + .andExpect(jsonPath("$[0].parentOcppTagPk").value("454")) + .andExpect(jsonPath("$[0].parentIdTag").value("parent-id-1")) + .andExpect(jsonPath("$[0].inTransaction").value("false")) + .andExpect(jsonPath("$[0].blocked").value("true")) + .andExpect(jsonPath("$[0].expiryDateFormatted").value("2020-10-01 at 00:00")) + .andExpect(jsonPath("$[0].expiryDate").value(someDate.getMillis())) + .andExpect(jsonPath("$[0].maxActiveTransactionCount").value("4")) + .andExpect(jsonPath("$[0].activeTransactionCount").value("0")) + .andExpect(jsonPath("$[0].note").value("some note")); + } + + @Test + @DisplayName("GET one: Wrong path variable format makes validation fail, expected 400") + public void test6() throws Exception { + mockMvc.perform(get("/api/v1/ocppTags/not-an-integer")) + .andExpect(status().isBadRequest()) + .andExpectAll(errorJsonMatchers()); + } + + @Test + @DisplayName("GET one: Entity not found, expected 404") + public void test7() throws Exception { + // when + when(ocppTagRepository.getOverview(any())).thenReturn(Collections.emptyList()); + + // then + mockMvc.perform(get("/api/v1/ocppTags/12")) + .andExpect(status().isNotFound()) + .andExpectAll(errorJsonMatchers()); + } + + @Test + @DisplayName("GET one: One entity found, expected 200") + public void test8() throws Exception { + // given + OcppTag.Overview result = OcppTag.Overview.builder().ocppTagPk(12).build(); + + // when + when(ocppTagRepository.getOverview(any())).thenReturn(List.of(result)); + + // then + mockMvc.perform(get("/api/v1/ocppTags/12")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.ocppTagPk").value("12")); + } + + @Test + @DisplayName("POST: Missing idTag makes validation fail, expected 400") + public void test9() throws Exception { + // given + OcppTagForm form = new OcppTagForm(); + form.setIdTag(null); + + // when and then + mockMvc.perform( + post("/api/v1/ocppTags") + .content(objectMapper.writeValueAsString(form)) + .contentType(CONTENT_TYPE) + ) + .andExpect(status().isBadRequest()) + .andExpectAll(errorJsonMatchers()); + + verifyNoInteractions(ocppTagRepository, ocppTagService); + } + + @Test + @DisplayName("POST: Expiry in past makes validation fail, expected 400") + public void test10() throws Exception { + // given + OcppTagForm form = new OcppTagForm(); + form.setIdTag("id-123"); + form.setExpiryDate(LocalDateTime.parse("1990-10-01T00:00")); + + // when and then + mockMvc.perform( + post("/api/v1/ocppTags") + .content(objectMapper.writeValueAsString(form)) + .contentType(CONTENT_TYPE) + ) + .andExpect(status().isBadRequest()) + .andExpectAll(errorJsonMatchers()); + + verifyNoInteractions(ocppTagRepository, ocppTagService); + } + + @Test + @DisplayName("POST: Entity created, expected 201") + public void test11() throws Exception { + // given + int ocppTagPk = 123; + + OcppTagForm form = new OcppTagForm(); + form.setIdTag("id-123"); + + OcppTag.Overview result = OcppTag.Overview.builder() + .ocppTagPk(ocppTagPk) + .idTag(form.getIdTag()) + .build(); + + // when + when(ocppTagRepository.addOcppTag(eq(form))).thenReturn(ocppTagPk); + when(ocppTagRepository.getOverview(any())).thenReturn(List.of(result)); + + // then + mockMvc.perform( + post("/api/v1/ocppTags") + .content(objectMapper.writeValueAsString(form)) + .contentType(CONTENT_TYPE) + ) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.ocppTagPk").value("123")) + .andExpect(jsonPath("$.idTag").value("id-123")); + + verify(ocppTagService).removeUnknown(anyList()); + } + + @Test + @DisplayName("PUT: Entity updated, expected 200") + public void test12() throws Exception { + // given + int ocppTagPk = 123; + + OcppTagForm form = new OcppTagForm(); + form.setOcppTagPk(ocppTagPk); + form.setIdTag("id-123"); + form.setNote("note-1"); + + OcppTag.Overview result = OcppTag.Overview.builder() + .ocppTagPk(ocppTagPk) + .idTag(form.getIdTag()) + .note(form.getNote()) + .build(); + + // when + when(ocppTagRepository.getOverview(any())).thenReturn(List.of(result)); + + // then + mockMvc.perform( + put("/api/v1/ocppTags/" + ocppTagPk) + .content(objectMapper.writeValueAsString(form)) + .contentType(CONTENT_TYPE) + ) + .andExpect(status().isOk()); + + verify(ocppTagRepository).updateOcppTag(eq(form)); + } + + @Test + @DisplayName("PUT: Missing idTag makes validation fail, expected 400") + public void test13() throws Exception { + // given + int ocppTagPk = 123; + + OcppTagForm form = new OcppTagForm(); + form.setOcppTagPk(ocppTagPk); + form.setNote("note-1"); + + // then + mockMvc.perform( + put("/api/v1/ocppTags/" + ocppTagPk) + .content(objectMapper.writeValueAsString(form)) + .contentType(CONTENT_TYPE) + ) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(ocppTagRepository, ocppTagService); + } + + @Test + @DisplayName("PUT: Downstream bean throws exception, expected 500") + public void test14() throws Exception { + // given + int ocppTagPk = 123; + + OcppTagForm form = new OcppTagForm(); + form.setIdTag("id-123"); + form.setOcppTagPk(ocppTagPk); + form.setNote("note-1"); + + // when + doThrow(new SteveException("failed")).when(ocppTagRepository).updateOcppTag(any()); + + // then + mockMvc.perform( + put("/api/v1/ocppTags/" + ocppTagPk) + .content(objectMapper.writeValueAsString(form)) + .contentType(CONTENT_TYPE) + ) + .andExpect(status().isInternalServerError()) + .andExpectAll(errorJsonMatchers()); + } + + @Test + @DisplayName("DELETE: Entity deleted, expected 200") + public void test15() throws Exception { + // given + int ocppTagPk = 123; + + OcppTag.Overview result = OcppTag.Overview.builder() + .ocppTagPk(ocppTagPk) + .idTag("id-123") + .note("note-2") + .build(); + + // when + when(ocppTagRepository.getOverview(any())).thenReturn(List.of(result)); + + // then + mockMvc.perform(delete("/api/v1/ocppTags/" + ocppTagPk)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.ocppTagPk").value("123")); + + verify(ocppTagRepository).deleteOcppTag(eq(ocppTagPk)); + } + + @Test + @DisplayName("DELETE: Downstream bean throws exception, expected 500") + public void test16() throws Exception { + // given + int ocppTagPk = 123; + + OcppTag.Overview result = OcppTag.Overview.builder() + .ocppTagPk(ocppTagPk) + .idTag("id-123") + .note("note-2") + .build(); + + // when + when(ocppTagRepository.getOverview(any())).thenReturn(List.of(result)); + doThrow(new SteveException("failed")).when(ocppTagRepository).deleteOcppTag(eq(ocppTagPk)); + + // then + mockMvc.perform(delete("/api/v1/ocppTags/" + ocppTagPk)) + .andExpect(status().isInternalServerError()) + .andExpectAll(errorJsonMatchers()); + } + + @Test + @DisplayName("DELETE: Entity not found, expected 404") + public void test17() throws Exception { + // given + int ocppTagPk = 123; + + // when + when(ocppTagRepository.getOverview(any())).thenReturn(List.of()); + + // then + mockMvc.perform(delete("/api/v1/ocppTags/" + ocppTagPk)) + .andExpect(status().isNotFound()) + .andExpectAll(errorJsonMatchers()); + + verify(ocppTagRepository, times(0)).deleteOcppTag(anyInt()); + } + + private static ResultMatcher[] errorJsonMatchers() { + return new ResultMatcher[]{ + jsonPath("$.timestamp").exists(), + jsonPath("$.status").exists(), + jsonPath("$.error").exists(), + jsonPath("$.message").exists(), + jsonPath("$.path").exists() + }; + } + +}