diff --git a/backend/pcmod/pom.xml b/backend/pcmod/pom.xml index 34eccf0..0074851 100644 --- a/backend/pcmod/pom.xml +++ b/backend/pcmod/pom.xml @@ -29,12 +29,6 @@ spring-boot-starter-web - - org.springdoc - springdoc-openapi-starter-webmvc-ui - 3.0.0 - - org.springframework.boot spring-boot-starter-data-jpa diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/ComponentsRestController.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/ComponentsRestController.java index 288a807..6006aa5 100644 --- a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/ComponentsRestController.java +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/ComponentsRestController.java @@ -1,14 +1,20 @@ package es.codeurjcstudents.pcmod.controller; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.sql.SQLException; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.Resource; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.http.MediaTypeFactory; +import org.springframework.http.ResponseEntity; import es.codeurjcstudents.pcmod.dto.ComponentDTO; import es.codeurjcstudents.pcmod.service.ComponentsService; @@ -30,4 +36,13 @@ public List findTop3MoreRecent() { return componentService.findTop3MoreRecent().stream().map(componentService::toDTO).toList(); } + @GetMapping("/{id}/image") + public ResponseEntity getComponentImage(@PathVariable long id) throws SQLException { + + Resource imageFile = componentService.getImageFile(id); + MediaType mediaType = MediaTypeFactory.getMediaType(imageFile).orElse(MediaType.IMAGE_JPEG); + + return ResponseEntity.ok().contentType(mediaType).body(imageFile); + } + } diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/dto/ComponentDTO.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/dto/ComponentDTO.java index ed08d91..31da572 100644 --- a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/dto/ComponentDTO.java +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/dto/ComponentDTO.java @@ -9,5 +9,6 @@ public record ComponentDTO( String type, String brand, BigDecimal price, - Integer stock) { + Integer stock, + ImageDTO image) { } diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/model/Component.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/model/Component.java index 59e7b98..193620c 100644 --- a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/model/Component.java +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/model/Component.java @@ -11,6 +11,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.OneToOne; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -21,6 +22,9 @@ public class Component { @GeneratedValue(strategy = GenerationType.AUTO) private Long id = null; + @OneToOne + private Image image; + @NotBlank(message = "El nombre del componente es obligatorio") private String name; @@ -62,6 +66,14 @@ public void setId(Long id) { this.id = id; } + public Image getImage() { + return image; + } + + public void setImage(Image image) { + this.image = image; + } + public String getName() { return name; } diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/repository/ComponentsRepository.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/repository/ComponentsRepository.java index ce0eab4..8692586 100644 --- a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/repository/ComponentsRepository.java +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/repository/ComponentsRepository.java @@ -1,13 +1,19 @@ package es.codeurjcstudents.pcmod.repository; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import es.codeurjcstudents.pcmod.model.Component; +import es.codeurjcstudents.pcmod.model.Image; public interface ComponentsRepository extends JpaRepository { List findTop3ByOrderByIdDesc(); + @Query("SELECT c.image from Component c WHERE c.id = :id") + Optional findImageById(long id); + } \ No newline at end of file diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/ComponentsService.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/ComponentsService.java index 87c4772..3a7c6d0 100644 --- a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/ComponentsService.java +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/ComponentsService.java @@ -1,8 +1,12 @@ package es.codeurjcstudents.pcmod.service; +import java.sql.SQLException; import java.util.List; +import java.util.Optional; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -10,6 +14,7 @@ import es.codeurjcstudents.pcmod.dto.ComponentDTO; import es.codeurjcstudents.pcmod.dto.ComponentMapper; import es.codeurjcstudents.pcmod.model.Component; +import es.codeurjcstudents.pcmod.model.Image; import es.codeurjcstudents.pcmod.repository.ComponentsRepository; @Service @@ -35,4 +40,15 @@ public ComponentDTO toDTO(Component component) { public List findTop3MoreRecent() { return componentRepository.findTop3ByOrderByIdDesc(); } + + public Resource getImageFile(long id) throws SQLException { + + Optional imageOptional = componentRepository.findImageById(id); + + if (imageOptional.isPresent() && imageOptional.get().getImageFile() != null) { + return new InputStreamResource(imageOptional.get().getImageFile().getBinaryStream()); + } + return null; + + } } 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 77c6135..16ee900 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 @@ -1,14 +1,18 @@ package es.codeurjcstudents.pcmod.service; +import java.io.IOException; import java.math.BigDecimal; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; import es.codeurjcstudents.pcmod.enums.ComponentType; import es.codeurjcstudents.pcmod.model.Component; import es.codeurjcstudents.pcmod.model.User; +import es.codeurjcstudents.pcmod.model.Image; import es.codeurjcstudents.pcmod.repository.ComponentsRepository; import jakarta.annotation.PostConstruct; @@ -31,41 +35,49 @@ public class DatabaseInitializer { private ImageService imageService; @PostConstruct - public void init() { + public void init() throws IOException { // Sample Components - componentsRepository.save(new Component("AMD Ryzen 7 7800X3D", + Component component1 = new Component("AMD Ryzen 7 7800X3D", "8 núcleos y 16 hilos ideales para gaming\r\n" + // "96 MB de caché L3 3D V-Cache, baja latencia\r\n" + // "Compatible con memorias DDR5, doble canal\r\n" + // "Frecuencia base 4.2 GHz y turbo hasta 5.0 GHz", - ComponentType.CPU, "AMD", BigDecimal.valueOf(350.00), 5)); + ComponentType.CPU, "AMD", BigDecimal.valueOf(350.00), 5); + setComponentImage(component1, "/sample_images/ryzen-7.webp"); + componentsRepository.save(component1); - componentsRepository.save(new Component("NVIDIA GeForce RTX 5060 WINDFORCE MAX OC", + Component component2 = new Component("NVIDIA GeForce RTX 5060 WINDFORCE MAX OC", "Ray Tracing y DLSS 4 para gaming fluido\r\n" + // "8GB GDDR7 y arquitectura Blackwell\r\n" + // "WINDFORCE 2X: refrigeración eficiente y silenciosa\r\n" + // "DisplayPort 2.1 y HDMI 2.1b hasta 8K\r\n" + // "Tensor Cores 5ª gen y Reflex 2 para IA avanzada", - ComponentType.GPU, "Gigabyte", BigDecimal.valueOf(359.85), 2)); + ComponentType.GPU, "Gigabyte", BigDecimal.valueOf(359.85), 2); + setComponentImage(component2, "/sample_images/rtx-5060.jpg"); + componentsRepository.save(component2); - componentsRepository.save(new Component("Kingston NV3", + Component component3 = new Component("Kingston NV3", "Capacidad: 1 TB\r\n" + // "Factor de forma de disco SSD: M.2\r\n" + // "Interfaz: PCI Express 4.0\r\n" + // "NVMe: Si\r\n" + // "Tipo de memoria: 3D NAND", - ComponentType.STORAGE, "Kingston", BigDecimal.valueOf(149.95), 15)); + ComponentType.STORAGE, "Kingston", BigDecimal.valueOf(149.95), 15); + setComponentImage(component3, "/sample_images/kingston-nv3.webp"); + componentsRepository.save(component3); - componentsRepository.save(new Component("Corsair Vengeance LPX 2X8GB", + Component component4 = new Component("Corsair Vengeance LPX 2X8GB", "Latencia CAS: 16\r\n" + // "Memoria interna: 16 GB\r\n" + // "Tipo de memoria interna: DDR4\r\n" + // "Velocidad de memoria del reloj: 3200 MHz", - ComponentType.RAM, "Corsair", BigDecimal.valueOf(149.95), 2)); + ComponentType.RAM, "Corsair", BigDecimal.valueOf(149.95), 2); + setComponentImage(component4, "/sample_images/corsair-vengeance.webp"); + componentsRepository.save(component4); - componentsRepository.save(new Component("MAG A650BN", + Component component5 = new Component("MAG A650BN", "Potencia total: 650 W\r\n" + // "Voltaje de entrada AC: 100 - 240 V\r\n" + // "Frecuencia de entrada AC: 50/60 Hz\r\n" + // @@ -76,9 +88,11 @@ public void init() { "Corriente máxima de salida (+12V): 54 A\r\n" + // "Corriente máxima de salida (+5Vsb): 20 A\r\n" + // "Funciones de protección de poder: Sobreintensidad, Sobretensión, Sobrevoltaje, Cortocircuito", - ComponentType.PSU, "MSI", BigDecimal.valueOf(55.90), 20)); + ComponentType.PSU, "MSI", BigDecimal.valueOf(55.90), 20); + setComponentImage(component5, "/sample_images/mag-a650bn.webp"); + componentsRepository.save(component5); - componentsRepository.save(new Component("Asus TUF GAMING B550M-PLUS WIFI II", + Component component6 = new Component("Asus TUF GAMING B550M-PLUS WIFI II", "Fabricante de procesador: AMD\r\n" + // "Socket de procesador: Zócalo AM4\r\n" + // "Procesador compatible: AMD Ryzen 3, AMD Ryzen 5, AMD Ryzen 7, 3rd Generation AMD Ryzen 9, AMD Ryzen 9 5th Gen\r\n" @@ -88,9 +102,11 @@ public void init() { "Número de ranuras de memoria: 4\r\n" + // "Tipo de ranuras de memoria: DIMM\r\n" + // "Canales de memoria: Dual-channel", - ComponentType.MOTHERBOARD, "Asus", BigDecimal.valueOf(118.90), 15)); + ComponentType.MOTHERBOARD, "Asus", BigDecimal.valueOf(118.90), 15); + setComponentImage(component6, "/sample_images/asus-tuf-b550m.webp"); + componentsRepository.save(component6); - componentsRepository.save(new Component("Lian Li Vector V100", + Component component7 = new Component("Lian Li Vector V100", "Formato: ATX (Midi Tower), compatibilidad total con EATX, Micro ATX y Mini-ITX\r\n" + // "Materiales: Acero, Vidrio templado, Plástico. Acabado negro profesional y paneles transparentes\r\n" + // "Bahías de almacenamiento: 1 x 3.5\", 1 x 2.5\" (total 2 unidades soportadas)\r\n" + // @@ -101,9 +117,11 @@ public void init() { "Iluminación: ARGB multi, completamente personalizable\r\n" + // "Conectividad frontal: 2 x USB 3.0, 1 x USB Type-C, combo audio/micrófono\r\n" + // "Peso aproximado: 14,38 kg (volumétrico)", - ComponentType.CASE, "Lian Li", BigDecimal.valueOf(65.99), 5)); + ComponentType.CASE, "Lian Li", BigDecimal.valueOf(65.99), 5); + setComponentImage(component7, "/sample_images/lian-li-v100.webp"); + componentsRepository.save(component7); - componentsRepository.save(new Component("Intel Core i5-12400F", + Component component8 = new Component("Intel Core i5-12400F", "6 núcleos de alto rendimiento con 12 hilos\r\n" + // "Frecuencias de 2.5 a 4.4GHz para gaming avanzado\r\n" + // "Compatible con memorias DDR4 y DDR5 hasta 128GB\r\n" + // @@ -111,33 +129,42 @@ public void init() { "PCIe 5.0 y 4.0 para GPUs/SSDs de nueva generación\r\n" + // "No incluye gráficos integrados, necesita gráfica dedicada", ComponentType.CPU, "Intel", - BigDecimal.valueOf(153.97), 14)); + BigDecimal.valueOf(153.97), 14); + setComponentImage(component8, "/sample_images/i5-12400f.webp"); + componentsRepository.save(component8); - componentsRepository.save(new Component("AMD Radeon RX 9060 XT DUAL WHITE", + Component component9 = new Component("AMD Radeon RX 9060 XT DUAL WHITE", "16GB GDDR6 para gaming y creación profesional\r\n" + // "PCIe 5.0 y diseño blanco compacto\r\n" + // "Refrigeración dual ASUS Axial-tech silenciosa\r\n" + // "Soporta hasta 3 monitores simultáneos\r\n" + // "Frecuencias OC hasta 3 250 MHz", - ComponentType.GPU, "Asus", BigDecimal.valueOf(482.90), 5)); + ComponentType.GPU, "Asus", BigDecimal.valueOf(482.90), 5); + setComponentImage(component9, "/sample_images/rx-9060.jpg"); + componentsRepository.save(component9); - componentsRepository.save(new Component("Seagate BarraCuda 3.5", + Component component10 = new Component("Seagate BarraCuda 3.5", "Capacidad de 2 TB para grandes volúmenes de datos\r\n" + // "Velocidad de 7200 rpm para acceso rápido\r\n" + // "Interfaz SATA III de 6 Gb/s\r\n" + // "Buffer de 256 MB optimiza el rendimiento\r\n" + // "Compatible con PC de sobremesa y estaciones de trabajo\r\n" + // "Alta fiabilidad y durabilidad para uso intensivo", - ComponentType.STORAGE, "Seagate", BigDecimal.valueOf(121.99), 1)); + ComponentType.STORAGE, "Seagate", BigDecimal.valueOf(121.99), 1); + setComponentImage(component10, "/sample_images/seagate-barracuda.webp"); + componentsRepository.save(component10); - componentsRepository.save(new Component("Kingston FURY Beast", + Component component11 = new Component("Kingston FURY Beast", "Latencia CAS: 36\r\n" + // "Memoria interna: 16 GB\r\n" + // "Diseño de memoria (módulos x tamaño): 2 x 8 GB\r\n" + // "Tipo de memoria interna: DDR5\r\n" + // "Velocidad de memoria del reloj: 6000 MHz", - ComponentType.RAM, "Kingston", BigDecimal.valueOf(269.00), 15)); + ComponentType.RAM, "Kingston", BigDecimal.valueOf(269.00), 15); + setComponentImage(component11, "/sample_images/kingston-fury.jpg"); + componentsRepository.save(component11); + // Sample Users userService.createUser(new User("user", "example", "user_example", "c/example_address 1", "user@example.com", userPasswordEncoded, "REGISTERED_USER")); userService.createUser( @@ -146,4 +173,11 @@ public void init() { } + // Auxiliar functions + public void setComponentImage(Component sampleComponent, String classpathResource) throws IOException { + + Resource image = new ClassPathResource(classpathResource); + Image createdImage = imageService.createImage(image.getInputStream()); + sampleComponent.setImage(createdImage); + } } diff --git a/backend/pcmod/src/main/resources/sample_images/asus-tuf-b550m.webp b/backend/pcmod/src/main/resources/sample_images/asus-tuf-b550m.webp new file mode 100644 index 0000000..87cc6ca Binary files /dev/null and b/backend/pcmod/src/main/resources/sample_images/asus-tuf-b550m.webp differ diff --git a/backend/pcmod/src/main/resources/sample_images/corsair-vengeance.webp b/backend/pcmod/src/main/resources/sample_images/corsair-vengeance.webp new file mode 100644 index 0000000..abf6f3a Binary files /dev/null and b/backend/pcmod/src/main/resources/sample_images/corsair-vengeance.webp differ diff --git a/backend/pcmod/src/main/resources/sample_images/kingston-fury.jpg b/backend/pcmod/src/main/resources/sample_images/kingston-fury.jpg new file mode 100644 index 0000000..5b0e67a Binary files /dev/null and b/backend/pcmod/src/main/resources/sample_images/kingston-fury.jpg differ diff --git a/backend/pcmod/src/main/resources/sample_images/lian-li-v100.webp b/backend/pcmod/src/main/resources/sample_images/lian-li-v100.webp new file mode 100644 index 0000000..7cfc6bd Binary files /dev/null and b/backend/pcmod/src/main/resources/sample_images/lian-li-v100.webp differ diff --git a/backend/pcmod/src/main/resources/sample_images/mag-a650bn.webp b/backend/pcmod/src/main/resources/sample_images/mag-a650bn.webp new file mode 100644 index 0000000..282f8a7 Binary files /dev/null and b/backend/pcmod/src/main/resources/sample_images/mag-a650bn.webp differ diff --git a/backend/pcmod/src/main/resources/sample_images/rtx-5060.jpg b/backend/pcmod/src/main/resources/sample_images/rtx-5060.jpg new file mode 100644 index 0000000..bdde6cd Binary files /dev/null and b/backend/pcmod/src/main/resources/sample_images/rtx-5060.jpg differ diff --git a/backend/pcmod/src/main/resources/sample_images/rx-9060.jpg b/backend/pcmod/src/main/resources/sample_images/rx-9060.jpg new file mode 100644 index 0000000..b6b10a4 Binary files /dev/null and b/backend/pcmod/src/main/resources/sample_images/rx-9060.jpg differ diff --git a/backend/pcmod/src/main/resources/sample_images/ryzen-7.webp b/backend/pcmod/src/main/resources/sample_images/ryzen-7.webp new file mode 100644 index 0000000..a888ae0 Binary files /dev/null and b/backend/pcmod/src/main/resources/sample_images/ryzen-7.webp differ diff --git a/backend/pcmod/src/main/resources/sample_images/seagate-barracuda.webp b/backend/pcmod/src/main/resources/sample_images/seagate-barracuda.webp new file mode 100644 index 0000000..6a8131d Binary files /dev/null and b/backend/pcmod/src/main/resources/sample_images/seagate-barracuda.webp differ diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/ComponentsSystemTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/ComponentsSystemTests.java index 5ba9c19..b762b66 100644 --- a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/ComponentsSystemTests.java +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/ComponentsSystemTests.java @@ -10,6 +10,10 @@ import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +import java.io.IOException; +import java.io.InputStream; @Tag("server-system") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -47,4 +51,19 @@ public void getComponents() { .body("content[0].stock", equalTo(5)); } + @Test + public void getComponentImage() throws IOException { + byte[] expectedImage; + try (InputStream imageResource = getClass().getResourceAsStream("/sample_images/ryzen-7.webp")) { + expectedImage = imageResource.readAllBytes(); + } + + byte[] actualImage = given().header("Content-Type", "application/json") + .when().get("/components/1/image") + .then().statusCode(200).contentType("image/jpeg") + .extract().asByteArray(); + + assertArrayEquals(expectedImage, actualImage); + } + } diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/ComponentsSystemTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/ComponentsSystemTests.java index 2923ddc..f9953e0 100644 --- a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/ComponentsSystemTests.java +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/ComponentsSystemTests.java @@ -59,26 +59,41 @@ private void scrollToAndClick(By locator) { } @Test - public void loadComponentsTest() throws InterruptedException { + public void loadComponentsTest() { driver.get("http://localhost:5173/components"); - wait.until(ExpectedConditions.presenceOfElementLocated(By.id("name-1"))); - String componentName = driver.findElement(By.id("name-1")).getText(); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-1"))); + String componentName = driver.findElement(By.id("component-1")).getText(); assertThat(componentName).isEqualTo("AMD Ryzen 7 7800X3D"); - assertThat(driver.findElements(By.id("name-11"))).isEmpty(); + assertThat(driver.findElements(By.id("component-11"))).isEmpty(); scrollToAndClick(By.name("loadMore")); - wait.until(ExpectedConditions.presenceOfElementLocated(By.id("name-11"))); - String loadedComponentName = driver.findElement(By.id("name-11")).getText(); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-11"))); + String loadedComponentName = driver.findElement(By.id("component-11")).getText(); assertThat(loadedComponentName).isEqualTo("Kingston FURY Beast"); assertThat(driver.findElements(By.name("loadMore"))).isEmpty(); } + @Test + public void loadEmptyComponentsTest() { + + driver.get("http://localhost:5173/components/?page=2"); + + WebElement emptyMessageElement = wait.until( + ExpectedConditions.presenceOfElementLocated(By.id("empty-message"))); + String emptyMessage = emptyMessageElement.getText(); + + assertThat(emptyMessage).isEqualTo("No se han encontrado componentes de PC."); + assertThat(driver.findElements(By.cssSelector("[id^='component-']"))).isEmpty(); + assertThat(driver.findElements(By.name("loadMore"))).isEmpty(); + + } + @Test public void loadErrorTest() { diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/HomePageSystemTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/HomePageSystemTests.java index f86a595..fca1b6e 100644 --- a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/HomePageSystemTests.java +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/HomePageSystemTests.java @@ -10,10 +10,8 @@ import org.junit.jupiter.api.Test; import org.openqa.selenium.By; import org.openqa.selenium.WebDriver; -import org.openqa.selenium.WebElement; import org.openqa.selenium.chrome.ChromeDriver; import org.openqa.selenium.chrome.ChromeOptions; -import org.openqa.selenium.interactions.Actions; import org.openqa.selenium.support.ui.ExpectedConditions; import org.openqa.selenium.support.ui.WebDriverWait; @@ -46,18 +44,8 @@ public void teardown() { } } - private void scrollToAndClick(By locator) { - WebElement element = wait.until(ExpectedConditions.presenceOfElementLocated(locator)); - - new Actions(driver) - .scrollToElement(element) - .perform(); - - wait.until(ExpectedConditions.elementToBeClickable(element)).click(); - } - @Test - public void loadHomePage() throws InterruptedException { + public void loadHomePage() { driver.get("http://localhost:5173/"); @@ -85,15 +73,5 @@ public void loadHomePage() throws InterruptedException { assertThat(driver.findElements(By.name("componentsButton"))).isNotEmpty(); } - - @Test - public void navigateToComponentsPage() throws InterruptedException { - - driver.get("http://localhost:5173/"); - - scrollToAndClick(By.name("componentsButton")); - - wait.until(ExpectedConditions.urlToBe("http://localhost:5173/components")); - - } + } \ No newline at end of file diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/ComponentsIntegrationTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/ComponentsIntegrationTests.java index 2e27abd..d7930d5 100644 --- a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/ComponentsIntegrationTests.java +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/ComponentsIntegrationTests.java @@ -1,14 +1,19 @@ package es.codeurjcstudents.pcmod.integration; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import java.io.IOException; import java.math.BigDecimal; +import java.sql.SQLException; 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.core.io.Resource; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.test.context.DynamicPropertyRegistry; @@ -22,6 +27,7 @@ import es.codeurjcstudents.pcmod.model.Component; import es.codeurjcstudents.pcmod.repository.ComponentsRepository; import es.codeurjcstudents.pcmod.service.ComponentsService; +import es.codeurjcstudents.pcmod.service.ImageService; @Tag("server-integration") @SpringBootTest @@ -50,6 +56,9 @@ static void configureProperties(DynamicPropertyRegistry registry) { @Autowired private ComponentsRepository componentsRepository; + @Autowired + private ImageService imageService; + @BeforeEach void setUp() { componentsRepository.deleteAll(); @@ -78,4 +87,23 @@ void loadComponents() { assertEquals("AMD Ryzen 7 7800X3D", componentList.getContent().get(0).getName()); assertEquals("NVIDIA GeForce RTX 5060 WINDFORCE MAX OC", componentList.getContent().get(1).getName()); } + + @Test + public void testGetComponentImage() throws IOException, SQLException { + Component component = componentsRepository.findAll().get(0); + Resource expectedImage = new ClassPathResource("/sample_images/ryzen-7.webp"); + component.setImage(imageService.createImage(expectedImage.getInputStream())); + componentsRepository.save(component); + Resource actualImage = componentsService.getImageFile(component.getId()); + + assertArrayEquals(expectedImage.getInputStream().readAllBytes(), actualImage.getInputStream().readAllBytes()); + } + + @Test + public void testGetNullComponentImage() throws SQLException { + Resource actualImage = componentsService.getImageFile(1L); + + assertEquals(null, actualImage); + } + } \ No newline at end of file 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 index 41a930d..064a972 100644 --- a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/ImagesIntegrationTests.java +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/ImagesIntegrationTests.java @@ -28,6 +28,7 @@ import es.codeurjcstudents.pcmod.model.Image; import es.codeurjcstudents.pcmod.repository.ImageRepository; import es.codeurjcstudents.pcmod.service.ImageService; +import es.codeurjcstudents.pcmod.repository.ComponentsRepository; @Tag("server-integration") @SpringBootTest @@ -56,8 +57,12 @@ static void configureProperties(DynamicPropertyRegistry registry) { @Autowired private ImageRepository imageRepository; + @Autowired + private ComponentsRepository componentRepository; + @BeforeEach void setUp() { + componentRepository.deleteAll(); imageRepository.deleteAll(); imageRepository.save(new Image()); imageRepository.save(new Image()); @@ -109,7 +114,7 @@ void replaceImage() throws IOException, SQLException { } @Test - void deleteImage() throws IOException { + void deleteImage() { Long imageToDelete = imageRepository.findAll().getFirst().getId(); imageService.deleteImage(imageToDelete); diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/ComponentsUnitTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/ComponentsUnitTests.java index 1cd8381..075f0f7 100644 --- a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/ComponentsUnitTests.java +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/ComponentsUnitTests.java @@ -2,19 +2,28 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.sql.SQLException; import java.util.Arrays; import java.util.List; +import java.util.Optional; +import java.io.IOException; + +import javax.sql.rowset.serial.SerialBlob; +import org.springframework.core.io.Resource; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import es.codeurjcstudents.pcmod.model.Component; +import es.codeurjcstudents.pcmod.model.Image; import es.codeurjcstudents.pcmod.repository.ComponentsRepository; import es.codeurjcstudents.pcmod.service.ComponentsService; @@ -37,4 +46,33 @@ public void testComponentsLoad() { verify(componentsRepository).findAll(pageable); } + @Test + public void testGetComponentImage() throws SQLException, IOException { + ComponentsRepository componentsRepository = mock(ComponentsRepository.class); + ComponentsService componentsService = new ComponentsService(componentsRepository); + + byte[] originalBytes = new byte[] { 1, 2, 3 }; + Image existingImage = new Image(); + existingImage.setImageFile(new SerialBlob(originalBytes)); + when(componentsRepository.findImageById(1L)).thenReturn(Optional.of(existingImage)); + + Resource actualImage = componentsService.getImageFile(1L); + + assertArrayEquals(originalBytes, actualImage.getInputStream().readAllBytes()); + verify(componentsRepository).findImageById(1L); + } + + @Test + public void testGetNullComponentImage() throws SQLException { + ComponentsRepository componentsRepository = mock(ComponentsRepository.class); + ComponentsService componentsService = new ComponentsService(componentsRepository); + + when(componentsRepository.findImageById(1L)).thenReturn(Optional.empty()); + + Resource actualImage = componentsService.getImageFile(1L); + + assertEquals(null, actualImage); + verify(componentsRepository).findImageById(1L); + } + } diff --git a/frontend/app/app.css b/frontend/app/app.css index 2b5eae8..a824017 100644 --- a/frontend/app/app.css +++ b/frontend/app/app.css @@ -322,11 +322,13 @@ body { border-radius: 4px; } -.main h1, -.home-page h1 { +.main h1 { font-size: 1.8rem; font-weight: 600; margin-bottom: 1rem; +} + +#welcome { text-align: center; } @@ -351,7 +353,7 @@ body { color: #111827; } -.row-recentComponents { +.row-components { display: flex; flex-wrap: wrap; gap: 1.5rem; @@ -359,6 +361,17 @@ body { margin-bottom: 2.5rem; } +.empty-components-state { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.empty-components-state p { + margin-bottom: 0; +} + .component-card-col { flex: 1 1 calc(33.333% - 1.5rem); min-width: 260px; @@ -384,6 +397,18 @@ body { font-family: var(--font-sans); } +.load-more-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.load-more-container p { + margin-bottom: 1rem; + text-align: center; +} + /* Profile */ .main:has(.profile-data) { @@ -662,7 +687,7 @@ body { background-color: #ffffff; border-radius: 8px; overflow: hidden; - border: 1px solid #e5e7eb; + border: 2px solid #e5e7eb; transition: transform 0.2s ease, box-shadow 0.2s ease; height: 100%; } @@ -673,15 +698,21 @@ body { color: inherit; } -.component-card-link .bg-secondary { +.component-card-link .component-card-image { height: 200px !important; - background-color: #e5e7eb !important; + background-color: #ffffff !important; color: #9ca3af !important; position: relative; border-bottom: 1px solid #e5e7eb; align-content: center; } +.component-card-link .bg-secondary img { + width: 100%; + height: 100%; + object-fit: contain; +} + .component-card-link .bg-secondary svg { width: 48px; height: 48px; diff --git a/frontend/app/components/componentCard.tsx b/frontend/app/components/componentCard.tsx index 449b2c3..fc84d8c 100644 --- a/frontend/app/components/componentCard.tsx +++ b/frontend/app/components/componentCard.tsx @@ -1,29 +1,32 @@ import { Link } from "react-router"; +import { Image } from "react-bootstrap"; import { Image as ImageIcon } from "react-bootstrap-icons"; import type ComponentDTO from "~/dtos/ComponentDTO"; +const API_URL = "/api/v1"; + export default function ComponentCard({ component }: Readonly<{ component: ComponentDTO }>) { return (
-
- -
- {/* - component.image ? ( - {component.name} - ) - */} + <> +
+ {component.image ? ( + {component.name} + ) : ( + + ) + } +
-
-

{component.name}

-

{component.type} | {component.price} €

-
+
+

{component.name}

+

{component.type} | {component.price} €

+
+
); diff --git a/frontend/app/components/header.tsx b/frontend/app/components/header.tsx index 06742b7..697f104 100644 --- a/frontend/app/components/header.tsx +++ b/frontend/app/components/header.tsx @@ -24,7 +24,7 @@ export default function Header() { Logo - Componentes + Componentes
{user ? ( diff --git a/frontend/app/dtos/ComponentDTO.ts b/frontend/app/dtos/ComponentDTO.ts index d796546..5c5a3e8 100644 --- a/frontend/app/dtos/ComponentDTO.ts +++ b/frontend/app/dtos/ComponentDTO.ts @@ -1,3 +1,5 @@ +import type ImageDTO from "./ImageDTO"; + export default interface ComponentDTO { id: number; name: string; @@ -6,4 +8,5 @@ export default interface ComponentDTO { brand: string; price: number; stock: number; + image: ImageDTO } \ No newline at end of file diff --git a/frontend/app/routes/components.tsx b/frontend/app/routes/components.tsx index 877929e..a54baa3 100644 --- a/frontend/app/routes/components.tsx +++ b/frontend/app/routes/components.tsx @@ -1,6 +1,10 @@ import { useEffect, useState } from "react"; import type { Route } from "./+types/components"; import { getComponents } from "~/services/components-service"; +import Col from "react-bootstrap/esm/Col"; +import ComponentCard from "~/components/componentCard"; +import { Button } from "react-bootstrap"; +import { Plus, XOctagon } from "react-bootstrap-icons"; export async function clientLoader({ request }: Route.ClientLoaderArgs) { @@ -53,32 +57,39 @@ export default function Components({ loaderData }: Route.ComponentProps) { return (
-

Componentes:

-
    - {components.length > 0 && ( - components.map((component) => ( -
  • -

    {component.name}

    -

    Marca: {component.brand} | Tipo: {component.type} | Precio: {component.price} € | Stock: {component.stock} ud.

    -

    {component.description}

    -
    -
  • - )) - )} -
-
- {components.length > 0 && hasNext && ( - +

Componentes:

+
+ {components.length > 0 ? ( + <> + {components.map((component) => ( + + + + ))} + + ) : ( + + +

+ No se han encontrado componentes de PC. +

+ )} +
+
{loadMoreError && (

Hubo un problema al cargar más componentes. Por favor, pruebe de nuevo.

)} + {components.length > 0 && hasNext && ( + + )}
diff --git a/frontend/app/routes/index.tsx b/frontend/app/routes/index.tsx index 5c5eafc..18b22d0 100644 --- a/frontend/app/routes/index.tsx +++ b/frontend/app/routes/index.tsx @@ -45,7 +45,7 @@ export default function Index({ loaderData }: Route.ComponentProps) {

Descubra nuestras últimas novedades:

-
+
{recentComponents.length > 0 ? ( <> {recentComponents.map((component) => ( @@ -59,7 +59,7 @@ export default function Index({ loaderData }: Route.ComponentProps) { ) : ( - +

No se han encontrado componentes de PC. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a886ba8..21ca187 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "bootstrap": "^5.3.8", "isbot": "^5.1.36", "react": "^19.2.7", + "react-aria": "^3.53.1", "react-bootstrap": "^2.10.10", "react-bootstrap-icons": "^1.11.6", "react-dom": "^19.2.7", @@ -545,18 +546,18 @@ } }, "node_modules/@internationalized/date": { - "version": "3.12.3", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.3.tgz", - "integrity": "sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==", + "version": "3.12.4", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.4.tgz", + "integrity": "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==", "license": "Apache-2.0", "dependencies": { "@swc/helpers": "^0.5.0" } }, "node_modules/@internationalized/number": { - "version": "3.6.7", - "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.7.tgz", - "integrity": "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==", + "version": "3.6.9", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.9.tgz", + "integrity": "sha512-GgWy2caO/+hV9sT/oCTWGHvbjmJMPT9kkXzoKRLnkEq7adghhR7UprhvY9sXrKfrp3NcDfhF6QQHXR1KfQbB8g==", "license": "Apache-2.0", "dependencies": { "@swc/helpers": "^0.5.0" @@ -797,9 +798,9 @@ } }, "node_modules/@react-types/shared": { - "version": "3.36.1", - "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.1.tgz", - "integrity": "sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag==", + "version": "3.37.0", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.37.0.tgz", + "integrity": "sha512-VNlZuifu4tHdxWPfHvoMfKsHA9kVTCGkduv5vKVTdinm8wHFJNhyVB5RzIMiRFj0q3RKoCbP68u2zaoBSiFueg==", "license": "Apache-2.0", "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" @@ -3565,19 +3566,19 @@ } }, "node_modules/react-aria": { - "version": "3.51.0", - "resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.51.0.tgz", - "integrity": "sha512-AyWLw0XR38cFPwBu/ErgGaVrc5dupLEKmRlMXTGvFKOtbaGRQ2+yQJkjVhpdHhoRhU4+G+tJDFeHDTS8tK3bfQ==", + "version": "3.53.1", + "resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.53.1.tgz", + "integrity": "sha512-dikisyOjJd3D2fFrIDWmVWzqiLz5B86GzZv1zxs7xI4dVc0JIz/1Wa52I20gYvVL2WXeFXlygYsle/QcOMhtcg==", "license": "Apache-2.0", "dependencies": { - "@internationalized/date": "^3.12.3", - "@internationalized/number": "^3.6.7", + "@internationalized/date": "^3.12.4", + "@internationalized/number": "^3.6.9", "@internationalized/string": "^3.2.10", - "@react-types/shared": "^3.36.1", + "@react-types/shared": "^3.37.0", "@swc/helpers": "^0.5.0", "aria-hidden": "^1.2.3", "clsx": "^2.0.0", - "react-stately": "3.49.0", + "react-stately": "3.51.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { @@ -3686,15 +3687,15 @@ } }, "node_modules/react-stately": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.49.0.tgz", - "integrity": "sha512-13iNq2KzBrRAzxRc+n53hgROfIistiYY/sPtIhCw1qUB7/kmo+X1xEU2uiS5zcCIrc55AUPwoHqOIIpKWSwB9A==", + "version": "3.51.0", + "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.51.0.tgz", + "integrity": "sha512-YJcmkAXu2XOQP97hO/qz/cC3qv9kni3oVhwBlxWr358BHePJUpY15XwL3YSRMKxYt4EkslPqAz11JRirMfANGw==", "license": "Apache-2.0", "dependencies": { - "@internationalized/date": "^3.12.3", - "@internationalized/number": "^3.6.7", + "@internationalized/date": "^3.12.4", + "@internationalized/number": "^3.6.9", "@internationalized/string": "^3.2.10", - "@react-types/shared": "^3.36.1", + "@react-types/shared": "^3.37.0", "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.6.0" }, diff --git a/frontend/package.json b/frontend/package.json index 151c8e2..3a07f4c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "bootstrap": "^5.3.8", "isbot": "^5.1.36", "react": "^19.2.7", + "react-aria": "^3.53.1", "react-bootstrap": "^2.10.10", "react-bootstrap-icons": "^1.11.6", "react-dom": "^19.2.7", diff --git a/frontend/tests/integration/Components.integration.test.tsx b/frontend/tests/integration/Components.integration.test.tsx index 9148c37..d5ad5de 100644 --- a/frontend/tests/integration/Components.integration.test.tsx +++ b/frontend/tests/integration/Components.integration.test.tsx @@ -1,12 +1,13 @@ import { render, screen, waitFor } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import "@testing-library/jest-dom"; import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router"; import Components, { clientLoader } from "~/routes/components"; describe("ComponentsPageIntegration", () => { - it("check communication between frontend and backend", async () => { + it("renders initial components and checks load more functionality", async () => { const user = userEvent.setup(); @@ -20,18 +21,20 @@ describe("ComponentsPageIntegration", () => { } as any); render( - + + + ); await waitFor(() => { const initialComponentsNames = screen.getAllByRole("heading", { level: 3 }); - expect(initialComponentsNames.length).toBe(10); + expect(initialComponentsNames).toHaveLength(10); expect(initialComponentsNames[0]).toHaveTextContent("AMD Ryzen 7 7800X3D"); }); @@ -41,7 +44,7 @@ describe("ComponentsPageIntegration", () => { await waitFor(() => { const updatedComponentsNames = screen.getAllByRole("heading", { level: 3 }); - expect(updatedComponentsNames.length).toBe(11); + expect(updatedComponentsNames).toHaveLength(11); expect(updatedComponentsNames[10]).toHaveTextContent("Kingston FURY Beast"); }); @@ -50,4 +53,68 @@ describe("ComponentsPageIntegration", () => { }); + it("renders an empty state when there are no components", async () => { + + render( + + ); + + await waitFor(() => { + expect(screen.getByText("No se han encontrado componentes de PC.")).toBeInTheDocument(); + }); + + expect(screen.queryAllByRole("heading", { level: 3 })).toHaveLength(0); + expect(screen.queryByRole("button", { name: /Cargar más componentes/i })).not.toBeInTheDocument(); + }); + + it("shows an error when loading more components fails", async () => { + const user = userEvent.setup(); + const firstComponent = { + id: 1, + name: "AMD Ryzen 7 7800X3D", + description: "Procesador gaming de alto rendimiento.", + type: "CPU", + brand: "AMD", + price: 350.0, + stock: 5, + }; + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("Network error")); + + render( + + + + ); + + await user.click(screen.getByRole("button", { name: /Cargar más componentes/i })); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + "Hubo un problema al cargar más componentes. Por favor, pruebe de nuevo.", + ); + }); + expect(screen.getByRole("button", { name: /Cargar más componentes/i })).toBeInTheDocument(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + fetchSpy.mockRestore(); + }); + }); \ No newline at end of file diff --git a/frontend/tests/unit/ComponentsPage.test.tsx b/frontend/tests/unit/ComponentsPage.test.tsx index 5ebc32c..d3a07ed 100644 --- a/frontend/tests/unit/ComponentsPage.test.tsx +++ b/frontend/tests/unit/ComponentsPage.test.tsx @@ -1,9 +1,10 @@ import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import "@testing-library/jest-dom"; import { getComponents } from "~/services/components-service"; import Components, { clientLoader } from "~/routes/components"; -import { createRoutesStub } from "react-router"; +import { MemoryRouter, createRoutesStub } from "react-router"; vi.mock("~/services/components-service", () => ({ getComponents: vi.fn(), @@ -66,8 +67,142 @@ describe("ComponentsPage", () => { expect(screen.getByText("NVIDIA GeForce RTX 5060 WINDFORCE MAX OC")).toBeInTheDocument(); }); - const items = screen.getAllByRole("listitem"); + const items = screen.getAllByRole("heading", { level: 3 }); expect(items).toHaveLength(2); }); + + it("renders an empty state when there are no components", async () => { + vi.mocked(getComponents).mockResolvedValue({ + items: [], + hasNext: false, + }); + + const RouterStub = createRoutesStub([ + { + path: "/components", + Component: Components, + loader: async ({ request }) => { + const url = new URL(request.url); + const page = Number(url.searchParams.get("page") ?? 0); + const result = await getComponents(page); + return { items: result.items, hasNext: result.hasNext }; + }, + }, + ]); + + render(); + + expect(vi.mocked(getComponents)).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(screen.getByText("No se han encontrado componentes de PC.")).toBeInTheDocument(); + }); + + expect(screen.queryAllByRole("heading", { level: 3 })).toHaveLength(0); + expect(screen.queryByRole("button", { name: /Cargar más componentes/i })).not.toBeInTheDocument(); + }); + + it("clientLoader returns the correct data", async () => { + const sampleMockData = { + items: [], + hasNext: false, + }; + vi.mocked(getComponents).mockResolvedValue(sampleMockData); + + const result = await clientLoader({ + request: new Request("http://localhost/components?page=0"), + } as Parameters[0]); + + expect(getComponents).toHaveBeenCalledTimes(1); + expect(result).toEqual(sampleMockData); + }); + + it("checks handleLoadMore functionality", async () => { + const user = userEvent.setup(); + + const firstComponent = { + id: 1, + name: "AMD Ryzen 7 7800X3D", + description: "Procesador gaming de alto rendimiento.", + type: "CPU", + brand: "AMD", + price: 350.0, + stock: 5, + }; + + const nextComponent = { + id: 2, + name: "NVIDIA GeForce RTX 5060", + description: "Tarjeta gráfica para gaming.", + type: "GPU", + brand: "NVIDIA", + price: 359.85, + stock: 2, + }; + + vi.mocked(getComponents).mockResolvedValue({ + items: [nextComponent], + hasNext: false, + }); + + render( + + + , + ); + + const loadMoreButton = screen.getByRole("button", { name: /Cargar más componentes/i }); + await user.click(loadMoreButton); + + await waitFor(() => { + expect(getComponents).toHaveBeenCalledWith(1); + expect(screen.getByText("NVIDIA GeForce RTX 5060")).toBeInTheDocument(); + }); + + expect(screen.getAllByRole("heading", { level: 3 })).toHaveLength(2); + expect(screen.queryByRole("button", { name: /Cargar más componentes/i })).not.toBeInTheDocument(); + }); + + it("shows an error when loading more components fails", async () => { + const user = userEvent.setup(); + const firstComponent = { + id: 1, + name: "AMD Ryzen 7 7800X3D", + description: "Procesador gaming de alto rendimiento.", + type: "CPU", + brand: "AMD", + price: 350.0, + stock: 5, + }; + + vi.mocked(getComponents).mockRejectedValueOnce(new Error("Network error")); + + render( + + + , + ); + + await user.click(screen.getByRole("button", { name: /Cargar más componentes/i })); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + "Hubo un problema al cargar más componentes. Por favor, pruebe de nuevo.", + ); + }); + + expect(getComponents).toHaveBeenCalledWith(1); + expect(screen.getByRole("button", { name: /Cargar más componentes/i })).toBeInTheDocument(); + }); }); \ No newline at end of file