diff --git a/pom.xml b/pom.xml index fb0061f3..863e8834 100644 --- a/pom.xml +++ b/pom.xml @@ -182,6 +182,11 @@ hypersistence-utils-hibernate-62 3.6.0 + + + com.fasterxml.jackson.dataformat + jackson-dataformat-csv + diff --git a/src/main/java/jewellery/inventory/controller/ResourceController.java b/src/main/java/jewellery/inventory/controller/ResourceController.java index a9ffbfcc..03d5548c 100644 --- a/src/main/java/jewellery/inventory/controller/ResourceController.java +++ b/src/main/java/jewellery/inventory/controller/ResourceController.java @@ -11,6 +11,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("/resources") @@ -69,4 +70,11 @@ public ResourceQuantityResponseDto getResourceQuantityById(@PathVariable("id") U public List getAllResourceQuantities() { return resourceService.getAllResourceQuantities(); } + + @Operation(summary = "Import resources from CSV") + @ResponseStatus(HttpStatus.OK) + @PostMapping("/import") + public List importResources(@RequestParam("file") MultipartFile file) { + return resourceService.importResources(file); + } } diff --git a/src/main/java/jewellery/inventory/exception/image/MultipartFileContentTypeException.java b/src/main/java/jewellery/inventory/exception/image/MultipartFileContentTypeException.java index 1f9ab59a..6313f349 100644 --- a/src/main/java/jewellery/inventory/exception/image/MultipartFileContentTypeException.java +++ b/src/main/java/jewellery/inventory/exception/image/MultipartFileContentTypeException.java @@ -1,7 +1,11 @@ package jewellery.inventory.exception.image; -public class MultipartFileContentTypeException extends RuntimeException{ - public MultipartFileContentTypeException() { - super("Only PNG or JPG images are allowed."); - } +public class MultipartFileContentTypeException extends RuntimeException { + public MultipartFileContentTypeException() { + super("Only PNG or JPG images are allowed."); + } + + public MultipartFileContentTypeException(String message) { + super(message); + } } diff --git a/src/main/java/jewellery/inventory/mapper/ElementMapper.java b/src/main/java/jewellery/inventory/mapper/ElementMapper.java index f65d0ee3..94329ea4 100644 --- a/src/main/java/jewellery/inventory/mapper/ElementMapper.java +++ b/src/main/java/jewellery/inventory/mapper/ElementMapper.java @@ -6,7 +6,7 @@ import org.mapstruct.Mapper; import org.mapstruct.factory.Mappers; -@Mapper(componentModel = "spring") +@Mapper(componentModel = "spring", uses = StringTrimmer.class) public interface ElementMapper { ElementMapper INSTANCE = Mappers.getMapper(ElementMapper.class); diff --git a/src/main/java/jewellery/inventory/mapper/MetalMapper.java b/src/main/java/jewellery/inventory/mapper/MetalMapper.java index d8c16c75..954ace15 100644 --- a/src/main/java/jewellery/inventory/mapper/MetalMapper.java +++ b/src/main/java/jewellery/inventory/mapper/MetalMapper.java @@ -6,7 +6,7 @@ import org.mapstruct.Mapper; import org.mapstruct.factory.Mappers; -@Mapper(componentModel = "spring") +@Mapper(componentModel = "spring", uses = StringTrimmer.class) public interface MetalMapper { MetalMapper INSTANCE = Mappers.getMapper(MetalMapper.class); diff --git a/src/main/java/jewellery/inventory/mapper/PearlMapper.java b/src/main/java/jewellery/inventory/mapper/PearlMapper.java index e8450a4a..e6265745 100644 --- a/src/main/java/jewellery/inventory/mapper/PearlMapper.java +++ b/src/main/java/jewellery/inventory/mapper/PearlMapper.java @@ -6,7 +6,7 @@ import org.mapstruct.Mapper; import org.mapstruct.factory.Mappers; -@Mapper(componentModel = "spring") +@Mapper(componentModel = "spring", uses = StringTrimmer.class) public interface PearlMapper { PearlMapper INSTANCE = Mappers.getMapper(PearlMapper.class); diff --git a/src/main/java/jewellery/inventory/mapper/PreciousStoneMapper.java b/src/main/java/jewellery/inventory/mapper/PreciousStoneMapper.java index e767db7d..7ebbaef6 100644 --- a/src/main/java/jewellery/inventory/mapper/PreciousStoneMapper.java +++ b/src/main/java/jewellery/inventory/mapper/PreciousStoneMapper.java @@ -6,11 +6,11 @@ import org.mapstruct.Mapper; import org.mapstruct.factory.Mappers; -@Mapper(componentModel = "spring") +@Mapper(componentModel = "spring", uses = StringTrimmer.class) public interface PreciousStoneMapper { - PreciousStoneMapper INSTANCE = Mappers.getMapper(PreciousStoneMapper.class); + PreciousStoneMapper INSTANCE = Mappers.getMapper(PreciousStoneMapper.class); - PreciousStoneResponseDto toResourceResponse(PreciousStone entity); + PreciousStoneResponseDto toResourceResponse(PreciousStone entity); - PreciousStone toResourceEntity(PreciousStoneRequestDto dto); -} \ No newline at end of file + PreciousStone toResourceEntity(PreciousStoneRequestDto dto); +} diff --git a/src/main/java/jewellery/inventory/mapper/SemiPreciousStoneMapper.java b/src/main/java/jewellery/inventory/mapper/SemiPreciousStoneMapper.java index 68145937..4b38983c 100644 --- a/src/main/java/jewellery/inventory/mapper/SemiPreciousStoneMapper.java +++ b/src/main/java/jewellery/inventory/mapper/SemiPreciousStoneMapper.java @@ -6,11 +6,11 @@ import org.mapstruct.Mapper; import org.mapstruct.factory.Mappers; -@Mapper(componentModel = "spring") +@Mapper(componentModel = "spring", uses = StringTrimmer.class) public interface SemiPreciousStoneMapper { - SemiPreciousStoneMapper INSTANCE = Mappers.getMapper(SemiPreciousStoneMapper.class); + SemiPreciousStoneMapper INSTANCE = Mappers.getMapper(SemiPreciousStoneMapper.class); - SemiPreciousStoneResponseDto toResourceResponse(SemiPreciousStone entity); + SemiPreciousStoneResponseDto toResourceResponse(SemiPreciousStone entity); - SemiPreciousStone toResourceEntity(SemiPreciousStoneRequestDto dto); + SemiPreciousStone toResourceEntity(SemiPreciousStoneRequestDto dto); } diff --git a/src/main/java/jewellery/inventory/mapper/StringTrimmer.java b/src/main/java/jewellery/inventory/mapper/StringTrimmer.java new file mode 100644 index 00000000..3bd6cbb2 --- /dev/null +++ b/src/main/java/jewellery/inventory/mapper/StringTrimmer.java @@ -0,0 +1,10 @@ +package jewellery.inventory.mapper; + +import org.springframework.stereotype.Component; + +@Component +public class StringTrimmer { + public String trimString(String value) { + return value.trim(); + } +} diff --git a/src/main/java/jewellery/inventory/model/EventType.java b/src/main/java/jewellery/inventory/model/EventType.java index 04bede4a..c7bc6137 100644 --- a/src/main/java/jewellery/inventory/model/EventType.java +++ b/src/main/java/jewellery/inventory/model/EventType.java @@ -10,6 +10,7 @@ public enum EventType { RESOURCE_TRANSFER, RESOURCE_REMOVE_QUANTITY, RESOURCE_ADD_QUANTITY, + RESOURCE_IMPORT, PRODUCT_CREATE, PRODUCT_TRANSFER, PRODUCT_DISASSEMBLY, diff --git a/src/main/java/jewellery/inventory/service/ResourceService.java b/src/main/java/jewellery/inventory/service/ResourceService.java index c9b95b77..5fa2808d 100644 --- a/src/main/java/jewellery/inventory/service/ResourceService.java +++ b/src/main/java/jewellery/inventory/service/ResourceService.java @@ -1,5 +1,11 @@ package jewellery.inventory.service; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MappingIterator; +import com.fasterxml.jackson.dataformat.csv.CsvMapper; +import com.fasterxml.jackson.dataformat.csv.CsvSchema; +import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.UUID; @@ -10,6 +16,8 @@ import jewellery.inventory.dto.request.resource.ResourceRequestDto; import jewellery.inventory.dto.response.ResourceQuantityResponseDto; import jewellery.inventory.dto.response.resource.ResourceResponseDto; +import jewellery.inventory.exception.image.MultipartFileContentTypeException; +import jewellery.inventory.exception.image.MultipartFileNotSelectedException; import jewellery.inventory.exception.not_found.ResourceNotFoundException; import jewellery.inventory.mapper.ResourceMapper; import jewellery.inventory.model.EventType; @@ -20,6 +28,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor @@ -90,6 +99,49 @@ public List getAllResourceQuantities() { .toList(); } + public List importResources(MultipartFile file) { + verifyIsCsv(file); + + List resourcesDto = getImportedResources(file); + + List savedResources = new ArrayList<>(); + for (ResourceRequestDto resourceRequestDto : resourcesDto) { + ResourceResponseDto savedResource = createResource(resourceRequestDto); + savedResources.add(savedResource); + } + + return savedResources; + } + + private void verifyIsCsv(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new MultipartFileNotSelectedException(); + } + + if (!"text/csv".equals(file.getContentType())) { + throw new MultipartFileContentTypeException("Only CSV files are allowed"); + } + } + + private List getImportedResources(MultipartFile file) { + CsvMapper mapper = new CsvMapper(); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + CsvSchema schema = CsvSchema.emptySchema().withHeader(); + + MappingIterator resourcesIterator; + List resourceDtos; + try { + resourcesIterator = + mapper.readerFor(ResourceRequestDto.class).with(schema).readValues(file.getInputStream()); + resourceDtos = resourcesIterator.readAll(); + } catch (IOException e) { + throw new RuntimeException(e); + } + + return resourceDtos; + } + @Override public Object fetchEntity(Object... ids) { ids = Arrays.stream(ids).filter(UUID.class::isInstance).toArray(); diff --git a/src/test/java/jewellery/inventory/integration/ResourceCrudIntegrationTest.java b/src/test/java/jewellery/inventory/integration/ResourceCrudIntegrationTest.java index 282c6f99..c615d0f0 100644 --- a/src/test/java/jewellery/inventory/integration/ResourceCrudIntegrationTest.java +++ b/src/test/java/jewellery/inventory/integration/ResourceCrudIntegrationTest.java @@ -8,11 +8,11 @@ import static jewellery.inventory.model.EventType.RESOURCE_UPDATE; import static jewellery.inventory.utils.BigDecimalUtil.getBigDecimal; import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; import java.util.List; @@ -28,12 +28,13 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.data.util.Pair; import org.springframework.data.util.StreamUtils; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; +import org.springframework.http.*; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; class ResourceCrudIntegrationTest extends AuthenticatedIntegrationTestBase { @Autowired private ResourceMapper resourceMapper; @@ -128,10 +129,10 @@ void willUpdateResourceToDatabase() throws JsonProcessingException { assertInputMatchesFetchedFromServer(updatedInputDtos); Map expectedEventPayload = - getUpdateEventPayload( - createdDtos.get(0), - getMatchingUpdatedDto(createdDtos.get(0).getId(), updatedDtos), - objectMapper); + getUpdateEventPayload( + createdDtos.get(0), + getMatchingUpdatedDto(createdDtos.get(0).getId(), updatedDtos), + objectMapper); systemEventTestHelper.assertEventWasLogged(RESOURCE_UPDATE, expectedEventPayload); } @@ -182,6 +183,69 @@ void willFailToDeleteResourceFromDatabaseWithWrongId() { assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); } + @Test + public void willThrowWhenFileIsEmpty() { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", getEmptyTestFile().getResource()); + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + ResponseEntity response = + testRestTemplate.postForEntity(getImportUrl(), requestEntity, String.class); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + public void willThrowWhenFileContentIsWrong() { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", getTestWrongContentFile().getResource()); + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + ResponseEntity response = + testRestTemplate.postForEntity(getImportUrl(), requestEntity, String.class); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + public void willImportResourcesSuccessfully() { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", getTestFile().getResource()); + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(getImportUrl(), HttpMethod.POST, requestEntity, responseType); + + List responseDto = response.getBody(); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(responseDto); + assertEquals(responseDto.get(0).getClazz(), "Element"); + assertEquals(responseDto.get(0).getQuantityType(), "28"); + assertEquals(responseDto.get(0).getPricePerQuantity(), BigDecimal.valueOf(30)); + assertEquals(responseDto.get(0).getNote(), "smth"); + } + + private MockMultipartFile getEmptyTestFile() { + return new MockMultipartFile("file", "test-file.txt", "text/plain", "".getBytes()); + } + + private MockMultipartFile getTestWrongContentFile() { + String data = "smth"; + return new MockMultipartFile("file", "test-file.txt", "text/plain", data.getBytes()); + } + + private MockMultipartFile getTestFile() { + String csvData = + "clazz,quantityType,pricePerQuantity,note,description\nElement,28,30,smth,Element description\n"; + return new MockMultipartFile("file", "test-file.csv", "text/csv", csvData.getBytes()); + } + + private String getImportUrl() { + return getBaseResourceUrl() + "/" + "import"; + } + @NotNull private List getResourcesWithRequest() throws JsonProcessingException { String response = this.testRestTemplate.getForObject(getBaseResourceUrl(), String.class); @@ -251,10 +315,12 @@ private void assertInputMatchesFetchedFromServer(List update assertThat(mappedDtos).containsExactlyInAnyOrderElementsOf(updatedResources); } - private ResourceResponseDto getMatchingUpdatedDto(UUID id, List updatedDtos) { + + private ResourceResponseDto getMatchingUpdatedDto( + UUID id, List updatedDtos) { return updatedDtos.stream() - .filter(resourceResponseDto -> resourceResponseDto.getId().equals(id)) - .findFirst() - .orElseThrow(() -> new AssertionFailure("Can't find id: " + id + " in responses")); + .filter(resourceResponseDto -> resourceResponseDto.getId().equals(id)) + .findFirst() + .orElseThrow(() -> new AssertionFailure("Can't find id: " + id + " in responses")); } } diff --git a/src/test/java/jewellery/inventory/unit/mapper/ResourceMapperTest.java b/src/test/java/jewellery/inventory/unit/mapper/ResourceMapperTest.java index 5d046824..c81a5cc4 100644 --- a/src/test/java/jewellery/inventory/unit/mapper/ResourceMapperTest.java +++ b/src/test/java/jewellery/inventory/unit/mapper/ResourceMapperTest.java @@ -7,26 +7,28 @@ import jewellery.inventory.dto.response.resource.ResourceResponseDto; import jewellery.inventory.exception.MappingException; import jewellery.inventory.mapper.*; -import jewellery.inventory.mapper.MetalMapper; import jewellery.inventory.model.resource.Resource; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; - +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration( + classes = { + PearlMapperImpl.class, + PreciousStoneMapperImpl.class, + ElementMapperImpl.class, + MetalMapperImpl.class, + SemiPreciousStoneMapperImpl.class, + StringTrimmer.class, + ResourceMapper.class + }) class ResourceMapperTest { - private ResourceMapper resourceMapper; - - @BeforeEach - void setUp() { - PearlMapper pearlMapper = PearlMapper.INSTANCE; - PreciousStoneMapper preciousStoneMapper = PreciousStoneMapper.INSTANCE; - ElementMapper elementMapper = ElementMapper.INSTANCE; - MetalMapper metalMapper = MetalMapper.INSTANCE; - SemiPreciousStoneMapper semiPreciousStoneMapper = SemiPreciousStoneMapper.INSTANCE; - resourceMapper = - new ResourceMapper(pearlMapper, preciousStoneMapper, elementMapper, metalMapper, semiPreciousStoneMapper); - } + @Autowired private ResourceMapper resourceMapper; @ParameterizedTest @MethodSource("jewellery.inventory.helper.ResourceTestHelper#provideResourcesAndResponseDtos") diff --git a/src/test/java/jewellery/inventory/unit/service/ResourceServiceTest.java b/src/test/java/jewellery/inventory/unit/service/ResourceServiceTest.java index e5a6527d..b23f1396 100644 --- a/src/test/java/jewellery/inventory/unit/service/ResourceServiceTest.java +++ b/src/test/java/jewellery/inventory/unit/service/ResourceServiceTest.java @@ -1,7 +1,6 @@ package jewellery.inventory.unit.service; -import static jewellery.inventory.helper.ResourceTestHelper.getPreciousStone; -import static jewellery.inventory.helper.ResourceTestHelper.provideResources; +import static jewellery.inventory.helper.ResourceTestHelper.*; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; @@ -12,6 +11,8 @@ import java.util.UUID; import jewellery.inventory.dto.request.resource.ResourceRequestDto; import jewellery.inventory.dto.response.resource.ResourceResponseDto; +import jewellery.inventory.exception.image.MultipartFileContentTypeException; +import jewellery.inventory.exception.image.MultipartFileNotSelectedException; import jewellery.inventory.exception.not_found.ResourceNotFoundException; import jewellery.inventory.mapper.ResourceMapper; import jewellery.inventory.model.resource.Resource; @@ -24,6 +25,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; @ExtendWith(MockitoExtension.class) class ResourceServiceTest { @@ -31,6 +33,10 @@ class ResourceServiceTest { @Mock private ResourceMapper resourceMapper; @InjectMocks private ResourceService resourceService; + private MockMultipartFile file; + private final String csvData = + "clazz,quantityType,pricePerQuantity,note,description\nElement,28,30,smth,Element description\n"; + @ParameterizedTest @MethodSource("jewellery.inventory.helper.ResourceTestHelper#provideResourcesAndRequestDtos") void willSaveResource(Resource resourceFromDatabase, ResourceRequestDto resourceRequestDto) { @@ -142,4 +148,40 @@ void willThrowWhenGetANonExistingResource() { assertThrows( ResourceNotFoundException.class, () -> resourceService.getResource(UUID.randomUUID())); } + + @Test + void willThrowWhenImportFileIsNull() { + assertThrows( + MultipartFileNotSelectedException.class, () -> resourceService.importResources(file)); + } + + @Test + void willThrowWhenImportFileIsEmpty() { + file = new MockMultipartFile("empty.csv", "".getBytes()); + + assertThrows( + MultipartFileNotSelectedException.class, () -> resourceService.importResources(file)); + } + + @Test + void willThrowWhenImportFileIsNotCsv() { + file = new MockMultipartFile("file.txt", "file.txt", "text/plain", csvData.getBytes()); + + assertThrows( + MultipartFileContentTypeException.class, () -> resourceService.importResources(file)); + } + + @Test + void willImportResources() { + file = new MockMultipartFile("file.csv", "file.csv", "text/csv", csvData.getBytes()); + Resource element = getElement(); + when(resourceMapper.toResourceEntity(any(ResourceRequestDto.class))).thenReturn(element); + when(resourceRepository.save(element)).thenReturn(element); + + resourceService.importResources(file); + + verify(resourceMapper, times(1)).toResourceEntity(any(ResourceRequestDto.class)); + verify(resourceRepository, times(1)).save(element); + verify(resourceMapper, times(1)).toResourceResponse(element); + } }