diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/controller/ApplicationLayoutController.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/controller/ApplicationLayoutController.java new file mode 100644 index 0000000000..76b750bfe6 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/controller/ApplicationLayoutController.java @@ -0,0 +1,55 @@ +package org.finos.vuu.layoutserver.controller; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import org.finos.vuu.layoutserver.dto.response.ApplicationLayoutDto; +import org.finos.vuu.layoutserver.service.ApplicationLayoutService; +import org.modelmapper.ModelMapper; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/application-layouts") +public class ApplicationLayoutController { + + private final ApplicationLayoutService service; + private final ModelMapper mapper; + + /** + * Gets the persisted application layout for the requesting user. If the requesting user does not have an + * application layout persisted, a default layout with a null username is returned instead. No more than one + * application layout can be persisted for a given user. + * + * @return the application layout + */ + @ResponseStatus(HttpStatus.OK) + @GetMapping + public ApplicationLayoutDto getApplicationLayout(@RequestHeader("username") String username) { + return mapper.map(service.getApplicationLayout(username), ApplicationLayoutDto.class); + } + + /** + * Creates or updates the unique application layout for the requesting user. + * + * @param layoutDefinition JSON representation of the application layout to be created + * @param username the user making the request + */ + @ResponseStatus(HttpStatus.CREATED) + @PutMapping + public void persistApplicationLayout(@RequestHeader("username") String username, @RequestBody ObjectNode layoutDefinition) { + service.persistApplicationLayout(username, layoutDefinition); + } + + /** + * Deletes the application layout for the requesting user. A 404 will be returned if there is no existing + * application layout. + * + * @param username the user making the request + */ + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping + public void deleteApplicationLayout(@RequestHeader("username") String username) { + service.deleteApplicationLayout(username); + } +} 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 new file mode 100644 index 0000000000..9620c11841 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/request/LayoutRequestDto.java @@ -0,0 +1,19 @@ +package org.finos.vuu.layoutserver.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Data; + +import javax.validation.constraints.NotNull; + +@Data +public class LayoutRequestDto { + + @JsonProperty(value = "definition", required = true) + @NotNull(message = "Definition must not be null") + private ObjectNode 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/ApplicationLayoutDto.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/ApplicationLayoutDto.java new file mode 100644 index 0000000000..d04d48af50 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/ApplicationLayoutDto.java @@ -0,0 +1,10 @@ +package org.finos.vuu.layoutserver.dto.response; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Data; + +@Data +public class ApplicationLayoutDto { + private String username; + private ObjectNode definition; +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/LayoutResponseDto.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/LayoutResponseDto.java new file mode 100644 index 0000000000..d919ebea8c --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/LayoutResponseDto.java @@ -0,0 +1,18 @@ +package org.finos.vuu.layoutserver.dto.response; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Data; +import org.finos.vuu.layoutserver.utils.ObjectNodeConverter; + +import javax.persistence.Column; +import javax.persistence.Convert; +import java.util.UUID; + +@Data +public class LayoutResponseDto { + + private UUID id; + private ObjectNode definition; + + private MetadataResponseDto metadata; +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/model/ApplicationLayout.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/model/ApplicationLayout.java new file mode 100644 index 0000000000..3ec5631c9b --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/model/ApplicationLayout.java @@ -0,0 +1,25 @@ +package org.finos.vuu.layoutserver.model; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.finos.vuu.layoutserver.utils.ObjectNodeConverter; + +import javax.persistence.Column; +import javax.persistence.Convert; +import javax.persistence.Entity; +import javax.persistence.Id; + +@Data +@Entity +@RequiredArgsConstructor +@AllArgsConstructor +public class ApplicationLayout { + @Id + private String username; + + @Convert(converter = ObjectNodeConverter.class) + @Column(columnDefinition = "JSON") + private ObjectNode definition; +} 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 new file mode 100644 index 0000000000..7941e1d718 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/model/Layout.java @@ -0,0 +1,30 @@ +package org.finos.vuu.layoutserver.model; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Data; +import org.finos.vuu.layoutserver.utils.ObjectNodeConverter; + +import javax.persistence.*; +import java.util.UUID; + +@Data +@Entity +public class Layout { + + @Id + @Column(columnDefinition = "BINARY(16)") + private UUID id; + + @Convert(converter = ObjectNodeConverter.class) + @Column(columnDefinition = "JSON") + private ObjectNode definition; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "metadata_id", referencedColumnName = "id") + private Metadata metadata; + + public void setId(UUID id) { + this.id = id; + this.metadata.setId(id); + } +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/service/ApplicationLayoutService.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/service/ApplicationLayoutService.java new file mode 100644 index 0000000000..0727552be7 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/service/ApplicationLayoutService.java @@ -0,0 +1,41 @@ +package org.finos.vuu.layoutserver.service; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import org.finos.vuu.layoutserver.model.ApplicationLayout; +import org.finos.vuu.layoutserver.repository.ApplicationLayoutRepository; +import org.finos.vuu.layoutserver.utils.DefaultApplicationLayoutLoader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.stereotype.Service; + +import java.util.NoSuchElementException; + +@RequiredArgsConstructor +@Service +public class ApplicationLayoutService { + + private static final Logger logger = LoggerFactory.getLogger(ApplicationLayoutService.class); + private final ApplicationLayoutRepository repository; + private final DefaultApplicationLayoutLoader defaultLoader; + + public void persistApplicationLayout(String username, ObjectNode layoutDefinition) { + repository.save(new ApplicationLayout(username, layoutDefinition)); + } + + public ApplicationLayout getApplicationLayout(String username) { + return repository.findById(username).orElseGet(() -> { + logger.info("No application layout for user, returning default"); + return defaultLoader.getDefaultLayout(); + }); + } + + public void deleteApplicationLayout(String username) { + try { + repository.deleteById(username); + } catch (EmptyResultDataAccessException e) { + throw new NoSuchElementException("No layout found for user: " + username); + } + } +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/utils/DefaultApplicationLayoutLoader.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/utils/DefaultApplicationLayoutLoader.java new file mode 100644 index 0000000000..55abadbe53 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/utils/DefaultApplicationLayoutLoader.java @@ -0,0 +1,40 @@ +package org.finos.vuu.layoutserver.utils; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.finos.vuu.layoutserver.exceptions.InternalServerErrorException; +import org.finos.vuu.layoutserver.model.ApplicationLayout; +import org.springframework.context.annotation.Bean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class DefaultApplicationLayoutLoader { + private static final String DEFAULT_LAYOUT_FILE = "defaultApplicationLayout.json"; + private static ApplicationLayout defaultLayout; + + @Bean + public ApplicationLayout getDefaultLayout() { + if (defaultLayout == null) { + loadDefaultLayout(); + } + return defaultLayout; + } + + private void loadDefaultLayout() { + ObjectNode definition = loadDefaultLayoutJsonFile(); + defaultLayout = new ApplicationLayout(null, definition); + } + + private ObjectNode loadDefaultLayoutJsonFile() { + ObjectMapper objectMapper = new ObjectMapper(); + ClassPathResource resource = new ClassPathResource(DEFAULT_LAYOUT_FILE); + try { + return objectMapper.readValue(resource.getInputStream(), ObjectNode.class); + } catch (IOException e) { + throw new InternalServerErrorException("Failed to read default application layout"); + } + } +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/utils/ObjectNodeConverter.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/utils/ObjectNodeConverter.java new file mode 100644 index 0000000000..a8393a9e66 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/utils/ObjectNodeConverter.java @@ -0,0 +1,45 @@ +package org.finos.vuu.layoutserver.utils; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.persistence.AttributeConverter; +import javax.persistence.Converter; +import java.io.IOException; + +@Converter +public class ObjectNodeConverter implements AttributeConverter { + private static final Logger logger = LoggerFactory.getLogger(ObjectNodeConverter.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(ObjectNode definition) { + try { + return objectMapper.writeValueAsString(definition); + } catch (final JsonProcessingException e) { + logger.error("JSON writing error", e); + return null; + } + } + + @Override + public ObjectNode convertToEntityAttribute(String definition) { + try { + return objectMapper.readValue(extractDefinition(definition), new TypeReference<>() {}); + } catch (final IOException e) { + logger.error("JSON reading error", e); + return null; + } + } + + private String extractDefinition(String definition) { + if (definition.startsWith("\"") && definition.endsWith("\"")) { + definition = definition.substring(1, definition.length() - 1); + } + return definition.replaceAll("\\\\", ""); + } +} diff --git a/layout-server/src/test/java/org/finos/vuu/layoutserver/controller/ApplicationLayoutControllerTest.java b/layout-server/src/test/java/org/finos/vuu/layoutserver/controller/ApplicationLayoutControllerTest.java new file mode 100644 index 0000000000..bdf1971b89 --- /dev/null +++ b/layout-server/src/test/java/org/finos/vuu/layoutserver/controller/ApplicationLayoutControllerTest.java @@ -0,0 +1,63 @@ +package org.finos.vuu.layoutserver.controller; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.finos.vuu.layoutserver.dto.response.ApplicationLayoutDto; +import org.finos.vuu.layoutserver.model.ApplicationLayout; +import org.finos.vuu.layoutserver.service.ApplicationLayoutService; +import org.finos.vuu.layoutserver.utils.ObjectNodeConverter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.modelmapper.ModelMapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class ApplicationLayoutControllerTest { + private static ApplicationLayoutService mockService; + private static ApplicationLayoutController controller; + private static final ModelMapper modelMapper = new ModelMapper(); + private static final ObjectNodeConverter objectNodeConverter = new ObjectNodeConverter(); + + @BeforeEach + public void setup() { + mockService = Mockito.mock(ApplicationLayoutService.class); + controller = new ApplicationLayoutController(mockService, modelMapper); + } + + @Test + public void getApplicationLayout_anyUsername_returnsLayoutFromService() throws JsonProcessingException { + String user = "user"; + ObjectNode definition = objectNodeConverter.convertToEntityAttribute("{\"id\":\"main-tabs\"}"); + + when(mockService.getApplicationLayout(user)) + .thenReturn(new ApplicationLayout(user, definition)); + + ApplicationLayoutDto response = controller.getApplicationLayout(user); + + assertThat(response.getUsername()).isEqualTo(user); + assertThat(response.getDefinition()).isEqualTo(definition); + + verify(mockService, times(1)).getApplicationLayout(user); + } + + @Test + public void persistApplicationLayout_anyInput_callsService() throws JsonProcessingException { + String user = "user"; + ObjectNode definition = objectNodeConverter.convertToEntityAttribute("{\"id\":\"main-tabs\"}"); + + controller.persistApplicationLayout(user, definition); + + verify(mockService, times(1)).persistApplicationLayout(user, definition); + } + + @Test + public void deleteApplicationLayout_anyUsername_callsService() { + String user = "user"; + + controller.deleteApplicationLayout(user); + + verify(mockService, times(1)).deleteApplicationLayout(user); + } +} 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 0000000000..0b128fbad2 --- /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 com.fasterxml.jackson.core.JsonProcessingException; +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.finos.vuu.layoutserver.utils.ObjectNodeConverter; +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; + +import java.util.List; +import java.util.NoSuchElementException; +import java.util.UUID; + +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; + +@ExtendWith(MockitoExtension.class) +class LayoutControllerTest { + + private static final String LAYOUT_DEFINITION_STRING = "{\"id\":\"main-tabs\"}"; + 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_ID = UUID.randomUUID(); + private static final UUID DOES_NOT_EXIST_ID = UUID.randomUUID(); + private static final ObjectNodeConverter objectNodeConverter = new ObjectNodeConverter(); + + @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() throws JsonProcessingException { + baseMetadata = new BaseMetadata(); + baseMetadata.setName(LAYOUT_NAME); + baseMetadata.setUser(LAYOUT_USER); + baseMetadata.setGroup(LAYOUT_GROUP); + baseMetadata.setScreenshot(LAYOUT_SCREENSHOT); + + metadata = Metadata.builder().id(VALID_ID).baseMetadata(baseMetadata).build(); + + layout = new Layout(); + layout.setMetadata(metadata); + layout.setId(VALID_ID); + layout.setDefinition(objectNodeConverter.convertToEntityAttribute(LAYOUT_DEFINITION_STRING)); + + 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_ID)).thenReturn(layout); + when(modelMapper.map(layout, LayoutResponseDto.class)).thenReturn(expectedLayoutResponse); + assertThat(layoutController.getLayout(VALID_ID)).isEqualTo(expectedLayoutResponse); + } + + @Test + void getLayout_layoutDoesNotExist_throwsNoSuchElementException() { + when(layoutService.getLayout(DOES_NOT_EXIST_ID)) + .thenThrow(NoSuchElementException.class); + + assertThrows(NoSuchElementException.class, + () -> layoutController.getLayout(DOES_NOT_EXIST_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); + + when(modelMapper.map(layoutRequest, Layout.class)).thenReturn(layoutWithoutIds); + when(layoutService.createLayout(layoutWithoutIds)).thenReturn(layout); + when(modelMapper.map(layout, LayoutResponseDto.class)).thenReturn(expectedLayoutResponse); + + assertThat(layoutController.createLayout(layoutRequest)).isEqualTo(expectedLayoutResponse); + } + + @Test + void updateLayout_validLayout_callsLayoutService() { + layout.setId(null); + + when(modelMapper.map(layoutRequest, Layout.class)).thenReturn(layout); + + layoutController.updateLayout(VALID_ID, layoutRequest); + + verify(layoutService).updateLayout(VALID_ID, layout); + } + + @Test + void deleteLayout__validId_callsLayoutService() { + layoutController.deleteLayout(VALID_ID); + + verify(layoutService).deleteLayout(VALID_ID); + } + + private MetadataResponseDto getMetadataResponseDto() { + MetadataResponseDto metadataResponse = new MetadataResponseDto(); + metadataResponse.setId(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/ApplicationLayoutIntegrationTest.java b/layout-server/src/test/java/org/finos/vuu/layoutserver/integration/ApplicationLayoutIntegrationTest.java new file mode 100644 index 0000000000..0f862b8525 --- /dev/null +++ b/layout-server/src/test/java/org/finos/vuu/layoutserver/integration/ApplicationLayoutIntegrationTest.java @@ -0,0 +1,181 @@ +package org.finos.vuu.layoutserver.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.finos.vuu.layoutserver.exceptions.InternalServerErrorException; +import org.finos.vuu.layoutserver.model.ApplicationLayout; +import org.finos.vuu.layoutserver.repository.ApplicationLayoutRepository; +import org.finos.vuu.layoutserver.utils.DefaultApplicationLayoutLoader; +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.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +public class ApplicationLayoutIntegrationTest { + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final String BASE_URL = "/application-layouts"; + private static final String MISSING_USERNAME_ERROR_MESSAGE = + "Required request header 'username' for method parameter type String is not present"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ApplicationLayoutRepository repository; + @MockBean + private DefaultApplicationLayoutLoader mockLoader; + private final DefaultApplicationLayoutLoader realLoader = new DefaultApplicationLayoutLoader(); + + @Test + public void getApplicationLayout_noLayoutExists_returns200WithDefaultLayout() throws Exception { + when(mockLoader.getDefaultLayout()).thenReturn(realLoader.getDefaultLayout()); + + mockMvc.perform(get(BASE_URL).header("username", "new user")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username", nullValue())) + // Expecting application layout as defined in /test/resources/defaultApplicationLayout.json + .andExpect(jsonPath("$.definition.defaultLayoutKey", is("default-layout-value"))); + } + + @Test + public void getApplicationLayout_defaultFailsToLoad_returns500() throws Exception { + String errorMessage = "Failed to read default application layout"; + doThrow(new InternalServerErrorException(errorMessage)).when(mockLoader).getDefaultLayout(); + + mockMvc.perform(get(BASE_URL).header("username", "new user")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", contains(errorMessage))); + } + + @Test + public void getApplicationLayout_noUserInHeader_returns400() throws Exception { + String actualError = mockMvc.perform(get(BASE_URL)) + .andExpect(status().isBadRequest()) + .andReturn().getResponse().getErrorMessage(); + + assertThat(actualError).isEqualTo(MISSING_USERNAME_ERROR_MESSAGE); + } + + @Test + public void getApplicationLayout_layoutExists_returns200WithPersistedLayout() throws Exception { + String user = "user"; + + Map definition = new HashMap<>(); + definition.put("defKey", "defVal"); + + persistApplicationLayout(user, definition); + + mockMvc.perform(get(BASE_URL).header("username", user)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username", is(user))) + .andExpect(jsonPath("$.definition", is(definition))); + } + + @Test + public void persistApplicationLayout_noLayoutExists_returns201AndPersistsLayout() throws Exception { + String user = "user"; + String definition = "{\"key\": \"value\"}"; + + mockMvc.perform(put(BASE_URL).header("username", user) + .content(definition) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$").doesNotExist()); + + ApplicationLayout persistedLayout = repository.findById(user).orElseThrow(); + + assertThat(persistedLayout.getUsername()).isEqualTo(user); + assertThat(persistedLayout.getDefinition()).isEqualTo(objectMapper.readTree(definition)); + } + + @Test + public void persistApplicationLayout_layoutExists_returns201AndOverwritesLayout() throws Exception { + String user = "user"; + + Map initialDefinition = new HashMap<>(); + initialDefinition.put("initial-key", "initial-value"); + + persistApplicationLayout(user, initialDefinition); + + String newDefinition = "{\"new-key\": \"new-value\"}"; + + mockMvc.perform(put(BASE_URL).header("username", user) + .content(newDefinition) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$").doesNotExist()); + + assertThat(repository.findAll()).hasSize(1); + + ApplicationLayout retrievedLayout = repository.findById(user).orElseThrow(); + + assertThat(retrievedLayout.getUsername()).isEqualTo(user); + assertThat(retrievedLayout.getDefinition()).isEqualTo(objectMapper.readTree(newDefinition)); + } + + @Test + public void persistApplicationLayout_noUserInHeader_returns400() throws Exception { + String actualError = mockMvc.perform(put(BASE_URL)) + .andExpect(status().isBadRequest()) + .andReturn().getResponse().getErrorMessage(); + + assertThat(actualError).isEqualTo(MISSING_USERNAME_ERROR_MESSAGE); + } + + @Test + public void deleteApplicationLayout_noLayoutExists_returns404() throws Exception { + String user = "user"; + + mockMvc.perform(delete(BASE_URL).header("username", user)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", contains("No layout found for user: " + user))); + } + + @Test + public void deleteApplicationLayout_layoutExists_returns204AndDeletesLayout() throws Exception { + String user = "user"; + + Map initialDefinition = new HashMap<>(); + initialDefinition.put("initial-key", "initial-value"); + + persistApplicationLayout(user, initialDefinition); + + mockMvc.perform(delete(BASE_URL).header("username", user)) + .andExpect(status().isNoContent()) + .andExpect(jsonPath("$").doesNotExist()); + + assertThat(repository.findAll()).hasSize(0); + } + + @Test + public void deleteApplicationLayout_noUserInHeader_returns400() throws Exception { + String actualError = mockMvc.perform(delete(BASE_URL)) + .andExpect(status().isBadRequest()) + .andReturn().getResponse().getErrorMessage(); + + assertThat(actualError).isEqualTo(MISSING_USERNAME_ERROR_MESSAGE); + } + + private void persistApplicationLayout(String user, Map definition) { + repository.save(new ApplicationLayout(user, objectMapper.convertValue(definition, ObjectNode.class))); + } +} 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 0000000000..6f7c9fdf27 --- /dev/null +++ b/layout-server/src/test/java/org/finos/vuu/layoutserver/integration/LayoutIntegrationTest.java @@ -0,0 +1,473 @@ +package org.finos.vuu.layoutserver.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.JsonPath; +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.finos.vuu.layoutserver.utils.ObjectNodeConverter; +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; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +public class LayoutIntegrationTest { + + private static final String LAYOUT_DEFINITION_STRING = "{\"id\":\"main-tabs\"}"; + 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 static final UUID DEFAULT_LAYOUT_ID = UUID.fromString("00000000-0000-0000-0000-000000000000"); + + private final ObjectMapper objectMapper = new ObjectMapper(); + private static final ObjectNodeConverter objectNodeConverter = new ObjectNodeConverter(); + + @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); + + Map definition = objectMapper.convertValue(layout.getDefinition(), Map.class); + + mockMvc.perform(get("/layouts/{id}", layout.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.definition", + is(definition))) + .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(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", + contains("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 { + UUID layout1Id = UUID.randomUUID(); + UUID layout2Id = UUID.randomUUID(); + Layout layout1 = createLayoutWithIdInDatabase(layout1Id); + Layout layout2 = createLayoutWithIdInDatabase(layout2Id); + layout2.setDefinition(objectNodeConverter.convertToEntityAttribute(LAYOUT_DEFINITION_STRING)); + 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(); + + Map definition = objectMapper.convertValue(layoutRequest.getDefinition(), Map.class); + + 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(definition))) + .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_invalidRequestBodyDefinitionsIsNull_returns400AndDoesNotCreateLayout() + throws Exception { + LayoutRequestDto layoutRequest = createValidLayoutRequest(); + layoutRequest.setDefinition(null); + + mockMvc.perform(post("/layouts") + .content(objectMapper.writeValueAsString(layoutRequest)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", contains("definition: Definition must not be null"))); + + 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(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", contains("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(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", contains( + "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(objectNodeConverter.convertToEntityAttribute(LAYOUT_DEFINITION_STRING)); + 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_invalidRequestBodyDefinitionIsNull_returns400AndLayoutDoesNotChange() + throws Exception { + Layout layout = createDefaultLayoutInDatabase(); + assertThat(layoutRepository.findById(layout.getId()).orElseThrow()).isEqualTo(layout); + + LayoutRequestDto request = createValidLayoutRequest(); + request.setDefinition(null); + + mockMvc.perform(put("/layouts/{id}", layout.getId()) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", contains("definition: Definition must not be null"))); + + 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(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", contains("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(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", contains( + "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(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", contains( + "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(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", contains( + "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(objectNodeConverter.convertToEntityAttribute(LAYOUT_DEFINITION_STRING)); + layout.setMetadata(metadata); + layout.setId(DEFAULT_LAYOUT_ID); + + return layoutRepository.save(layout); + } + + private Layout createLayoutWithIdInDatabase(UUID id) { + 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(objectNodeConverter.convertToEntityAttribute(LAYOUT_DEFINITION_STRING)); + layout.setMetadata(metadata); + layout.setId(id); + + 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(objectNodeConverter.convertToEntityAttribute(LAYOUT_DEFINITION_STRING)); + layoutRequest.setMetadata(metadataRequest); + + return layoutRequest; + } +} diff --git a/layout-server/src/test/java/org/finos/vuu/layoutserver/service/ApplicationLayoutServiceTest.java b/layout-server/src/test/java/org/finos/vuu/layoutserver/service/ApplicationLayoutServiceTest.java new file mode 100644 index 0000000000..7798c72e81 --- /dev/null +++ b/layout-server/src/test/java/org/finos/vuu/layoutserver/service/ApplicationLayoutServiceTest.java @@ -0,0 +1,105 @@ +package org.finos.vuu.layoutserver.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.finos.vuu.layoutserver.model.ApplicationLayout; +import org.finos.vuu.layoutserver.repository.ApplicationLayoutRepository; +import org.finos.vuu.layoutserver.utils.DefaultApplicationLayoutLoader; +import org.finos.vuu.layoutserver.utils.ObjectNodeConverter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.dao.EmptyResultDataAccessException; + +import java.util.NoSuchElementException; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class ApplicationLayoutServiceTest { + + private static ApplicationLayoutRepository mockRepo; + private static ApplicationLayoutService service; + private static final DefaultApplicationLayoutLoader defaultLoader = new DefaultApplicationLayoutLoader(); + private static final ObjectNodeConverter objectNodeConverter = new ObjectNodeConverter(); + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + public void setup() { + mockRepo = Mockito.mock(ApplicationLayoutRepository.class); + service = new ApplicationLayoutService(mockRepo, defaultLoader); + } + + @Test + public void getApplicationLayout_noLayout_returnsDefault() throws JsonProcessingException { + when(mockRepo.findById(anyString())).thenReturn(Optional.empty()); + + ApplicationLayout actualLayout = service.getApplicationLayout("new user"); + + // Expecting application layout as defined in /test/resources/defaultApplicationLayout.json + ObjectNode expectedDefinition = objectNodeConverter.convertToEntityAttribute("{\"defaultLayoutKey\":\"default-layout-value\"}"); + + assertThat(actualLayout.getUsername()).isNull(); + assertThat(actualLayout.getDefinition()).isEqualTo(expectedDefinition); + } + + @Test + public void getApplicationLayout_layoutExists_returnsLayout() throws JsonProcessingException { + String user = "user"; + + ObjectNode expectedDefinition = objectNodeConverter.convertToEntityAttribute("{\"id\":\"main-tabs\"}"); + ApplicationLayout expectedLayout = new ApplicationLayout(user, expectedDefinition); + + when(mockRepo.findById(user)).thenReturn(Optional.of(expectedLayout)); + + ApplicationLayout actualLayout = service.getApplicationLayout(user); + + assertThat(actualLayout).isEqualTo(expectedLayout); + } + + @Test + public void createApplicationLayout_validDefinition_callsRepoSave() throws JsonProcessingException { + String user = "user"; + ObjectNode definition = objectNodeConverter.convertToEntityAttribute("{\"id\":\"main-tabs\"}"); + + service.persistApplicationLayout(user, definition); + + verify(mockRepo, times(1)) + .save(new ApplicationLayout(user, definition)); + } + + @Test + public void createApplicationLayout_invalidDefinition_throwsJsonException() { + String definition = "invalid JSON"; + + assertThrows(JsonProcessingException.class, () -> + service.persistApplicationLayout("user", (ObjectNode) objectMapper.readTree(definition)) + ); + } + + @Test + public void deleteApplicationLayout_entryExists_callsRepoDelete() { + String user = "user"; + + service.deleteApplicationLayout(user); + + verify(mockRepo, times(1)).deleteById(user); + } + + @Test + public void deleteApplicationLayout_deleteFails_throwsException() { + String user = "user"; + + doThrow(EmptyResultDataAccessException.class).when(mockRepo).deleteById(user); + + NoSuchElementException exception = assertThrows(NoSuchElementException.class, () -> + service.deleteApplicationLayout(user) + ); + + assertThat(exception.getMessage()).isEqualTo("No layout found for user: " + user); + } +} 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 0000000000..42af579a08 --- /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 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.utils.ObjectNodeConverter; +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; + +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LayoutServiceTest { + + private static final UUID LAYOUT_ID = UUID.randomUUID(); + + @Mock + private LayoutRepository layoutRepository; + + @InjectMocks + private LayoutService layoutService; + + private Layout layout; + + private static final ObjectNodeConverter objectNodeConverter = new ObjectNodeConverter(); + + @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(LAYOUT_ID).baseMetadata(baseMetadata).build(); + + layout = new Layout(); + layout.setMetadata(metadata); + layout.setId(LAYOUT_ID); + layout.setDefinition(objectNodeConverter.convertToEntityAttribute("{\"id\":\"main-tabs\"}")); + } + + @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_returnsNewLayout() { + when(layoutRepository.save(layout)).thenReturn(layout); + + assertThat(layoutService.createLayout(layout)).isEqualTo(layout); + } + + @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