diff --git a/layout-server/pom.xml b/layout-server/pom.xml index f617a4999..9ae3cf8e5 100644 --- a/layout-server/pom.xml +++ b/layout-server/pom.xml @@ -27,6 +27,10 @@ spring-boot-starter-test test + + org.springframework.boot + spring-boot-starter-validation + org.springframework.boot spring-boot-starter-web @@ -54,6 +58,12 @@ modelmapper 3.1.0 + + org.jetbrains + annotations + 13.0 + compile + diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/config/MappingConfig.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/config/MappingConfig.java index d45ac6fcd..855392f6c 100644 --- a/layout-server/src/main/java/org/finos/vuu/layoutserver/config/MappingConfig.java +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/config/MappingConfig.java @@ -2,7 +2,6 @@ import lombok.RequiredArgsConstructor; import org.finos.vuu.layoutserver.dto.request.LayoutRequestDTO; -import org.finos.vuu.layoutserver.dto.request.MetadataRequestDTO; import org.finos.vuu.layoutserver.dto.response.MetadataResponseDTO; import org.finos.vuu.layoutserver.model.Layout; import org.finos.vuu.layoutserver.model.Metadata; @@ -29,16 +28,6 @@ public ModelMapper modelMapper() { metadata -> layoutService.getLayoutByMetadataId(metadata.getId()), MetadataResponseDTO::setLayoutId)); - mapper.typeMap(MetadataRequestDTO.class, Metadata.class) - .addMappings(m -> m.map( - MetadataRequestDTO::getBaseMetadata, - Metadata::setBaseMetadata)); - - mapper.typeMap(Metadata.class, MetadataResponseDTO.class) - .addMappings(m -> m.map( - Metadata::getBaseMetadata, - MetadataResponseDTO::setBaseMetadata)); - return mapper; } } \ No newline at end of file diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/controller/LayoutController.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/controller/LayoutController.java index 5adee9b72..3de15fa14 100644 --- a/layout-server/src/main/java/org/finos/vuu/layoutserver/controller/LayoutController.java +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/controller/LayoutController.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.UUID; +import javax.validation.Valid; import lombok.RequiredArgsConstructor; import org.finos.vuu.layoutserver.dto.request.LayoutRequestDTO; import org.finos.vuu.layoutserver.dto.response.LayoutResponseDTO; @@ -11,6 +12,7 @@ import org.finos.vuu.layoutserver.service.MetadataService; import org.modelmapper.ModelMapper; import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -24,6 +26,7 @@ @RequiredArgsConstructor @RestController @RequestMapping("/layouts") +@Validated public class LayoutController { private final LayoutService layoutService; @@ -65,7 +68,7 @@ public List getMetadata() { */ @ResponseStatus(HttpStatus.CREATED) @PostMapping - public LayoutResponseDTO createLayout(@RequestBody LayoutRequestDTO layoutToCreate) { + public LayoutResponseDTO createLayout(@Valid @RequestBody LayoutRequestDTO layoutToCreate) { Layout layout = mapper.map(layoutToCreate, Layout.class); Layout createdLayout = layoutService.getLayout(layoutService.createLayout(layout)); @@ -81,7 +84,7 @@ public LayoutResponseDTO createLayout(@RequestBody LayoutRequestDTO layoutToCrea */ @ResponseStatus(HttpStatus.NO_CONTENT) @PutMapping("/{id}") - public void updateLayout(@PathVariable UUID id, @RequestBody LayoutRequestDTO layout) { + public void updateLayout(@PathVariable UUID id, @Valid @RequestBody LayoutRequestDTO layout) { Layout newLayout = mapper.map(layout, Layout.class); layoutService.updateLayout(id, newLayout); diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/request/LayoutRequestDTO.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/request/LayoutRequestDTO.java index 06c371450..60d50af11 100644 --- a/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/request/LayoutRequestDTO.java +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/request/LayoutRequestDTO.java @@ -1,14 +1,22 @@ package org.finos.vuu.layoutserver.dto.request; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; import lombok.Data; @Data public class LayoutRequestDTO { /** - * The definition of the layout as a string (i.e. stringified JSON structure containing components) + * The definition of the layout as a string (e.g. stringified JSON structure containing + * components) */ + @JsonProperty(value = "definition", required = true) + @NotBlank(message = "Definition must not be blank") private String definition; + @JsonProperty(value = "metadata", required = true) + @NotNull(message = "Metadata must not be null") private MetadataRequestDTO metadata; } diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/MetadataResponseDTO.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/MetadataResponseDTO.java index 8312a4758..5efa5abaa 100644 --- a/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/MetadataResponseDTO.java +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/MetadataResponseDTO.java @@ -1,7 +1,7 @@ package org.finos.vuu.layoutserver.dto.response; import com.fasterxml.jackson.annotation.JsonUnwrapped; -import java.util.Date; +import java.time.LocalDate; import java.util.UUID; import lombok.Data; import org.finos.vuu.layoutserver.model.BaseMetadata; @@ -14,6 +14,6 @@ public class MetadataResponseDTO { @JsonUnwrapped BaseMetadata baseMetadata; - private Date created; - private Date updated; + private LocalDate created; + private LocalDate updated; } diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/exceptions/GlobalExceptionHandler.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/exceptions/GlobalExceptionHandler.java new file mode 100644 index 000000000..b9838c681 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/exceptions/GlobalExceptionHandler.java @@ -0,0 +1,40 @@ +package org.finos.vuu.layoutserver.exceptions; + +import java.util.List; +import java.util.NoSuchElementException; +import java.util.stream.Collectors; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(NoSuchElementException.class) + public ResponseEntity handleNotFound(NoSuchElementException ex) { + return new ResponseEntity<>(ex.getMessage(), + org.springframework.http.HttpStatus.NOT_FOUND); + } + + @ExceptionHandler({ + HttpMessageNotReadableException.class, + MethodArgumentTypeMismatchException.class}) + public ResponseEntity handleBadRequest(Exception ex) { + return new ResponseEntity<>(ex.getMessage(), + org.springframework.http.HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException ex) { + List errors = ex.getFieldErrors() + .stream() + .map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage()) + .collect(Collectors.toList()); + + return new ResponseEntity<>(errors.toString(), + org.springframework.http.HttpStatus.BAD_REQUEST); + } +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/model/Layout.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/model/Layout.java index c83b54aa2..6251cbf2a 100644 --- a/layout-server/src/main/java/org/finos/vuu/layoutserver/model/Layout.java +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/model/Layout.java @@ -1,5 +1,6 @@ package org.finos.vuu.layoutserver.model; +import java.util.UUID; import javax.persistence.CascadeType; import javax.persistence.Column; import javax.persistence.Entity; @@ -9,7 +10,6 @@ import javax.persistence.JoinColumn; import javax.persistence.OneToOne; import lombok.Data; -import java.util.UUID; @Data @Entity diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/model/Metadata.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/model/Metadata.java index 4b56db7ac..604779f23 100644 --- a/layout-server/src/main/java/org/finos/vuu/layoutserver/model/Metadata.java +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/model/Metadata.java @@ -1,6 +1,6 @@ package org.finos.vuu.layoutserver.model; -import java.util.Date; +import java.time.LocalDate; import java.util.UUID; import javax.persistence.Column; import javax.persistence.Embedded; @@ -28,8 +28,7 @@ public class Metadata { @Embedded private BaseMetadata baseMetadata; - private Date created = new Date(); - - private Date updated; + private final LocalDate created = LocalDate.now(); + private LocalDate updated; } diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/repository/MetadataRepository.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/repository/MetadataRepository.java index 03f81b108..50cbe6288 100644 --- a/layout-server/src/main/java/org/finos/vuu/layoutserver/repository/MetadataRepository.java +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/repository/MetadataRepository.java @@ -1,10 +1,9 @@ package org.finos.vuu.layoutserver.repository; +import java.util.UUID; import org.finos.vuu.layoutserver.model.Metadata; import org.springframework.data.repository.CrudRepository; import org.springframework.stereotype.Repository; -import java.util.UUID; - @Repository public interface MetadataRepository extends CrudRepository {} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/service/LayoutService.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/service/LayoutService.java index 9e28a72c1..26dca49f5 100644 --- a/layout-server/src/main/java/org/finos/vuu/layoutserver/service/LayoutService.java +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/service/LayoutService.java @@ -1,6 +1,7 @@ package org.finos.vuu.layoutserver.service; -import java.util.Date; +import java.time.LocalDate; +import java.util.NoSuchElementException; import java.util.UUID; import lombok.RequiredArgsConstructor; import org.finos.vuu.layoutserver.model.Layout; @@ -15,7 +16,8 @@ public class LayoutService { private final LayoutRepository layoutRepository; public Layout getLayout(UUID id) { - return layoutRepository.findById(id).orElseThrow(); + return layoutRepository.findById(id) + .orElseThrow(() -> new NoSuchElementException("Layout with ID '" + id + "' not found")); } public Layout getLayoutByMetadataId(UUID id) { @@ -32,7 +34,7 @@ public void updateLayout(UUID layoutId, Layout newLayout) { Metadata updatedMetadata = Metadata.builder() .baseMetadata(newMetadata.getBaseMetadata()) - .updated(new Date()) + .updated(LocalDate.now()) .build(); layoutToUpdate.setDefinition(newLayout.getDefinition()); @@ -42,6 +44,10 @@ public void updateLayout(UUID layoutId, Layout newLayout) { } public void deleteLayout(UUID id) { - layoutRepository.deleteById(id); + try { + layoutRepository.deleteById(id); + } catch (Exception e) { + throw new NoSuchElementException("Layout with ID '" + id + "' not found"); + } } } diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/service/MetadataService.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/service/MetadataService.java index 08398edc4..de3eb095e 100644 --- a/layout-server/src/main/java/org/finos/vuu/layoutserver/service/MetadataService.java +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/service/MetadataService.java @@ -1,13 +1,12 @@ package org.finos.vuu.layoutserver.service; +import java.util.ArrayList; +import java.util.List; import lombok.RequiredArgsConstructor; import org.finos.vuu.layoutserver.model.Metadata; import org.finos.vuu.layoutserver.repository.MetadataRepository; import org.springframework.stereotype.Service; -import java.util.ArrayList; -import java.util.List; - @RequiredArgsConstructor @Service public class MetadataService { diff --git a/layout-server/src/test/java/org/finos/vuu/layoutserver/controller/LayoutControllerTest.java b/layout-server/src/test/java/org/finos/vuu/layoutserver/controller/LayoutControllerTest.java new file mode 100644 index 000000000..6c9ecb75f --- /dev/null +++ b/layout-server/src/test/java/org/finos/vuu/layoutserver/controller/LayoutControllerTest.java @@ -0,0 +1,164 @@ +package org.finos.vuu.layoutserver.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.NoSuchElementException; +import java.util.UUID; +import org.finos.vuu.layoutserver.dto.request.LayoutRequestDTO; +import org.finos.vuu.layoutserver.dto.request.MetadataRequestDTO; +import org.finos.vuu.layoutserver.dto.response.LayoutResponseDTO; +import org.finos.vuu.layoutserver.dto.response.MetadataResponseDTO; +import org.finos.vuu.layoutserver.model.BaseMetadata; +import org.finos.vuu.layoutserver.model.Layout; +import org.finos.vuu.layoutserver.model.Metadata; +import org.finos.vuu.layoutserver.service.LayoutService; +import org.finos.vuu.layoutserver.service.MetadataService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.modelmapper.ModelMapper; + +@ExtendWith(MockitoExtension.class) +class LayoutControllerTest { + + private static final String LAYOUT_DEFINITION = "Test Definition"; + private static final String LAYOUT_GROUP = "Test Group"; + private static final String LAYOUT_NAME = "Test Layout"; + private static final String LAYOUT_SCREENSHOT = "Test Screenshot"; + private static final String LAYOUT_USER = "Test User"; + private static final UUID VALID_LAYOUT_ID = UUID.randomUUID(); + private static final UUID VALID_METADATA_ID = UUID.randomUUID(); + private static final UUID DOES_NOT_EXIST_LAYOUT_ID = UUID.randomUUID(); + + @Mock + private LayoutService layoutService; + + @Mock + private MetadataService metadataService; + + @Mock + private ModelMapper modelMapper; + + @InjectMocks + private LayoutController layoutController; + + private Layout layout; + private Metadata metadata; + private BaseMetadata baseMetadata; + private LayoutRequestDTO layoutRequest; + private LayoutResponseDTO expectedLayoutResponse; + private MetadataResponseDTO metadataResponse; + + @BeforeEach + public void setup() { + baseMetadata = new BaseMetadata(); + baseMetadata.setName(LAYOUT_NAME); + baseMetadata.setUser(LAYOUT_USER); + baseMetadata.setGroup(LAYOUT_GROUP); + baseMetadata.setScreenshot(LAYOUT_SCREENSHOT); + + metadata = Metadata.builder().id(VALID_METADATA_ID).baseMetadata(baseMetadata).build(); + + layout = new Layout(); + layout.setId(VALID_LAYOUT_ID); + layout.setDefinition(LAYOUT_DEFINITION); + layout.setMetadata(metadata); + + layoutRequest = new LayoutRequestDTO(); + MetadataRequestDTO metadataRequestDTO = new MetadataRequestDTO(); + metadataRequestDTO.setBaseMetadata(baseMetadata); + layoutRequest.setDefinition(layout.getDefinition()); + layoutRequest.setMetadata(metadataRequestDTO); + + metadataResponse = getMetadataResponseDTO(); + + expectedLayoutResponse = new LayoutResponseDTO(); + expectedLayoutResponse.setId(layout.getId()); + expectedLayoutResponse.setDefinition(layout.getDefinition()); + expectedLayoutResponse.setMetadata(metadataResponse); + + } + + + @Test + void getLayout_layoutExists_returnsLayout() { + when(layoutService.getLayout(VALID_LAYOUT_ID)).thenReturn(layout); + when(modelMapper.map(layout, LayoutResponseDTO.class)).thenReturn(expectedLayoutResponse); + assertThat(layoutController.getLayout(VALID_LAYOUT_ID)).isEqualTo(expectedLayoutResponse); + } + + @Test + void getLayout_layoutDoesNotExist_throwsNoSuchElementException() { + when(layoutService.getLayout(DOES_NOT_EXIST_LAYOUT_ID)) + .thenThrow(NoSuchElementException.class); + + assertThrows(NoSuchElementException.class, + () -> layoutController.getLayout(DOES_NOT_EXIST_LAYOUT_ID)); + } + + @Test + void getMetadata_metadataExists_returnsMetadata() { + List metadataList = List.of(metadata); + + when(metadataService.getMetadata()).thenReturn(metadataList); + when(modelMapper.map(metadata, MetadataResponseDTO.class)) + .thenReturn(metadataResponse); + + assertThat(layoutController.getMetadata()).isEqualTo(List.of(metadataResponse)); + } + + @Test + void getMetadata_noMetadataExists_returnsEmptyArray() { + when(metadataService.getMetadata()).thenReturn(List.of()); + assertThat(layoutController.getMetadata()).isEmpty(); + } + + @Test + void createLayout_validLayout_returnsCreatedLayout() { + Layout layoutWithoutIds = layout; + layoutWithoutIds.setId(null); + layoutWithoutIds.getMetadata().setId(null); + + when(modelMapper.map(layoutRequest, Layout.class)).thenReturn(layoutWithoutIds); + when(layoutService.createLayout(layoutWithoutIds)).thenReturn(layout.getId()); + when(layoutService.getLayout(layout.getId())).thenReturn(layout); + when(modelMapper.map(layout, LayoutResponseDTO.class)).thenReturn(expectedLayoutResponse); + + assertThat(layoutController.createLayout(layoutRequest)).isEqualTo(expectedLayoutResponse); + } + + @Test + void updateLayout_validLayout_callsLayoutService() { + layout.setId(null); + layout.getMetadata().setId(null); + + when(modelMapper.map(layoutRequest, Layout.class)).thenReturn(layout); + + layoutController.updateLayout(VALID_LAYOUT_ID, layoutRequest); + + verify(layoutService).updateLayout(VALID_LAYOUT_ID, layout); + } + + @Test + void deleteLayout__validId_callsLayoutService() { + layoutController.deleteLayout(VALID_LAYOUT_ID); + + verify(layoutService).deleteLayout(VALID_LAYOUT_ID); + } + + private MetadataResponseDTO getMetadataResponseDTO() { + MetadataResponseDTO metadataResponse = new MetadataResponseDTO(); + metadataResponse.setLayoutId(layout.getId()); + metadataResponse.setBaseMetadata(baseMetadata); + metadataResponse.setCreated(layout.getMetadata().getCreated()); + metadataResponse.setUpdated(layout.getMetadata().getUpdated()); + return metadataResponse; + } +} diff --git a/layout-server/src/test/java/org/finos/vuu/layoutserver/integration/LayoutIntegrationTest.java b/layout-server/src/test/java/org/finos/vuu/layoutserver/integration/LayoutIntegrationTest.java new file mode 100644 index 000000000..a1689c9e4 --- /dev/null +++ b/layout-server/src/test/java/org/finos/vuu/layoutserver/integration/LayoutIntegrationTest.java @@ -0,0 +1,440 @@ +package org.finos.vuu.layoutserver.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.is; +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; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.JsonPath; +import java.util.UUID; +import org.finos.vuu.layoutserver.dto.request.LayoutRequestDTO; +import org.finos.vuu.layoutserver.dto.request.MetadataRequestDTO; +import org.finos.vuu.layoutserver.model.BaseMetadata; +import org.finos.vuu.layoutserver.model.Layout; +import org.finos.vuu.layoutserver.model.Metadata; +import org.finos.vuu.layoutserver.repository.LayoutRepository; +import org.finos.vuu.layoutserver.repository.MetadataRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +public class LayoutIntegrationTest { + + private static final String DEFAULT_LAYOUT_DEFINITION = "Default layout definition"; + private static final String DEFAULT_LAYOUT_NAME = "Default layout name"; + private static final String DEFAULT_LAYOUT_GROUP = "Default layout group"; + private static final String DEFAULT_LAYOUT_SCREENSHOT = "Default layout screenshot"; + private static final String DEFAULT_LAYOUT_USER = "Default layout user"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + private MockMvc mockMvc; + @Autowired + private LayoutRepository layoutRepository; + @Autowired + private MetadataRepository metadataRepository; + + @BeforeEach + void tearDown() { + layoutRepository.deleteAll(); + metadataRepository.deleteAll(); + } + + @Test + void getLayout_validIDAndLayoutExists_returns200WithLayout() throws Exception { + Layout layout = createDefaultLayoutInDatabase(); + assertThat(layoutRepository.findById(layout.getId()).orElseThrow()).isEqualTo(layout); + + mockMvc.perform(get("/layouts/{id}", layout.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.definition", + is(layout.getDefinition()))) + .andExpect(jsonPath("$.metadata.name", + is(layout.getMetadata().getBaseMetadata().getName()))) + .andExpect(jsonPath("$.metadata.group", + is(layout.getMetadata().getBaseMetadata().getGroup()))) + .andExpect(jsonPath("$.metadata.screenshot", + is(layout.getMetadata().getBaseMetadata().getScreenshot()))) + .andExpect(jsonPath("$.metadata.user", + is(layout.getMetadata().getBaseMetadata().getUser()))); + } + + @Test + void getLayout_validIDButLayoutDoesNotExist_returns404() throws Exception { + UUID layoutID = UUID.randomUUID(); + + mockMvc.perform(get("/layouts/{id}", layoutID)).andExpect(status().isNotFound()); + } + + @Test + void getLayout_invalidId_returns400() throws Exception { + String layoutID = "invalidUUID"; + + mockMvc.perform(get("/layouts/{id}", layoutID)) + .andExpect(status().isBadRequest()) + .andExpect(content().string( + "Failed to convert value of type 'java.lang.String' to required type 'java.util" + + ".UUID'; nested exception is java.lang.IllegalArgumentException: Invalid " + + "UUID string: invalidUUID")); + } + + @Test + void getMetadata_singleMetadataExists_returnsMetadata() throws Exception { + Layout layout = createDefaultLayoutInDatabase(); + assertThat(layoutRepository.findById(layout.getId()).orElseThrow()).isEqualTo(layout); + + mockMvc.perform(get("/layouts/metadata")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name", + is(layout.getMetadata().getBaseMetadata().getName()))) + .andExpect(jsonPath("$[0].group", + is(layout.getMetadata().getBaseMetadata().getGroup()))) + .andExpect(jsonPath("$[0].screenshot", + is(layout.getMetadata().getBaseMetadata().getScreenshot()))) + .andExpect(jsonPath("$[0].user", + is(layout.getMetadata().getBaseMetadata().getUser()))); + } + + @Test + void getMetadata_multipleMetadataExists_returnsAllMetadata() throws Exception { + Layout layout1 = createDefaultLayoutInDatabase(); + Layout layout2 = createDefaultLayoutInDatabase(); + layout2.setDefinition("Different definition"); + layout2.getMetadata().getBaseMetadata().setName("Different name"); + layout2.getMetadata().getBaseMetadata().setGroup("Different group"); + layout2.getMetadata().getBaseMetadata().setScreenshot("Different screenshot"); + layout2.getMetadata().getBaseMetadata().setUser("Different user"); + layoutRepository.save(layout2); + + assertThat(layoutRepository.findById(layout1.getId()).orElseThrow()).isEqualTo(layout1); + assertThat(layoutRepository.findById(layout2.getId()).orElseThrow()).isEqualTo(layout2); + + mockMvc.perform(get("/layouts/metadata")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name", + is(layout1.getMetadata().getBaseMetadata().getName()))) + .andExpect(jsonPath("$[0].group", + is(layout1.getMetadata().getBaseMetadata().getGroup()))) + .andExpect(jsonPath("$[0].screenshot", + is(layout1.getMetadata().getBaseMetadata().getScreenshot()))) + .andExpect(jsonPath("$[0].user", + is(layout1.getMetadata().getBaseMetadata().getUser()))) + .andExpect(jsonPath("$[1].name", + is(layout2.getMetadata().getBaseMetadata().getName()))) + .andExpect(jsonPath("$[1].group", + is(layout2.getMetadata().getBaseMetadata().getGroup()))) + .andExpect(jsonPath("$[1].screenshot", + is(layout2.getMetadata().getBaseMetadata().getScreenshot()))) + .andExpect(jsonPath("$[1].user", + is(layout2.getMetadata().getBaseMetadata().getUser()))); + } + + @Test + void getMetadata_metadataDoesNotExist_returnsEmptyList() throws Exception { + mockMvc.perform(get("/layouts/metadata")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isEmpty()); + } + + @Test + void createLayout_validRequest_returnsCreatedLayoutAndLayoutIsPersisted() + throws Exception { + LayoutRequestDTO layoutRequest = createValidLayoutRequest(); + + MvcResult result = mockMvc.perform(post("/layouts") + .content(objectMapper.writeValueAsString(layoutRequest)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.definition", is(layoutRequest.getDefinition()))) + .andExpect(jsonPath("$.metadata.name", + is(layoutRequest.getMetadata().getBaseMetadata().getName()))) + .andExpect(jsonPath("$.metadata.group", + is(layoutRequest.getMetadata().getBaseMetadata().getGroup()))) + .andExpect(jsonPath("$.metadata.screenshot", + is(layoutRequest.getMetadata().getBaseMetadata().getScreenshot()))) + .andExpect(jsonPath("$.metadata.user", + is(layoutRequest.getMetadata().getBaseMetadata().getUser()))) + .andReturn(); + + UUID createdLayoutId = UUID.fromString( + JsonPath.read(result.getResponse().getContentAsString(), "$.id")); + Layout createdLayout = layoutRepository.findById(createdLayoutId).orElseThrow(); + Metadata createdMetadata = metadataRepository.findById(createdLayout.getMetadata().getId()) + .orElseThrow(); + + // Check that the one-to-one relationship isn't causing duplicate/unexpected entries in + // the DB + assertThat(layoutRepository.findAll()).containsExactly(createdLayout); + assertThat(metadataRepository.findAll()).containsExactly(createdMetadata); + + assertThat(createdLayout.getDefinition()) + .isEqualTo(layoutRequest.getDefinition()); + assertThat(createdMetadata.getBaseMetadata().getName()) + .isEqualTo(layoutRequest.getMetadata().getBaseMetadata().getName()); + assertThat(createdMetadata.getBaseMetadata().getGroup()) + .isEqualTo(layoutRequest.getMetadata().getBaseMetadata().getGroup()); + assertThat(createdMetadata.getBaseMetadata().getScreenshot()) + .isEqualTo(layoutRequest.getMetadata().getBaseMetadata().getScreenshot()); + assertThat(createdMetadata.getBaseMetadata().getUser()) + .isEqualTo(layoutRequest.getMetadata().getBaseMetadata().getUser()); + } + + @Test + void createLayout_invalidRequestBodyDefinitionsIsBlank_returns400AndDoesNotCreateLayout() + throws Exception { + LayoutRequestDTO layoutRequest = createValidLayoutRequest(); + layoutRequest.setDefinition(""); + + mockMvc.perform(post("/layouts") + .content(objectMapper.writeValueAsString(layoutRequest)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(content().string( + "[definition: Definition must not be blank]")); + + assertThat(layoutRepository.findAll()).isEmpty(); + assertThat(metadataRepository.findAll()).isEmpty(); + } + + @Test + void createLayout_invalidRequestBodyMetadataIsNull_returns400AndDoesNotCreateLayout() + throws Exception { + LayoutRequestDTO layoutRequest = createValidLayoutRequest(); + layoutRequest.setMetadata(null); + + mockMvc.perform(post("/layouts") + .content(objectMapper.writeValueAsString(layoutRequest)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(content().string( + "[metadata: Metadata must not be null]")); + + assertThat(layoutRepository.findAll()).isEmpty(); + assertThat(metadataRepository.findAll()).isEmpty(); + } + + + @Test + void createLayout_invalidRequestBodyUnexpectedFormat_returns400() throws Exception { + String invalidLayout = "invalidLayout"; + + mockMvc.perform(post("/layouts") + .content(invalidLayout) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(content().string( + "JSON parse error: Unrecognized token 'invalidLayout': was expecting (JSON " + + "String, Number, Array, Object or token 'null', 'true' or 'false'); nested " + + "exception is com.fasterxml.jackson.core.JsonParseException: Unrecognized " + + "token 'invalidLayout': was expecting (JSON String, Number, Array, Object " + + "or token 'null', 'true' or 'false')\n" + + " at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream);" + + " line: 1, column: 14]")); + } + + @Test + void updateLayout_validIdAndValidRequest_returns204AndLayoutHasChanged() throws Exception { + Layout initialLayout = createDefaultLayoutInDatabase(); + assertThat(layoutRepository.findById(initialLayout.getId()).orElseThrow()).isEqualTo( + initialLayout); + + LayoutRequestDTO layoutRequest = createValidLayoutRequest(); + layoutRequest.setDefinition("Updated definition"); + layoutRequest.getMetadata().getBaseMetadata().setName("Updated name"); + layoutRequest.getMetadata().getBaseMetadata().setGroup("Updated group"); + layoutRequest.getMetadata().getBaseMetadata().setScreenshot("Updated screenshot"); + layoutRequest.getMetadata().getBaseMetadata().setUser("Updated user"); + + mockMvc.perform(put("/layouts/{id}", initialLayout.getId()) + .content(objectMapper.writeValueAsString(layoutRequest)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()) + .andExpect(jsonPath("$").doesNotExist()); + + Layout updatedLayout = layoutRepository.findById(initialLayout.getId()).orElseThrow(); + + assertThat(updatedLayout.getDefinition()) + .isEqualTo(layoutRequest.getDefinition()); + assertThat(updatedLayout.getMetadata().getBaseMetadata().getName()) + .isEqualTo(layoutRequest.getMetadata().getBaseMetadata().getName()); + assertThat(updatedLayout.getMetadata().getBaseMetadata().getGroup()) + .isEqualTo(layoutRequest.getMetadata().getBaseMetadata().getGroup()); + assertThat(updatedLayout.getMetadata().getBaseMetadata().getScreenshot()) + .isEqualTo(layoutRequest.getMetadata().getBaseMetadata().getScreenshot()); + assertThat(updatedLayout.getMetadata().getBaseMetadata().getUser()) + .isEqualTo(layoutRequest.getMetadata().getBaseMetadata().getUser()); + + assertThat(updatedLayout).isNotEqualTo(initialLayout); + } + + @Test + void updateLayout_invalidRequestBodyDefinitionIsBlank_returns400AndLayoutDoesNotChange() + throws Exception { + Layout layout = createDefaultLayoutInDatabase(); + assertThat(layoutRepository.findById(layout.getId()).orElseThrow()).isEqualTo(layout); + + LayoutRequestDTO request = createValidLayoutRequest(); + request.setDefinition(""); + + mockMvc.perform(put("/layouts/{id}", layout.getId()) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(content().string("[definition: Definition must not be blank]")); + + assertThat(layoutRepository.findById(layout.getId()).orElseThrow()).isEqualTo(layout); + } + + @Test + void updateLayout_invalidRequestBodyMetadataIsNull_returns400AndLayoutDoesNotChange() + throws Exception { + Layout layout = createDefaultLayoutInDatabase(); + assertThat(layoutRepository.findById(layout.getId()).orElseThrow()).isEqualTo(layout); + + LayoutRequestDTO request = createValidLayoutRequest(); + request.setMetadata(null); + + mockMvc.perform(put("/layouts/{id}", layout.getId()) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(content().string("[metadata: Metadata must not be null]")); + + assertThat(layoutRepository.findById(layout.getId()).orElseThrow()).isEqualTo(layout); + } + + @Test + void updateLayout_invalidRequestBodyUnexpectedFormat_returns400AndLayoutDoesNotChange() + throws Exception { + Layout layout = createDefaultLayoutInDatabase(); + assertThat(layoutRepository.findById(layout.getId()).orElseThrow()).isEqualTo(layout); + + String request = "invalidRequest"; + + mockMvc.perform(put("/layouts/{id}", layout.getId()) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(content().string( + "JSON parse error: Cannot construct instance of `org.finos.vuu.layoutserver.dto" + + ".request.LayoutRequestDTO` (although at least one Creator exists): no " + + "String-argument constructor/factory method to deserialize from String " + + "value ('invalidRequest'); nested exception is com.fasterxml.jackson" + + ".databind.exc.MismatchedInputException: Cannot construct instance of `org" + + ".finos.vuu.layoutserver.dto.request.LayoutRequestDTO` (although at least " + + "one Creator exists): no String-argument constructor/factory method to " + + "deserialize from String value ('invalidRequest')\n" + + " at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream);" + + " line: 1, column: 1]")); + + assertThat(layoutRepository.findById(layout.getId()).orElseThrow()).isEqualTo( + layout); + } + + @Test + void updateLayout_validIdButLayoutDoesNotExist_returnsNotFound() throws Exception { + UUID layoutID = UUID.randomUUID(); + LayoutRequestDTO layoutRequest = createValidLayoutRequest(); + + mockMvc.perform(put("/layouts/{id}", layoutID) + .content(objectMapper.writeValueAsString(layoutRequest)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + void updateLayout_invalidId_returns400() throws Exception { + String layoutID = "invalidUUID"; + LayoutRequestDTO layoutRequest = createValidLayoutRequest(); + + mockMvc.perform(put("/layouts/{id}", layoutID) + .content(objectMapper.writeValueAsString(layoutRequest)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(content().string( + "Failed to convert value of type 'java.lang.String' to required type 'java.util" + + ".UUID'; nested exception is java.lang.IllegalArgumentException: Invalid " + + "UUID string: invalidUUID")); + } + + @Test + void deleteLayout_validIdLayoutExists_returnsSuccessAndLayoutIsDeleted() throws Exception { + Layout layout = createDefaultLayoutInDatabase(); + assertThat(layoutRepository.findById(layout.getId()).orElseThrow()).isEqualTo(layout); + + mockMvc.perform(delete("/layouts/{id}", layout.getId())).andExpect(status().isNoContent()); + + assertThat(layoutRepository.findById(layout.getId())).isEmpty(); + } + + @Test + void deleteLayout_validIdLayoutDoesNotExist_returnsNotFound() throws Exception { + UUID layoutID = UUID.randomUUID(); + + mockMvc.perform(delete("/layouts/{id}", layoutID)).andExpect(status().isNotFound()); + } + + @Test + void deleteLayout_invalidId_returns400() throws Exception { + String layoutID = "invalidUUID"; + + mockMvc.perform(delete("/layouts/{id}", layoutID)) + .andExpect(status().isBadRequest()) + .andExpect(content().string( + "Failed to convert value of type 'java.lang.String' to required type 'java.util" + + ".UUID'; nested exception is java.lang.IllegalArgumentException: Invalid " + + "UUID string: invalidUUID")); + } + + private Layout createDefaultLayoutInDatabase() { + Layout layout = new Layout(); + Metadata metadata = new Metadata(); + BaseMetadata baseMetadata = new BaseMetadata(); + + baseMetadata.setName(DEFAULT_LAYOUT_NAME); + baseMetadata.setGroup(DEFAULT_LAYOUT_GROUP); + baseMetadata.setScreenshot(DEFAULT_LAYOUT_SCREENSHOT); + baseMetadata.setUser(DEFAULT_LAYOUT_USER); + + metadata.setBaseMetadata(baseMetadata); + + layout.setDefinition(DEFAULT_LAYOUT_DEFINITION); + layout.setMetadata(metadata); + + return layoutRepository.save(layout); + } + + private LayoutRequestDTO createValidLayoutRequest() { + BaseMetadata baseMetadata = new BaseMetadata(); + baseMetadata.setName(DEFAULT_LAYOUT_NAME); + baseMetadata.setGroup(DEFAULT_LAYOUT_GROUP); + baseMetadata.setScreenshot(DEFAULT_LAYOUT_SCREENSHOT); + baseMetadata.setUser(DEFAULT_LAYOUT_USER); + + MetadataRequestDTO metadataRequest = new MetadataRequestDTO(); + metadataRequest.setBaseMetadata(baseMetadata); + + LayoutRequestDTO layoutRequest = new LayoutRequestDTO(); + layoutRequest.setDefinition(DEFAULT_LAYOUT_DEFINITION); + layoutRequest.setMetadata(metadataRequest); + + return layoutRequest; + } +} diff --git a/layout-server/src/test/java/org/finos/vuu/layoutserver/service/LayoutServiceTest.java b/layout-server/src/test/java/org/finos/vuu/layoutserver/service/LayoutServiceTest.java new file mode 100644 index 000000000..5ec50e5d7 --- /dev/null +++ b/layout-server/src/test/java/org/finos/vuu/layoutserver/service/LayoutServiceTest.java @@ -0,0 +1,111 @@ +package org.finos.vuu.layoutserver.service; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.UUID; +import org.finos.vuu.layoutserver.model.BaseMetadata; +import org.finos.vuu.layoutserver.model.Layout; +import org.finos.vuu.layoutserver.model.Metadata; +import org.finos.vuu.layoutserver.repository.LayoutRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.EmptyResultDataAccessException; + +@ExtendWith(MockitoExtension.class) +class LayoutServiceTest { + + private static final UUID LAYOUT_ID = UUID.randomUUID(); + public static final UUID METADATA_ID = UUID.randomUUID(); + + @Mock + private LayoutRepository layoutRepository; + + @InjectMocks + private LayoutService layoutService; + + private Layout layout; + + @BeforeEach + public void setup() { + BaseMetadata baseMetadata = new BaseMetadata(); + baseMetadata.setName("Test Name"); + baseMetadata.setGroup("Test Group"); + baseMetadata.setScreenshot("Test Screenshot"); + baseMetadata.setUser("Test User"); + + Metadata metadata = Metadata.builder().id(METADATA_ID).baseMetadata(baseMetadata).build(); + + layout = new Layout(); + layout.setId(LAYOUT_ID); + layout.setDefinition(""); + layout.setMetadata(metadata); + } + + @Test + void getLayout_layoutExists_returnsLayout() { + when(layoutRepository.findById(LAYOUT_ID)).thenReturn(Optional.of(layout)); + + assertThat(layoutService.getLayout(LAYOUT_ID)).isEqualTo(layout); + } + + @Test + void getLayout_noLayoutsExist_throwsNotFoundException() { + when(layoutRepository.findById(LAYOUT_ID)).thenReturn(Optional.empty()); + + assertThrows(NoSuchElementException.class, + () -> layoutService.getLayout(LAYOUT_ID)); + } + + @Test + void createLayout_anyLayout_returnsLayoutId() { + when(layoutRepository.save(layout)).thenReturn(layout); + + assertThat(layoutService.createLayout(layout)).isEqualTo(LAYOUT_ID); + } + + @Test + void updateLayout_layoutExists_callsRepositorySave() { + when(layoutRepository.findById(LAYOUT_ID)).thenReturn(Optional.of(layout)); + + layoutService.updateLayout(LAYOUT_ID, layout); + + verify(layoutRepository, times(1)).save(layout); + } + + @Test + void updateLayout_layoutDoesNotExist_throwsNoSuchElementException() { + when(layoutRepository.findById(LAYOUT_ID)).thenReturn(Optional.empty()); + + assertThrows(NoSuchElementException.class, + () -> layoutService.updateLayout(LAYOUT_ID, layout)); + } + + @Test + void deleteLayout_anyUUID_callsRepositoryDeleteById() { + layoutService.deleteLayout(LAYOUT_ID); + + verify(layoutRepository, times(1)).deleteById(LAYOUT_ID); + } + + @Test + void deleteLayout_noLayoutExists_throwsNoSuchElementException() { + doThrow(new EmptyResultDataAccessException(1)) + .when(layoutRepository).deleteById(LAYOUT_ID); + + assertThrows(NoSuchElementException.class, + () -> layoutService.deleteLayout(LAYOUT_ID)); + + verify(layoutRepository, times(1)).deleteById(LAYOUT_ID); + } +} \ No newline at end of file diff --git a/layout-server/src/test/java/org/finos/vuu/layoutserver/service/MetadataServiceTest.java b/layout-server/src/test/java/org/finos/vuu/layoutserver/service/MetadataServiceTest.java new file mode 100644 index 000000000..1539a9237 --- /dev/null +++ b/layout-server/src/test/java/org/finos/vuu/layoutserver/service/MetadataServiceTest.java @@ -0,0 +1,37 @@ +package org.finos.vuu.layoutserver.service; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.Mockito.when; + +import java.util.List; +import org.finos.vuu.layoutserver.model.Metadata; +import org.finos.vuu.layoutserver.repository.MetadataRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class MetadataServiceTest { + + @Mock + private MetadataRepository metadataRepository; + + @InjectMocks + private MetadataService metadataService; + + @Test + void getMetadata_metadataExists_returnsMetadata() { + Metadata metadata = Metadata.builder().build(); + + when(metadataRepository.findAll()).thenReturn(List.of(metadata)); + assertThat(metadataService.getMetadata()).isEqualTo(List.of(metadata)); + } + + @Test + void getMetadata_noMetadataExists_returnsEmptyList() { + when(metadataRepository.findAll()).thenReturn(List.of()); + assertThat(metadataService.getMetadata()).isEqualTo(List.of()); + } +} \ No newline at end of file diff --git a/layout-server/src/test/resources/application-test.properties b/layout-server/src/test/resources/application-test.properties new file mode 100644 index 000000000..2722b4aca --- /dev/null +++ b/layout-server/src/test/resources/application-test.properties @@ -0,0 +1,5 @@ +spring.datasource.url=jdbc:h2:mem:testdb;NON_KEYWORDS=GROUP,USER +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password=password +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect \ No newline at end of file