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);
+ }
}