diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/dto/ImageDTO.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/dto/ImageDTO.java new file mode 100644 index 0000000..ad10e83 --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/dto/ImageDTO.java @@ -0,0 +1,5 @@ +package es.codeurjcstudents.pcmod.dto; + +public record ImageDTO( + Long id) { +} \ No newline at end of file diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/dto/ImageMapper.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/dto/ImageMapper.java new file mode 100644 index 0000000..3559ecf --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/dto/ImageMapper.java @@ -0,0 +1,15 @@ +package es.codeurjcstudents.pcmod.dto; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import es.codeurjcstudents.pcmod.model.Image; + +@Mapper(componentModel = "spring") +public interface ImageMapper { + + ImageDTO toDTO(Image image); + + @Mapping(target = "imageFile", ignore = true) + Image toDomain(ImageDTO imageDTO); +} \ No newline at end of file diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/model/Image.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/model/Image.java new file mode 100644 index 0000000..7447d6e --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/model/Image.java @@ -0,0 +1,48 @@ +package es.codeurjcstudents.pcmod.model; + +import java.sql.Blob; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; + +@Entity +public class Image { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + @Lob + private Blob imageFile; + + public Image() { + } + + public Image(Blob imageFile) { + this.imageFile = imageFile; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Blob getImageFile() { + return imageFile; + } + + public void setImageFile(Blob imageFile) { + this.imageFile = imageFile; + } + + @Override + public String toString() { + return "Image [id=" + id + "]"; + } +} \ No newline at end of file diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/repository/ImageRepository.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/repository/ImageRepository.java new file mode 100644 index 0000000..f24d2e0 --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/repository/ImageRepository.java @@ -0,0 +1,9 @@ +package es.codeurjcstudents.pcmod.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import es.codeurjcstudents.pcmod.model.Image; + +public interface ImageRepository extends JpaRepository { + +} \ No newline at end of file diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/DatabaseInitializer.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/DatabaseInitializer.java index 0f45894..3024fac 100644 --- a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/DatabaseInitializer.java +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/DatabaseInitializer.java @@ -16,6 +16,9 @@ public class DatabaseInitializer { @Autowired private ComponentsRepository componentsRepository; + @Autowired + private ImageService imageService; + @PostConstruct public void init() { diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/ImageService.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/ImageService.java new file mode 100644 index 0000000..2113a9d --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/ImageService.java @@ -0,0 +1,87 @@ +package es.codeurjcstudents.pcmod.service; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import javax.sql.rowset.serial.SerialBlob; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import es.codeurjcstudents.pcmod.dto.ComponentMapper; +import es.codeurjcstudents.pcmod.model.Image; +import es.codeurjcstudents.pcmod.repository.ImageRepository; + +@Service +public class ImageService { + + private static final long MAX_SIZE = 10 * 1024 * 1024; // 10MB + private static final List ALLOWED_TYPES = List.of("image/jpeg", "image/png", "image/webp"); + + private final ImageRepository imageRepository; + + @Autowired + private ComponentMapper componentMapper; + + public ImageService(ImageRepository imageRepository) { + this.imageRepository = imageRepository; + } + + public List findAll() { + return imageRepository.findAll(); + } + + public Image createImage(InputStream inputStream) throws IOException { + + Image image = new Image(); + + try { + image.setImageFile(new SerialBlob(inputStream.readAllBytes())); + } catch (Exception e) { + throw new IOException("Failed to create image", e); + } + + imageRepository.save(image); + + return image; + } + + public Image replaceImageFile(long id, InputStream inputStream) throws IOException { + + Image image = imageRepository.findById(id).orElseThrow(); + + try { + image.setImageFile(new SerialBlob(inputStream.readAllBytes())); + } catch (Exception e) { + throw new IOException("Failed to create image", e); + } + + imageRepository.save(image); + + return image; + } + + public Image deleteImage(long id) { + + Image image = imageRepository.findById(id).orElseThrow(); + imageRepository.deleteById(id); + + return image; + } + + public void validate(MultipartFile imageField) { + + if (imageField.getSize() > MAX_SIZE) { + throw new IllegalArgumentException("El tamaño de la imagen no puede superar los 10MB."); + } + + String contentType = imageField.getContentType(); + if (contentType == null || !ALLOWED_TYPES.contains(contentType)) { + throw new IllegalArgumentException( + "El tipo de archivo no es válido. Solo se permiten imágenes JPEG, PNG y WebP."); + } + + } +} \ No newline at end of file diff --git a/backend/pcmod/src/main/resources/sample_images/i5-12400f.webp b/backend/pcmod/src/main/resources/sample_images/i5-12400f.webp new file mode 100644 index 0000000..91a2270 Binary files /dev/null and b/backend/pcmod/src/main/resources/sample_images/i5-12400f.webp differ diff --git a/backend/pcmod/src/main/resources/sample_images/kingston-nv3.webp b/backend/pcmod/src/main/resources/sample_images/kingston-nv3.webp new file mode 100644 index 0000000..85c5090 Binary files /dev/null and b/backend/pcmod/src/main/resources/sample_images/kingston-nv3.webp differ diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/ImagesIntegrationTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/ImagesIntegrationTests.java new file mode 100644 index 0000000..41a930d --- /dev/null +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/ImagesIntegrationTests.java @@ -0,0 +1,122 @@ +package es.codeurjcstudents.pcmod.integration; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.sql.SQLException; +import java.util.Arrays; +import java.util.List; + +import org.springframework.core.io.Resource; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import es.codeurjcstudents.pcmod.model.Image; +import es.codeurjcstudents.pcmod.repository.ImageRepository; +import es.codeurjcstudents.pcmod.service.ImageService; + +@Tag("server-integration") +@SpringBootTest +@Testcontainers +public class ImagesIntegrationTests { + + @Container + private static final MySQLContainer mysqlContainer = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("TestDB") + .withUsername("TestDBUser") + .withPassword("TestDBPassword"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", + () -> mysqlContainer.getJdbcUrl() + "?useSSL=false&allowPublicKeyRetrieval=true"); + + registry.add("spring.datasource.username", mysqlContainer::getUsername); + registry.add("spring.datasource.password", mysqlContainer::getPassword); + registry.add("spring.datasource.driver-class-name", mysqlContainer::getDriverClassName); + } + + @Autowired + private ImageService imageService; + + @Autowired + private ImageRepository imageRepository; + + @BeforeEach + void setUp() { + imageRepository.deleteAll(); + imageRepository.save(new Image()); + imageRepository.save(new Image()); + imageRepository.save(new Image()); + } + + @Test + void createImages() throws IOException { + + imageRepository.deleteAll(); + + List imageList = imageService.findAll(); + assertEquals(0, imageList.size()); + + Resource imagePath1 = new ClassPathResource("/sample_images/i5-12400f.webp"); + Image image1 = imageService.createImage(imagePath1.getInputStream()); + + Resource imagePath2 = new ClassPathResource("/sample_images/kingston-nv3.webp"); + Image image2 = imageService.createImage(imagePath2.getInputStream()); + + imageList = imageService.findAll(); + assertEquals(2, imageList.size()); + + List idList = imageList.stream().map(Image::getId).toList(); + assertTrue(idList.contains(image1.getId())); + assertTrue(idList.contains(image2.getId())); + + } + + @Test + void replaceImage() throws IOException, SQLException { + + imageRepository.deleteAll(); + + Resource originalPath = new ClassPathResource("/sample_images/kingston-nv3.webp"); + byte[] originalBytes = originalPath.getInputStream().readAllBytes(); + Image image = imageService.createImage(originalPath.getInputStream()); + + Resource replacementPath = new ClassPathResource("/sample_images/i5-12400f.webp"); + byte[] expectedBytes = replacementPath.getInputStream().readAllBytes(); + + Image updatedImage = imageService.replaceImageFile(image.getId(), replacementPath.getInputStream()); + byte[] updatedBytes = updatedImage.getImageFile().getBytes(1, (int) updatedImage.getImageFile().length()); + + assertEquals(image.getId(), updatedImage.getId()); + assertFalse(Arrays.equals(originalBytes, updatedBytes)); + assertArrayEquals(expectedBytes, updatedBytes); + + } + + @Test + void deleteImage() throws IOException { + + Long imageToDelete = imageRepository.findAll().getFirst().getId(); + imageService.deleteImage(imageToDelete); + + List imageList = imageService.findAll(); + assertEquals(2, imageList.size()); + + } + +} \ No newline at end of file diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/ImagesUnitTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/ImagesUnitTests.java new file mode 100644 index 0000000..896a048 --- /dev/null +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/ImagesUnitTests.java @@ -0,0 +1,120 @@ +package es.codeurjcstudents.pcmod.unit; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import org.springframework.core.io.Resource; +import org.springframework.mock.web.MockMultipartFile; + +import es.codeurjcstudents.pcmod.model.Image; +import es.codeurjcstudents.pcmod.repository.ImageRepository; +import es.codeurjcstudents.pcmod.service.ImageService; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.sql.SQLException; +import java.util.Optional; + +import javax.sql.rowset.serial.SerialBlob; +import javax.sql.rowset.serial.SerialException; + +@Tag("server-unit") +public class ImagesUnitTests { + + @Test + public void testCreateImage() throws SerialException, SQLException, IOException { + + ImageRepository imageRepository = mock(ImageRepository.class); + ImageService imageService = new ImageService(imageRepository); + + String classpathResource = "/sample_images/i5-12400f.webp"; + Resource imagePath = new ClassPathResource(classpathResource); + Image actualImage = imageService.createImage(imagePath.getInputStream()); + + Image expectedImage = new Image(); + expectedImage.setImageFile(new SerialBlob(imagePath.getInputStream().readAllBytes())); + + assertEquals(expectedImage.getImageFile(), actualImage.getImageFile()); + verify(imageRepository).save(actualImage); + + } + + @Test + public void testReplaceImage() throws SerialException, SQLException, IOException { + + ImageRepository imageRepository = mock(ImageRepository.class); + ImageService imageService = new ImageService(imageRepository); + + byte[] originalBytes = new byte[] { 1, 2, 3 }; + Image existingImage = new Image(); + existingImage.setImageFile(new SerialBlob(originalBytes)); + when(imageRepository.findById(1L)).thenReturn(Optional.of(existingImage)); + + String classpathResource = "/sample_images/i5-12400f.webp"; + Resource imagePath = new ClassPathResource(classpathResource); + Image updatedImage = imageService.replaceImageFile(1L, imagePath.getInputStream()); + byte[] updatedBytes = updatedImage.getImageFile().getBytes(1, (int) updatedImage.getImageFile().length()); + byte[] expectedBytes = imagePath.getInputStream().readAllBytes(); + + assertEquals(expectedBytes.length, updatedBytes.length); + assertNotEquals(originalBytes, updatedBytes); + verify(imageRepository).save(updatedImage); + + } + + @Test + public void testDeleteImage() throws IOException { + + ImageRepository imageRepository = mock(ImageRepository.class); + ImageService imageService = new ImageService(imageRepository); + + Image imageToDelete = new Image(); + when(imageRepository.findById(1L)).thenReturn(Optional.of(imageToDelete)); + + imageService.deleteImage(1L); + + verify(imageRepository).deleteById(1L); + + } + + @Test + void testValidateImage() { + + ImageRepository imageRepository = mock(ImageRepository.class); + ImageService imageService = new ImageService(imageRepository); + + MockMultipartFile validImage = new MockMultipartFile( + "image", "image.webp", "image/webp", new byte[1024]); + MockMultipartFile invalidTypeImage = new MockMultipartFile( + "image", "image.txt", "text/plain", new byte[1024]); + MockMultipartFile imageWithoutType = new MockMultipartFile( + "image", "image", null, new byte[1024]); + MockMultipartFile oversizedImage = new MockMultipartFile( + "image", "image.webp", "image/webp", new byte[10 * 1024 * 1024 + 1]); + + assertDoesNotThrow(() -> imageService.validate(validImage)); + assertThrows(IllegalArgumentException.class, () -> imageService.validate(invalidTypeImage)); + assertThrows(IllegalArgumentException.class, () -> imageService.validate(imageWithoutType)); + assertThrows(IllegalArgumentException.class, () -> imageService.validate(oversizedImage)); + + } + + @Test + public void testFindAllImages() { + ImageRepository imageRepository = mock(ImageRepository.class); + ImageService imageService = new ImageService(imageRepository); + + imageService.findAll(); + + verify(imageRepository).findAll(); + } + +} diff --git a/frontend/app/dtos/ImageDTO.ts b/frontend/app/dtos/ImageDTO.ts new file mode 100644 index 0000000..b1d0c13 --- /dev/null +++ b/frontend/app/dtos/ImageDTO.ts @@ -0,0 +1,3 @@ +export default interface ImageDTO { + id: number; +} \ No newline at end of file