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 3b5dbff..a02adcb 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 @@ -3,9 +3,17 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import static org.springframework.web.servlet.support.ServletUriComponentsBuilder.fromCurrentRequest; + +import java.io.IOException; +import java.net.URI; import java.sql.SQLException; import java.util.List; @@ -18,7 +26,9 @@ import org.springframework.http.ResponseEntity; import es.codeurjcstudents.pcmod.dto.ComponentDTO; +import es.codeurjcstudents.pcmod.dto.ImageDTO; import es.codeurjcstudents.pcmod.model.Component; +import es.codeurjcstudents.pcmod.model.Image; import es.codeurjcstudents.pcmod.service.ComponentsService; @RestController @@ -55,6 +65,28 @@ public ResponseEntity getComponentImage(@PathVariable long id) throws return ResponseEntity.ok().contentType(mediaType).body(imageFile); } + @PostMapping("/") + public ResponseEntity createComponent(@RequestBody ComponentDTO componentDTO) { + + Component component = componentService.toDomain(componentDTO); + componentService.save(component); + + URI location = fromCurrentRequest().path("/{id}").buildAndExpand(component.getId()).toUri(); + + return ResponseEntity.created(location).body(componentService.toDTO(component)); + } + + @PostMapping("/{id}/image") + public ResponseEntity addComponentImage(@PathVariable long id, @RequestParam MultipartFile imageFile) + throws IOException { + + Image image = componentService.addComponentImage(id, imageFile); + + URI location = fromCurrentRequest().buildAndExpand(id).toUri(); + + return ResponseEntity.created(location).body(new ImageDTO(image.getId())); + } + @DeleteMapping("/{id}") public void deleteComponent(@PathVariable long id) { diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/ComponentsTypeRestController.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/ComponentsTypeRestController.java new file mode 100644 index 0000000..cdd0fda --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/ComponentsTypeRestController.java @@ -0,0 +1,18 @@ +package es.codeurjcstudents.pcmod.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import es.codeurjcstudents.pcmod.enums.ComponentType; + +@RestController +@RequestMapping("/api/v1/component-types") +public class ComponentsTypeRestController { + + @GetMapping + public ComponentType[] getComponentTypes() { + return ComponentType.values(); + } + +} 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 8692586..804f632 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 @@ -16,4 +16,6 @@ public interface ComponentsRepository extends JpaRepository { @Query("SELECT c.image from Component c WHERE c.id = :id") Optional findImageById(long id); + boolean existsByName(String name); + } \ No newline at end of file diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/SecurityConfig.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/SecurityConfig.java index 1782c31..3842421 100644 --- a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/SecurityConfig.java +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/SecurityConfig.java @@ -59,6 +59,8 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { .exceptionHandling(handling -> handling.authenticationEntryPoint(unauthorizedHandlerJwt)); http.authorizeHttpRequests(authorize -> authorize + .requestMatchers(HttpMethod.POST, "/api/v1/components/**").hasRole("ADMIN") + .requestMatchers(HttpMethod.DELETE, "/api/v1/components/**").hasRole("ADMIN") .requestMatchers(HttpMethod.GET, "/api/v1/users/**").hasRole("REGISTERED_USER") .requestMatchers(HttpMethod.POST, "/api/v1/users/*/image").hasRole("REGISTERED_USER") .requestMatchers(HttpMethod.DELETE, "/api/v1/users/**").hasRole("REGISTERED_USER") 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 b4d531a..9837f3c 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,6 +1,9 @@ package es.codeurjcstudents.pcmod.service; +import java.io.IOException; +import java.math.BigDecimal; import java.sql.SQLException; +import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -10,6 +13,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; import org.springframework.stereotype.Service; @@ -23,22 +27,20 @@ public class ComponentsService { private final ComponentsRepository componentRepository; - private final ComponentMapper componentMapper; + private final ImageService imagesService; + - public ComponentsService(ComponentsRepository componentRepository, ComponentMapper componentMapper) { + public ComponentsService(ComponentsRepository componentRepository, ComponentMapper componentMapper, ImageService imagesService) { this.componentRepository = componentRepository; this.componentMapper = componentMapper; + this.imagesService = imagesService; } public Page findAll(Pageable pageable) { return componentRepository.findAll(pageable); } - public ComponentDTO toDTO(Component component) { - return componentMapper.toDTO(component); - } - public List findTop3MoreRecent() { return componentRepository.findTop3ByOrderByIdDesc(); } @@ -59,6 +61,32 @@ public Resource getImageFile(long id) throws SQLException { } + @PreAuthorize ("hasRole('ADMIN')") + public Component save(Component component) { + + List errors = validateComponent(component); + + if (!errors.isEmpty()) { + throw new IllegalArgumentException("Validación fallida:\n• " + String.join("\n• ", errors)); + } + + return componentRepository.save(component); + } + + @PreAuthorize("hasRole('ADMIN')") + public Image addComponentImage(long id, MultipartFile imageFile) throws IOException { + + imagesService.validate(imageFile); + + Component component = componentRepository.findById(id).orElseThrow(); + + Image image = imagesService.createImage(imageFile.getInputStream()); + + component.setImage(image); + + return componentRepository.save(component).getImage(); + } + @PreAuthorize("hasRole('ADMIN')") public void delete(long id) { @@ -71,4 +99,74 @@ public void delete(long id) { } } + + public List validateComponent(Component component) { + + List errors = new ArrayList<>(); + + if (component.getName() == null || component.getName().isEmpty()) { + errors.add("El nombre del componente es obligatorio."); + } else { + validateName(component, errors); + } + + validateRequiredField(component.getDescription(), "La descripción es obligatoria.", errors); + validateRequiredField(component.getBrand(), "La marca es obligatoria.", errors); + validateRequiredField(component.getType().toString(), "El tipo es obligatoria.", errors); + + if (component.getPrice() == null || component.getPrice().toString().isEmpty()) { + errors.add("El precio es obligatorio."); + } else { + validatePrice(component, errors); + } + + if (component.getStock() == null || component.getStock().toString().isEmpty()) { + errors.add("El stock es obligatorio."); + } else { + validateStock(component, errors); + } + + return errors; + + } + + private void validateRequiredField(String value, String message, List errors) { + + if (value == null || value.isEmpty()) { + errors.add(message); + } + + } + + public void validateName(Component component, List errors) { + + if (componentRepository.existsByName(component.getName())) { + errors.add("Ya existe un componente con ese nombre."); + } + + } + + public void validatePrice(Component component, List errors) { + + if (component.getPrice().compareTo(BigDecimal.ZERO) <= 0) { + errors.add("El precio del producto ha de ser mayor de 0.00 €."); + } + + } + + public void validateStock(Component component, List errors) { + + if (component.getStock() < 0) { + errors.add("El stock del producto ha de ser 0 o mayor."); + } + + } + + public ComponentDTO toDTO(Component component) { + return componentMapper.toDTO(component); + } + + public Component toDomain(ComponentDTO componentDTO) { + return componentMapper.toDomain(componentDTO); + } } 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 0af5c5b..b59400a 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 @@ -73,7 +73,7 @@ public void init() throws IOException { "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), 0); setComponentImage(component4, "/sample_images/corsair-vengeance.webp"); componentsRepository.save(component4); 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 8bd32fa..978d939 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 @@ -67,7 +67,7 @@ public void getComponent() { @Test public void getComponentNotFound() { given().header("Content-Type", "application/json") - .when().get("/components/13") + .when().get("/components/15") .then().statusCode(404).contentType(ContentType.JSON) .body("error", equalTo("Not Found")) .body("message", equalTo("Componente no encontrado.")); @@ -88,6 +88,354 @@ public void getComponentImage() throws IOException { assertArrayEquals(expectedImage, actualImage); } + @Test + public void postComponentAsNoLoggedIn() { + given() + .header("Content-Type", "application/json") + .body(""" + { + "name": "AMD Ryzen 5 9600X", + "description": "El AMD Ryzen 5 9600X destaca por su arquitectura de 4 nm y el nuevo socket AM5.", + "type" : "CPU", + "brand" : "AMD", + "price" : 182.95, + "stock" : 7 + } + """) + .when().post("/components/") + .then().statusCode(401).contentType(ContentType.JSON) + .body("error", equalTo("Unauthorized")); + } + + @Test + public void postComponentAsRegisteredUserNoAdmin() { + String authToken = given() + .header("Content-Type", "application/json") + .body(""" + { + "username": "user@example.com", + "password": "userpass" + } + """) + .when().post("auth/login") + .then().statusCode(200).contentType(ContentType.JSON) + .extract().cookie("AuthToken"); + + given() + .cookie("AuthToken", authToken) + .header("Content-Type", "application/json") + .body(""" + { + "name": "AMD Ryzen 5 9600X", + "description": "El AMD Ryzen 5 9600X destaca por su arquitectura de 4 nm y el nuevo socket AM5.", + "type" : "CPU", + "brand" : "AMD", + "price" : 182.95, + "stock" : 7 + } + """) + .when().post("/components/") + .then().statusCode(403).contentType(ContentType.JSON) + .body("error", equalTo("Forbidden")); + } + + @Test + public void postComponentAsAdmin() { + String authToken = given() + .header("Content-Type", "application/json") + .body(""" + { + "username": "admin@example.com", + "password": "adminpass" + } + """) + .when().post("auth/login") + .then().statusCode(200).contentType(ContentType.JSON) + .extract().cookie("AuthToken"); + + given() + .cookie("AuthToken", authToken) + .header("Content-Type", "application/json") + .body(""" + { + "name": "AMD Ryzen 5 9600X", + "description": "El AMD Ryzen 5 9600X destaca por su arquitectura de 4 nm y el nuevo socket AM5.", + "type" : "CPU", + "brand" : "AMD", + "price" : 182.95, + "stock" : 7 + } + """) + .when().post("/components/") + .then().statusCode(201).contentType(ContentType.JSON) + .body("id", notNullValue()) + .body("name", equalTo("AMD Ryzen 5 9600X")) + .body("description", equalTo("El AMD Ryzen 5 9600X destaca por su arquitectura de 4 nm y el nuevo socket AM5.")) + .body("type", equalTo("CPU")) + .body("brand", equalTo("AMD")) + .body("price", equalTo(182.95F)) + .body("stock", equalTo(7)); + } + + @Test + public void postVoidComponentData() { + String authToken = given() + .header("Content-Type", "application/json") + .body(""" + { + "username": "admin@example.com", + "password": "adminpass" + } + """) + .when().post("auth/login") + .then().statusCode(200).contentType(ContentType.JSON) + .extract().cookie("AuthToken"); + + given() + .cookie("AuthToken", authToken) + .header("Content-Type", "application/json") + .body(""" + { + "name": "", + "description": "", + "type": "CPU", + "brand" : "", + "price": 182.95, + "stock": 7 + } + """) + .when().post("/components/") + .then().statusCode(500).contentType(ContentType.JSON) + .body("message", equalTo( + "Validación fallida:\n• El nombre del componente es obligatorio.\n• La descripción es obligatoria.\n• La marca es obligatoria.")); + } + + @Test + public void postNullComponentData() { + String authToken = given() + .header("Content-Type", "application/json") + .body(""" + { + "username": "admin@example.com", + "password": "adminpass" + } + """) + .when().post("auth/login") + .then().statusCode(200).contentType(ContentType.JSON) + .extract().cookie("AuthToken"); + + given() + .cookie("AuthToken", authToken) + .header("Content-Type", "application/json") + .body(""" + { + "name": null, + "description": null, + "type": "CPU", + "brand" : null, + "price": 182.95, + "stock": 7 + } + """) + .when().post("/components/") + .then().statusCode(500).contentType(ContentType.JSON) + .body("message", equalTo( + "Validación fallida:\n• El nombre del componente es obligatorio.\n• La descripción es obligatoria.\n• La marca es obligatoria.")); + } + + @Test + public void postExistingComponentData() { + String authToken = given() + .header("Content-Type", "application/json") + .body(""" + { + "username": "admin@example.com", + "password": "adminpass" + } + """) + .when().post("auth/login") + .then().statusCode(200).contentType(ContentType.JSON) + .extract().cookie("AuthToken"); + + given() + .cookie("AuthToken", authToken) + .header("Content-Type", "application/json") + .body(""" + { + "name": "Seagate BarraCuda 3.5", + "description": "Sample description.", + "type" : "STORAGE", + "brand" : "Seagate", + "price" : 100.00, + "stock" : 2 + } + """) + .when().post("/components/") + .then().statusCode(500).contentType(ContentType.JSON) + .body("status", equalTo(500)) + .body("message", equalTo("Validación fallida:\n• Ya existe un componente con ese nombre.")); + } + + @Test + public void postBadNumComponentData() { + String authToken = given() + .header("Content-Type", "application/json") + .body(""" + { + "username": "admin@example.com", + "password": "adminpass" + } + """) + .when().post("auth/login") + .then().statusCode(200).contentType(ContentType.JSON) + .extract().cookie("AuthToken"); + + given() + .cookie("AuthToken", authToken) + .header("Content-Type", "application/json") + .body(""" + { + "name": "sampleComponent", + "description": "sampleDescription", + "type" : "CPU", + "brand" : "sampleBrand", + "price" : "-5", + "stock" : "-5" + } + """) + .when().post("/components/") + .then().statusCode(500).contentType(ContentType.JSON) + .body("status", equalTo(500)) + .body("message", equalTo( + "Validación fallida:\n• El precio del producto ha de ser mayor de 0.00 €.\n• El stock del producto ha de ser 0 o mayor.")); + } + + @Test + public void postBadFormatComponentData() { + String authToken = given() + .header("Content-Type", "application/json") + .body(""" + { + "username": "admin@example.com", + "password": "adminpass" + } + """) + .when().post("auth/login") + .then().statusCode(200).contentType(ContentType.JSON) + .extract().cookie("AuthToken"); + + given() + .cookie("AuthToken", authToken) + .header("Content-Type", "application/json") + .body(""" + { + "name": "AMD Ryzen 5 9600X", + "description": "El AMD Ryzen 5 9600X destaca por su arquitectura de 4 nm y el nuevo socket AM5.", + "type" : "CPU", + "brand" : "AMD", + "price" : "bad_format", + "stock" : "bad_format" + } + """) + .when().post("/components/") + .then().statusCode(400).contentType(ContentType.JSON) + .body("status", equalTo(400)) + .body("message", equalTo( + "JSON parse error: Cannot deserialize value of type `java.math.BigDecimal` from String \"bad_format\": not a valid representation")); + } + + @Test + public void postComponentImageAsNoLoggedIn() throws IOException { + byte[] expectedImage; + try (InputStream imageResource = getClass().getResourceAsStream("/sample_images/ryzen-7.webp")) { + expectedImage = imageResource.readAllBytes(); + } + + given() + .multiPart("imageFile", "ryzen-7.webp", expectedImage, "image/webp") + .when().post("components/2/image") + .then().statusCode(401).contentType(ContentType.JSON) + .body("error", equalTo("Unauthorized")); + } + + @Test + public void postComponentImageAsRegisteredUserNoAdmin() throws IOException { + String authToken = given() + .header("Content-Type", "application/json") + .body(""" + { + "username": "user@example.com", + "password": "userpass" + } + """) + .when().post("auth/login") + .then().statusCode(200).contentType(ContentType.JSON) + .extract().cookie("AuthToken"); + + byte[] expectedImage; + try (InputStream imageResource = getClass().getResourceAsStream("/sample_images/ryzen-7.webp")) { + expectedImage = imageResource.readAllBytes(); + } + + given() + .cookie("AuthToken", authToken) + .multiPart("imageFile", "ryzen-7.webp", expectedImage, "image/webp") + .when().post("components/2/image") + .then().statusCode(403).contentType(ContentType.JSON) + .body("error", equalTo("Forbidden")); + } + + @Test + public void postComponentImageAsAdmin() throws IOException { + String authToken = given() + .header("Content-Type", "application/json") + .body(""" + { + "username": "admin@example.com", + "password": "adminpass" + } + """) + .when().post("auth/login") + .then().statusCode(200).contentType(ContentType.JSON) + .extract().cookie("AuthToken"); + + byte[] expectedImage; + try (InputStream imageResource = getClass().getResourceAsStream("/sample_images/ryzen-7.webp")) { + expectedImage = imageResource.readAllBytes(); + } + + given() + .cookie("AuthToken", authToken) + .multiPart("imageFile", "ryzen-7.webp", expectedImage, "image/webp") + .when().post("components/2/image") + .then().statusCode(201).contentType(ContentType.JSON); + } + + @Test + public void postComponentImageWithInvalidFile() { + String authToken = given() + .header("Content-Type", "application/json") + .body(""" + { + "username": "admin@example.com", + "password": "adminpass" + } + """) + .when().post("auth/login") + .then().statusCode(200).contentType(ContentType.JSON) + .extract().cookie("AuthToken"); + + byte[] invalidTypeImage = new byte[1024]; + + given() + .cookie("AuthToken", authToken) + .multiPart("imageFile", "test.txt", invalidTypeImage, "text/plain") + .when().post("components/2/image") + .then().statusCode(500).contentType(ContentType.JSON) + .body("status", equalTo(500)) + .body("message", equalTo("El tipo de archivo no es válido. Solo se permiten imágenes JPEG, PNG y WebP.")); + } + @Test public void deleteComponentAsNotLoggedIn() { @@ -142,4 +490,14 @@ public void deleteComponentAsAdmin() { .then().statusCode(200); } + @Test + public void loadComponentTypes() { + given() + .header("Content-Type", "application/json") + .when().get("/component-types") + .then().statusCode(200).contentType(ContentType.JSON) + .body("", hasSize(7)) + .body("", hasItems("CPU", "GPU", "RAM", "STORAGE", "MOTHERBOARD", "PSU", "CASE")); + } + } 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 2e6ef34..bb9c847 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 @@ -2,6 +2,9 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.time.Duration; import java.util.Map; @@ -16,6 +19,7 @@ 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.Select; import org.openqa.selenium.support.ui.WebDriverWait; @Tag("client-system") @@ -58,6 +62,21 @@ private void scrollToAndClick(By locator) { } + private void login(String email, String pass) { + + driver.get("http://localhost:5173/login"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("email"))); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("password"))); + driver.findElement(By.id("email")).sendKeys(email); + driver.findElement(By.id("password")).sendKeys(pass); + + scrollToAndClick(By.name("login-button")); + + wait.until(ExpectedConditions.urlToBe("http://localhost:5173/")); + + } + @Test public void loadComponentsTest() { @@ -79,6 +98,39 @@ public void loadComponentsTest() { } + @Test + public void componentCreationOptionAsNonLoggedInTest() { + + driver.get("http://localhost:5173/components"); + + assertThat(driver.findElements(By.id("create-component-button"))).isEmpty(); + + } + + @Test + public void componentCreationOptionAsLoggedInNoAdminTest() { + + login("user@example.com", "userpass"); + + driver.get("http://localhost:5173/components"); + + assertThat(driver.findElements(By.id("create-component-button"))).isEmpty(); + + } + + @Test + public void componentCreationOptionAsAdminTest() { + + login("admin@example.com", "adminpass"); + + driver.get("http://localhost:5173/components"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("create-component-button"))); + + assertThat(driver.findElements(By.id("create-component-button"))).isNotEmpty(); + + } + @Test public void loadEmptyComponentsTest() { @@ -170,6 +222,18 @@ public void componentDetailRenderLastUnitsTest() { } + @Test + public void componentDetailRenderNoStockTest() { + + driver.get("http://localhost:5173/components/4"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-stock"))); + String componentStockMessage = driver.findElement(By.id("component-stock")).getText(); + assertThat(componentStockMessage).isEqualTo("¡No hay stock!"); + assertThat(driver.findElement(By.id("add-to-cart-button")).isEnabled()).isFalse(); + + } + @Test public void componentDetailOptionsAsNonLoggedInTest() { @@ -186,14 +250,7 @@ public void componentDetailOptionsAsNonLoggedInTest() { @Test public void componentDetailOptionsAsLoggedInNoAdminTest() { - driver.get("http://localhost:5173/login"); - - wait.until(ExpectedConditions.presenceOfElementLocated(By.id("email"))); - wait.until(ExpectedConditions.presenceOfElementLocated(By.id("password"))); - driver.findElement(By.id("email")).sendKeys("user@example.com"); - driver.findElement(By.id("password")).sendKeys("userpass"); - - scrollToAndClick(By.name("login-button")); + login("user@example.com", "userpass"); wait.until(ExpectedConditions.urlToBe("http://localhost:5173/")); @@ -210,16 +267,7 @@ public void componentDetailOptionsAsLoggedInNoAdminTest() { @Test public void componentDetailOptionsAsAdminTest() { - driver.get("http://localhost:5173/login"); - - wait.until(ExpectedConditions.presenceOfElementLocated(By.id("email"))); - wait.until(ExpectedConditions.presenceOfElementLocated(By.id("password"))); - driver.findElement(By.id("email")).sendKeys("admin@example.com"); - driver.findElement(By.id("password")).sendKeys("adminpass"); - - scrollToAndClick(By.name("login-button")); - - wait.until(ExpectedConditions.urlToBe("http://localhost:5173/")); + login("admin@example.com", "adminpass"); driver.get("http://localhost:5173/components/1"); @@ -234,17 +282,241 @@ public void componentDetailOptionsAsAdminTest() { } @Test - public void deleteComponentTest() { + public void componentCreateRenderTest() { - driver.get("http://localhost:5173/login"); - wait.until(ExpectedConditions.presenceOfElementLocated(By.id("email"))); - wait.until(ExpectedConditions.presenceOfElementLocated(By.id("password"))); - driver.findElement(By.id("email")).sendKeys("admin@example.com"); - driver.findElement(By.id("password")).sendKeys("adminpass"); + login("admin@example.com", "adminpass"); - scrollToAndClick(By.name("login-button")); + driver.get("http://localhost:5173/components/create"); - wait.until(ExpectedConditions.urlToBe("http://localhost:5173/")); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("create-component-title"))); + String registerTitle = driver.findElement(By.id("create-component-title")).getText(); + assertThat(registerTitle).isEqualTo("Ingrese los datos del componente:"); + + assertThat(driver.findElements(By.id("error-message"))).isEmpty(); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("name-title"))); + assertThat(driver.findElement(By.id("name-title")).getText()).isEqualTo("Nombre:"); + assertThat(driver.findElements(By.id("name"))).isNotEmpty(); + assertThat(driver.findElement(By.id("invalid-name")).isDisplayed()).isFalse(); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("description-title"))); + assertThat(driver.findElement(By.id("description-title")).getText()).isEqualTo("Descripción:"); + assertThat(driver.findElements(By.id("description"))).isNotEmpty(); + assertThat(driver.findElement(By.id("invalid-description")).isDisplayed()).isFalse(); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("brand-title"))); + assertThat(driver.findElement(By.id("brand-title")).getText()).isEqualTo("Marca:"); + assertThat(driver.findElements(By.id("brand"))).isNotEmpty(); + assertThat(driver.findElement(By.id("invalid-brand")).isDisplayed()).isFalse(); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("type-title"))); + assertThat(driver.findElement(By.id("type-title")).getText()).isEqualTo("Tipo de componente:"); + assertThat(driver.findElements(By.id("type"))).isNotEmpty(); + assertThat(driver.findElement(By.id("invalid-type")).isDisplayed()).isFalse(); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("price-title"))); + assertThat(driver.findElement(By.id("price-title")).getText()).isEqualTo("Precio:"); + assertThat(driver.findElements(By.id("price"))).isNotEmpty(); + assertThat(driver.findElement(By.id("invalid-price")).isDisplayed()).isFalse(); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("stock-title"))); + assertThat(driver.findElement(By.id("stock-title")).getText()).isEqualTo("Stock:"); + assertThat(driver.findElements(By.id("stock"))).isNotEmpty(); + assertThat(driver.findElement(By.id("invalid-stock")).isDisplayed()).isFalse(); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("image-title"))); + assertThat(driver.findElement(By.id("image-title")).getText()).isEqualTo("Imagen del componente:"); + assertThat(driver.findElements(By.id("image"))).isNotEmpty(); + assertThat(driver.findElement(By.id("invalid-image")).isDisplayed()).isFalse(); + + assertThat(driver.findElements(By.name("save-component-button"))).isNotEmpty(); + assertThat(driver.findElements(By.id("cancel-component-button"))).isNotEmpty(); + + } + + @Test + public void createComponentTest() throws Exception { + + login("admin@example.com", "adminpass"); + + driver.get("http://localhost:5173/components/create"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("name"))); + driver.findElement(By.id("name")).sendKeys("testComponent"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("description"))); + driver.findElement(By.id("description")).sendKeys("testDescription"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("brand"))); + driver.findElement(By.id("brand")).sendKeys("testBrand"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("type"))); + Select select = new Select(driver.findElement(By.id("type"))); + select.selectByValue("CPU"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("price"))); + driver.findElement(By.id("price")).sendKeys("5"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("stock"))); + driver.findElement(By.id("stock")).sendKeys("10"); + + String imagePath = Files.createTempFile("valid-image", ".png").toString(); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("image"))); + driver.findElement(By.id("image")).sendKeys(imagePath); + + scrollToAndClick(By.name("save-component-button")); + + wait.until(ExpectedConditions.urlMatches(".*/components/\\d+")); + + Files.deleteIfExists(Paths.get(imagePath)); + + } + + @Test + public void createVoidComponentTest() { + + login("admin@example.com", "adminpass"); + + driver.get("http://localhost:5173/components/create"); + + scrollToAndClick(By.name("save-component-button")); + + assertThat(driver.findElement(By.id("invalid-name")).isDisplayed()).isTrue(); + assertThat(driver.findElement(By.id("invalid-name")).getText()) + .isEqualTo("Por favor, ingrese un nombre válido."); + assertThat(driver.findElement(By.id("invalid-description")).isDisplayed()).isTrue(); + assertThat(driver.findElement(By.id("invalid-description")).getText()) + .isEqualTo("Por favor, ingrese una descripción válida."); + assertThat(driver.findElement(By.id("invalid-brand")).isDisplayed()).isTrue(); + assertThat(driver.findElement(By.id("invalid-brand")).getText()) + .isEqualTo("Por favor, ingrese una marca válida."); + assertThat(driver.findElement(By.id("invalid-type")).isDisplayed()).isTrue(); + assertThat(driver.findElement(By.id("invalid-type")).getText()) + .isEqualTo("Por favor, seleccione un tipo válido."); + assertThat(driver.findElement(By.id("invalid-price")).isDisplayed()).isTrue(); + assertThat(driver.findElement(By.id("invalid-price")).getText()) + .isEqualTo("Por favor, ingrese un precio válido."); + assertThat(driver.findElement(By.id("invalid-stock")).isDisplayed()).isTrue(); + assertThat(driver.findElement(By.id("invalid-stock")).getText()) + .isEqualTo("Por favor, ingrese un stock válido."); + assertThat(driver.getCurrentUrl()).isEqualTo("http://localhost:5173/components/create"); + + } + + @Test + public void createExistingComponentTest() { + + login("admin@example.com", "adminpass"); + + driver.get("http://localhost:5173/components/create"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("name"))); + driver.findElement(By.id("name")).sendKeys("Kingston NV3"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("description"))); + driver.findElement(By.id("description")).sendKeys("testDescription"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("brand"))); + driver.findElement(By.id("brand")).sendKeys("testBrand"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("type"))); + Select select = new Select(driver.findElement(By.id("type"))); + select.selectByValue("CPU"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("price"))); + driver.findElement(By.id("price")).sendKeys("5"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("stock"))); + driver.findElement(By.id("stock")).sendKeys("10"); + + scrollToAndClick(By.name("save-component-button")); + + WebElement errorMessage = wait.until( + ExpectedConditions.visibilityOfElementLocated(By.id("error-message"))); + assertThat(errorMessage.getText()).contains("Ya existe un componente con ese nombre."); + assertThat(driver.getCurrentUrl()).isEqualTo("http://localhost:5173/components/create"); + + } + + @Test + public void createInvalidFormatComponentTest() { + + login("admin@example.com", "adminpass"); + + driver.get("http://localhost:5173/components/create"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("name"))); + driver.findElement(By.id("name")).sendKeys("testName"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("description"))); + driver.findElement(By.id("description")).sendKeys("testDescription"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("brand"))); + driver.findElement(By.id("brand")).sendKeys("testBrand"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("type"))); + Select select = new Select(driver.findElement(By.id("type"))); + select.selectByValue("CPU"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("price"))); + driver.findElement(By.id("price")).sendKeys("bad_format"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("stock"))); + driver.findElement(By.id("stock")).sendKeys("bad_format"); + + scrollToAndClick(By.name("save-component-button")); + + assertThat(driver.findElement(By.id("invalid-price")).isDisplayed()).isTrue(); + assertThat(driver.findElement(By.id("invalid-price")).getText()) + .isEqualTo("Por favor, ingrese un precio válido."); + assertThat(driver.findElement(By.id("invalid-stock")).isDisplayed()).isTrue(); + assertThat(driver.findElement(By.id("invalid-stock")).getText()) + .isEqualTo("Por favor, ingrese un stock válido."); + assertThat(driver.getCurrentUrl()).isEqualTo("http://localhost:5173/components/create"); + + } + + @Test + public void createInvalidImageComponentTest() throws Exception { + + login("admin@example.com", "adminpass"); + + driver.get("http://localhost:5173/components/create"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("name"))); + driver.findElement(By.id("name")).sendKeys("testName"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("description"))); + driver.findElement(By.id("description")).sendKeys("testDescription"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("brand"))); + driver.findElement(By.id("brand")).sendKeys("testBrand"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("type"))); + Select select = new Select(driver.findElement(By.id("type"))); + select.selectByValue("CPU"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("price"))); + driver.findElement(By.id("price")).sendKeys("5"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("stock"))); + driver.findElement(By.id("stock")).sendKeys("5"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("image"))); + Path tempFile = Files.createTempFile("invalid-image", ".txt"); + Files.writeString(tempFile, "invalid file content"); + String imagePath = tempFile.toAbsolutePath().toString(); + driver.findElement(By.id("image")).sendKeys(imagePath); + + scrollToAndClick(By.name("save-component-button")); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("error-message"))); + assertThat(driver.findElement(By.id("error-message")).isDisplayed()).isTrue(); + assertThat(driver.findElement(By.id("error-message")).getText()) + .isEqualTo("Hubo un error al subir la imagen. Solo se permiten imágenes JPEG, PNG y WebP."); + assertThat(driver.getCurrentUrl()).isEqualTo("http://localhost:5173/components/create"); + + Files.deleteIfExists(Paths.get(imagePath)); + + } + + @Test + public void cancelCreateComponentTest() { + + login("admin@example.com", "adminpass"); + + driver.get("http://localhost:5173/components/create"); + + scrollToAndClick(By.id("cancel-component-button")); + + wait.until(ExpectedConditions.urlToBe("http://localhost:5173/components")); + + } + + @Test + public void deleteComponentTest() { + + login("admin@example.com", "adminpass"); driver.get("http://localhost:5173/components/8"); @@ -266,15 +538,7 @@ public void deleteComponentTest() { @Test public void cancelDeleteComponentTest() { - driver.get("http://localhost:5173/login"); - wait.until(ExpectedConditions.presenceOfElementLocated(By.id("email"))); - wait.until(ExpectedConditions.presenceOfElementLocated(By.id("password"))); - driver.findElement(By.id("email")).sendKeys("admin@example.com"); - driver.findElement(By.id("password")).sendKeys("adminpass"); - - scrollToAndClick(By.name("login-button")); - - wait.until(ExpectedConditions.urlToBe("http://localhost:5173/")); + login("admin@example.com", "adminpass"); driver.get("http://localhost:5173/components/1"); @@ -298,16 +562,8 @@ public void cancelDeleteComponentTest() { @Test public void cancelDeleteComponentClosingModalTest() { - driver.get("http://localhost:5173/login"); - wait.until(ExpectedConditions.presenceOfElementLocated(By.id("email"))); - wait.until(ExpectedConditions.presenceOfElementLocated(By.id("password"))); - driver.findElement(By.id("email")).sendKeys("admin@example.com"); - driver.findElement(By.id("password")).sendKeys("adminpass"); - - scrollToAndClick(By.name("login-button")); - - wait.until(ExpectedConditions.urlToBe("http://localhost:5173/")); - + login("admin@example.com", "adminpass"); + driver.get("http://localhost:5173/components/1"); wait.until(ExpectedConditions.presenceOfElementLocated(By.id("delete-component-button"))); 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 f56c799..48fe982 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,6 +10,7 @@ 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.support.ui.ExpectedConditions; @@ -57,17 +58,14 @@ public void loadHomePage() { String logoMessage = driver.findElement(By.id("logo")).getAttribute("alt"); assertThat(logoMessage).isEqualTo("PCMod Logo"); - wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-10"))); - String componentName10 = driver.findElement(By.id("component-10")).getText(); - assertThat(componentName10).isEqualTo("Seagate BarraCuda 3.5"); - - wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-11"))); - String componentName11 = driver.findElement(By.id("component-11")).getText(); - assertThat(componentName11).isEqualTo("Kingston FURY Beast"); - - wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-12"))); - String componentName12 = driver.findElement(By.id("component-12")).getText(); - assertThat(componentName12).isEqualTo("Corsair iCUE LINK 3500X RGB"); + By recentComponentNames = By.cssSelector("h3[id^='component-']"); + wait.until(ExpectedConditions.numberOfElementsToBe(recentComponentNames, 3)); + assertThat(driver.findElements(recentComponentNames)) + .extracting(WebElement::getText) + .containsExactly( + "testComponent", + "Corsair iCUE LINK 3500X RGB", + "Kingston FURY Beast"); wait.until(ExpectedConditions.presenceOfElementLocated(By.name("componentsButton"))); assertThat(driver.findElements(By.name("componentsButton"))).isNotEmpty(); 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 c6f319b..8cf9e97 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 @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; import java.math.BigDecimal; @@ -19,6 +20,7 @@ import org.springframework.core.io.Resource; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authorization.AuthorizationDeniedException; @@ -35,6 +37,7 @@ import es.codeurjcstudents.pcmod.enums.ComponentType; import es.codeurjcstudents.pcmod.model.Component; +import es.codeurjcstudents.pcmod.model.Image; import es.codeurjcstudents.pcmod.model.User; import es.codeurjcstudents.pcmod.repository.ComponentsRepository; import es.codeurjcstudents.pcmod.repository.UsersRepository; @@ -152,6 +155,198 @@ public void testGetNullComponentImage() throws SQLException { assertEquals(null, actualImage); } + @Test + void postComponentAsNoRegisteresUser() { + Component component = new Component("Intel i5 12400F", "Procesador de 6 núcleos y 12 hilos", ComponentType.CPU, "Intel", BigDecimal.valueOf(153.97), 14); + assertThrows(AuthenticationCredentialsNotFoundException.class, () -> componentsService.save(component)); + } + + @Test + void postComponentAsNoAdmin() { + User user = usersRepository.findByEmail("testuser1@example.com").orElseThrow(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + org.springframework.security.core.userdetails.User.withUsername(user.getId().toString()) + .password("").authorities("ROLE_REGISTERED_USER").build(), + null, java.util.List.of(new SimpleGrantedAuthority("ROLE_REGISTERED_USER")))); + + Component component = new Component("Intel i5 12400F", "Procesador de 6 núcleos y 12 hilos", ComponentType.CPU, "Intel", BigDecimal.valueOf(153.97), 14); + assertThrows(AuthorizationDeniedException.class, () -> componentsService.save(component)); + + } + + @Test + void postComponentAsAdmin() { + User user = usersRepository.findByEmail("testadmin1@example.com").orElseThrow(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + org.springframework.security.core.userdetails.User.withUsername(user.getId().toString()) + .password("").authorities("ROLE_ADMIN").build(), + null, java.util.List.of(new SimpleGrantedAuthority("ROLE_ADMIN")))); + + Component component = new Component("Intel i5 12400F", "Procesador de 6 núcleos y 12 hilos", ComponentType.CPU, "Intel", BigDecimal.valueOf(153.97), 14); + assertDoesNotThrow(() -> componentsService.save(component)); + + } + + @Test + void validateComponent() { + User admin = usersRepository.findByEmail("testadmin1@example.com").orElseThrow(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + org.springframework.security.core.userdetails.User.withUsername(admin.getId().toString()) + .password("").authorities("ROLE_ADMIN").build(), + null, java.util.List.of(new SimpleGrantedAuthority("ROLE_ADMIN")))); + + Component component = new Component("Intel i5 12400F", "Procesador de 6 núcleos y 12 hilos", ComponentType.CPU, "Intel", BigDecimal.valueOf(153.97), 14); + + List errors = componentsService.validateComponent(component); + + assertTrue(errors.isEmpty()); + + } + + @Test + void validateVoidComponent() { + User admin = usersRepository.findByEmail("testadmin1@example.com").orElseThrow(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + org.springframework.security.core.userdetails.User.withUsername(admin.getId().toString()) + .password("").authorities("ROLE_ADMIN").build(), + null, java.util.List.of(new SimpleGrantedAuthority("ROLE_ADMIN")))); + + Component component = new Component("", "", ComponentType.CPU, "", BigDecimal.valueOf(153.97), 14); + + List errors = componentsService.validateComponent(component); + + assertTrue(errors.contains("El nombre del componente es obligatorio.")); + assertTrue(errors.contains("La descripción es obligatoria.")); + assertTrue(errors.contains("La marca es obligatoria.")); + + } + + @Test + void validateNullComponent() { + User admin = usersRepository.findByEmail("testadmin1@example.com").orElseThrow(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + org.springframework.security.core.userdetails.User.withUsername(admin.getId().toString()) + .password("").authorities("ROLE_ADMIN").build(), + null, java.util.List.of(new SimpleGrantedAuthority("ROLE_ADMIN")))); + + Component component = new Component(null, null, ComponentType.CPU, null, BigDecimal.valueOf(153.97), 14); + + List errors = componentsService.validateComponent(component); + + assertTrue(errors.contains("El nombre del componente es obligatorio.")); + assertTrue(errors.contains("La descripción es obligatoria.")); + assertTrue(errors.contains("La marca es obligatoria.")); + + } + + @Test + void validateBadNumComponentData() { + User admin = usersRepository.findByEmail("testadmin1@example.com").orElseThrow(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + org.springframework.security.core.userdetails.User.withUsername(admin.getId().toString()) + .password("").authorities("ROLE_ADMIN").build(), + null, java.util.List.of(new SimpleGrantedAuthority("ROLE_ADMIN")))); + + Component component = new Component("sampleComponent", "sampleDescription", ComponentType.CPU, "sampleBrand", BigDecimal.valueOf(-350.00), -5); + + List errors = componentsService.validateComponent(component); + + assertTrue(errors.contains("El precio del producto ha de ser mayor de 0.00 €.")); + assertTrue(errors.contains("El stock del producto ha de ser 0 o mayor.")); + + } + + @Test + void validateExistingComponentData() { + User admin = usersRepository.findByEmail("testadmin1@example.com").orElseThrow(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + org.springframework.security.core.userdetails.User.withUsername(admin.getId().toString()) + .password("").authorities("ROLE_ADMIN").build(), + null, java.util.List.of(new SimpleGrantedAuthority("ROLE_ADMIN")))); + + Component component = new Component("AMD Ryzen 7 7800X3D", "Procesador de 8 núcleos y 16 hilos", ComponentType.CPU, "AMD", BigDecimal.valueOf(350.00), 5); + + List errors = componentsService.validateComponent(component); + + assertTrue(errors.contains("Ya existe un componente con ese nombre.")); + + } + + @Test + void postComponentImageAsNonRegisteredUser() throws IOException { + + Resource resource = new ClassPathResource("/sample_images/ryzen-7.webp"); + MockMultipartFile imageFile = new MockMultipartFile("imageFile", "ryzen-7.webp", "image/webp", + resource.getInputStream()); + Component component = componentsRepository.findAll().get(0); + + assertThrows(AuthenticationCredentialsNotFoundException.class, + () -> componentsService.addComponentImage(component.getId(), imageFile)); + + } + + @Test + void postComponentImageAsNoAdmin() throws IOException { + User user = usersRepository.findByEmail("testuser1@example.com").orElseThrow(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + org.springframework.security.core.userdetails.User.withUsername(user.getId().toString()) + .password("").authorities("ROLE_REGISTERED_USER").build(), + null, java.util.List.of(new SimpleGrantedAuthority("ROLE_REGISTERED_USER")))); + + Resource expectedImage = new ClassPathResource("/sample_images/user.png"); + MockMultipartFile imageFile = new MockMultipartFile("imageFile", "user.png", "image/png", + expectedImage.getInputStream()); + + Component component = componentsRepository.findAll().get(0); + assertThrows(AuthorizationDeniedException.class, () -> componentsService.addComponentImage(component.getId(), imageFile)); + + } + + @Test + void postImagesAsAdmin() throws IOException, SQLException { + User admin = usersRepository.findByEmail("testadmin1@example.com").orElseThrow(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + org.springframework.security.core.userdetails.User.withUsername(admin.getId().toString()) + .password("").authorities("ROLE_ADMIN").build(), + null, java.util.List.of(new SimpleGrantedAuthority("ROLE_ADMIN")))); + + Resource expectedImage = new ClassPathResource("/sample_images/ryzen-7.webp"); + MockMultipartFile imageFile = new MockMultipartFile("imageFile", "ryzen-7.webp", "image/webp", + expectedImage.getInputStream()); + + Component component = componentsRepository.findAll().get(0); + Image savedComponentImage = componentsService.addComponentImage(component.getId(), imageFile); + + assertArrayEquals(expectedImage.getInputStream().readAllBytes(), + savedComponentImage.getImageFile().getBinaryStream().readAllBytes()); + } + + @Test + void postImageWithInvalidFile() { + User user = usersRepository.findByEmail("testadmin1@example.com").orElseThrow(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + org.springframework.security.core.userdetails.User.withUsername(user.getId().toString()) + .password("").authorities("ROLE_ADMIN").build(), + null, java.util.List.of(new SimpleGrantedAuthority("ROLE_ADMIN")))); + + MockMultipartFile invalidTypeImage = new MockMultipartFile( + "image", "image.txt", "text/plain", new byte[1024]); + Component component = componentsRepository.findAll().get(0); + + assertThrows(IllegalArgumentException.class, () -> componentsService.addComponentImage(component.getId(), invalidTypeImage)); + + } + @Test void deleteComponentAsNotLoggedIn() { 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 05357c9..c131561 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 @@ -6,6 +6,8 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -20,10 +22,12 @@ import javax.sql.rowset.serial.SerialBlob; +import org.springframework.core.io.ClassPathResource; 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 org.springframework.mock.web.MockMultipartFile; import org.springframework.web.server.ResponseStatusException; import es.codeurjcstudents.pcmod.dto.ComponentMapper; @@ -32,6 +36,7 @@ import es.codeurjcstudents.pcmod.model.Image; import es.codeurjcstudents.pcmod.repository.ComponentsRepository; import es.codeurjcstudents.pcmod.service.ComponentsService; +import es.codeurjcstudents.pcmod.service.ImageService; @Tag("server-unit") public class ComponentsUnitTests { @@ -40,7 +45,8 @@ public class ComponentsUnitTests { public void testComponentsLoad() { ComponentsRepository componentsRepository = mock(ComponentsRepository.class); ComponentMapper componentMapper = mock(ComponentMapper.class); - ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper); + ImageService imageService = mock(ImageService.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper, imageService); Pageable pageable = mock(Pageable.class); List componentList = Arrays.asList(new Component(), new Component()); @@ -57,7 +63,8 @@ public void testComponentsLoad() { public void testGetComponent() { ComponentsRepository componentsRepository = mock(ComponentsRepository.class); ComponentMapper componentMapper = mock(ComponentMapper.class); - ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper); + ImageService imageService = mock(ImageService.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper, imageService); Component componentToReturn = new Component("Intel i5 12400F", "Procesador de 6 núcleos y 12 hilos", ComponentType.CPU, "Intel", BigDecimal.valueOf(153.97), 14); @@ -73,7 +80,8 @@ public void testGetComponent() { public void testGetComponentNotFound() { ComponentsRepository componentsRepository = mock(ComponentsRepository.class); ComponentMapper componentMapper = mock(ComponentMapper.class); - ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper); + ImageService imageService = mock(ImageService.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper, imageService); when(componentsRepository.findById(12L)).thenReturn(Optional.empty()); @@ -85,7 +93,8 @@ public void testGetComponentNotFound() { public void testGetComponentImage() throws SQLException, IOException { ComponentsRepository componentsRepository = mock(ComponentsRepository.class); ComponentMapper componentMapper = mock(ComponentMapper.class); - ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper); + ImageService imageService = mock(ImageService.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper, imageService); byte[] originalBytes = new byte[] { 1, 2, 3 }; Image existingImage = new Image(); @@ -102,7 +111,8 @@ public void testGetComponentImage() throws SQLException, IOException { public void testGetNullComponentImage() throws SQLException { ComponentsRepository componentsRepository = mock(ComponentsRepository.class); ComponentMapper componentMapper = mock(ComponentMapper.class); - ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper); + ImageService imageService = mock(ImageService.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper, imageService); when(componentsRepository.findImageById(1L)).thenReturn(Optional.empty()); @@ -112,12 +122,211 @@ public void testGetNullComponentImage() throws SQLException { verify(componentsRepository).findImageById(1L); } + @Test + public void testCreateComponent() { + + ComponentsRepository componentsRepository = mock(ComponentsRepository.class); + ComponentMapper componentMapper = mock(ComponentMapper.class); + ImageService imageService = mock(ImageService.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper, imageService); + + Component component = new Component("Intel i5 12400F", "Procesador de 6 núcleos y 12 hilos", ComponentType.CPU, "Intel", BigDecimal.valueOf(153.97), 14); + + when(componentsRepository.save(any(Component.class))).thenReturn(component); + + Component savedComponent = componentsService.save(component); + + assertEquals(component.getName(), savedComponent.getName()); + assertEquals(component.getDescription(), savedComponent.getDescription()); + assertEquals(component.getType(), savedComponent.getType()); + assertEquals(component.getBrand(), savedComponent.getBrand()); + assertEquals(component.getPrice(), savedComponent.getPrice()); + assertEquals(component.getStock(), savedComponent.getStock()); + verify(componentsRepository).save(any(Component.class)); + + } + + @Test + public void testCreateVoidComponentData() { + + ComponentsRepository componentsRepository = mock(ComponentsRepository.class); + ComponentMapper componentMapper = mock(ComponentMapper.class); + ImageService imageService = mock(ImageService.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper, imageService); + + Component component = new Component("", "", ComponentType.CPU, "", BigDecimal.valueOf(153.97), 14); + + assertThrows(IllegalArgumentException.class, () -> componentsService.save(component)); + verify(componentsRepository, never()).save(any(Component.class)); + + } + + @Test + public void testCreateNullComponentData() { + + ComponentsRepository componentsRepository = mock(ComponentsRepository.class); + ComponentMapper componentMapper = mock(ComponentMapper.class); + ImageService imageService = mock(ImageService.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper, imageService); + + Component component = new Component(null, null, ComponentType.CPU, null, BigDecimal.valueOf(153.97), 14); + + assertThrows(IllegalArgumentException.class, () -> componentsService.save(component)); + verify(componentsRepository, never()).save(any(Component.class)); + + } + + @Test + public void testCreateExistingComponentData() { + + ComponentsRepository componentsRepository = mock(ComponentsRepository.class); + ComponentMapper componentMapper = mock(ComponentMapper.class); + ImageService imageService = mock(ImageService.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper, imageService); + + Component component = new Component("Intel i5 12400F", "Procesador de 6 núcleos y 12 hilos", ComponentType.CPU, "Intel", BigDecimal.valueOf(153.97), 14); + + when(componentsRepository.existsByName("Intel i5 12400F")).thenReturn(true); + + assertThrows(IllegalArgumentException.class, () -> componentsService.save(component)); + verify(componentsRepository, never()).save(any(Component.class)); + + } + + @Test + public void testValidation() { + + ComponentsRepository componentsRepository = mock(ComponentsRepository.class); + ComponentMapper componentMapper = mock(ComponentMapper.class); + ImageService imageService = mock(ImageService.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper, imageService); + + Component component = new Component("Intel i5 12400F", "Procesador de 6 núcleos y 12 hilos", ComponentType.CPU, "Intel", BigDecimal.valueOf(153.97), 14); + List errors = componentsService.validateComponent(component); + + assertTrue(errors.isEmpty()); + + } + + @Test + public void testVoidComponentValidation() { + + ComponentsRepository componentsRepository = mock(ComponentsRepository.class); + ComponentMapper componentMapper = mock(ComponentMapper.class); + ImageService imageService = mock(ImageService.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper, imageService); + + Component component = new Component("", "", ComponentType.CPU, "", BigDecimal.valueOf(153.97), 14); + List errors = componentsService.validateComponent(component); + + assertTrue(errors.contains("El nombre del componente es obligatorio.")); + assertTrue(errors.contains("La descripción es obligatoria.")); + assertTrue(errors.contains("La marca es obligatoria.")); + + } + + @Test + public void testNullComponentValidation() { + + ComponentsRepository componentsRepository = mock(ComponentsRepository.class); + ComponentMapper componentMapper = mock(ComponentMapper.class); + ImageService imageService = mock(ImageService.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper, imageService); + + Component component = new Component(null, null, ComponentType.CPU, null, BigDecimal.valueOf(153.97), 14); + List errors = componentsService.validateComponent(component); + + assertTrue(errors.contains("El nombre del componente es obligatorio.")); + assertTrue(errors.contains("La descripción es obligatoria.")); + assertTrue(errors.contains("La marca es obligatoria.")); + + } + + @Test + public void testBadNumComponentValidation() { + + ComponentsRepository componentsRepository = mock(ComponentsRepository.class); + ComponentMapper componentMapper = mock(ComponentMapper.class); + ImageService imageService = mock(ImageService.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper, imageService); + + Component component = new Component("sampleComponent", "sampleDescription", ComponentType.CPU, "sampleBrand", BigDecimal.valueOf(-153.97), -14); + List errors = componentsService.validateComponent(component); + + assertTrue(errors.contains("El precio del producto ha de ser mayor de 0.00 €.")); + assertTrue(errors.contains("El stock del producto ha de ser 0 o mayor.")); + + } + + @Test + public void testExistingComponentValidation() { + + ComponentsRepository componentsRepository = mock(ComponentsRepository.class); + ComponentMapper componentMapper = mock(ComponentMapper.class); + ImageService imageService = mock(ImageService.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper, imageService); + + Component component = new Component("Intel i5 12400F", "Procesado de 6 núcleos y 12 hilos", ComponentType.CPU, "Intel", BigDecimal.valueOf(153.97), 14); + when(componentsRepository.existsByName("Intel i5 12400F")).thenReturn(true); + + List errors = componentsService.validateComponent(component); + + assertTrue(errors.contains("Ya existe un componente con ese nombre.")); + + } + + @Test + public void testAddImageFile() throws Exception { + + ComponentsRepository componentsRepository = mock(ComponentsRepository.class); + ComponentMapper componentMapper = mock(ComponentMapper.class); + ImageService imageService = mock(ImageService.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper, imageService); + + Component component = new Component("Intel i5 12400F", "Procesado de 6 núcleos y 12 hilos", ComponentType.CPU, "Intel", BigDecimal.valueOf(153.97), 14); + + Resource expectedImage = new ClassPathResource("/sample_images/i5-12400f.webp"); + MockMultipartFile imageFile = new MockMultipartFile( + "imageFile", "user.png", "image/png", expectedImage.getInputStream()); + Image image = new Image(new SerialBlob(expectedImage.getInputStream().readAllBytes())); + when(imageService.createImage(any())).thenReturn(image); + + when(componentsRepository.findById(1L)).thenReturn(Optional.of(component)); + when(componentsRepository.save(component)).thenReturn(component); + + Image actualImage = componentsService.addComponentImage(1L, imageFile); + + assertArrayEquals(expectedImage.getInputStream().readAllBytes(), + actualImage.getImageFile().getBinaryStream().readAllBytes()); + + } + + @Test + public void testAddImageWithInvalidFile() throws IOException { + + ComponentsRepository componentsRepository = mock(ComponentsRepository.class); + ComponentMapper componentMapper = mock(ComponentMapper.class); + ImageService imageService = mock(ImageService.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper, imageService); + + Component component = new Component("Intel i5 12400F", "Procesado de 6 núcleos y 12 hilos", ComponentType.CPU, "Intel", BigDecimal.valueOf(153.97), 14); + when(componentsRepository.findById(1L)).thenReturn(Optional.of(component)); + + MockMultipartFile invalidTypeImage = new MockMultipartFile( + "image", "image.txt", "text/plain", new byte[1024]); + + when(imageService.createImage(any())).thenThrow(IllegalArgumentException.class); + + assertThrows(IllegalArgumentException.class, () -> componentsService.addComponentImage(1L, invalidTypeImage)); + } + @Test public void testDeleteComponent() { ComponentsRepository componentsRepository = mock(ComponentsRepository.class); ComponentMapper componentMapper = mock(ComponentMapper.class); - ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper); + ImageService imageService = mock(ImageService.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper, imageService); Component component = new Component("Intel i5 12400F", "Procesador de 6 núcleos y 12 hilos", ComponentType.CPU, "Intel", BigDecimal.valueOf(153.97), 14); @@ -134,7 +343,8 @@ public void testDeleteNonExistentComponent() { ComponentsRepository componentsRepository = mock(ComponentsRepository.class); ComponentMapper componentMapper = mock(ComponentMapper.class); - ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper); + ImageService imageService = mock(ImageService.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper, imageService); when(componentsRepository.findById(1L)).thenReturn(Optional.empty()); diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/HomePageUnitTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/HomePageUnitTests.java index ff4a339..22166f1 100644 --- a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/HomePageUnitTests.java +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/HomePageUnitTests.java @@ -16,6 +16,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-unit") public class HomePageUnitTests { @@ -25,7 +26,8 @@ public void testRecentComponentsLoad() { ComponentsRepository componentsRepository = mock(ComponentsRepository.class); ComponentMapper componentMapper = mock(ComponentMapper.class); - ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper); + ImageService imageService = mock(ImageService.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper, imageService); Component component1 = new Component("AMD Ryzen 7 7800X3D", "8 núcleos y 16 hilos ideales para gaming\r\n" + // diff --git a/frontend/app/app.css b/frontend/app/app.css index 7230931..1aaf33c 100644 --- a/frontend/app/app.css +++ b/frontend/app/app.css @@ -397,6 +397,13 @@ body { font-family: var(--font-sans); } +.pcmod-button:disabled { + background-color: var(--color-muted); + color: #ffffff; + opacity: 1; + transform: none; +} + .load-more-container { display: flex; flex-direction: column; @@ -1092,4 +1099,179 @@ body { color: var(--color-principal); text-decoration: underline; text-underline-offset: 3px; +} + +/* Components */ + +.main:has(.components-header) .container { + max-width: 1000px; +} + +.components-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.5rem; + margin-bottom: 2rem; +} + +.components-header h1 { + margin: 0; + color: var(--color-ink); + font-size: clamp(1.7rem, 4vw, 2.35rem); + font-weight: 700; +} + +.components-header .pcmod-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + flex-shrink: 0; + text-decoration: none; +} + +.main:has(.component-form) .container { + max-width: 760px; +} + +.component-form { + padding: clamp(1.5rem, 5vw, 3rem); + background-color: var(--color-surface); + border: 1px solid var(--color-line); + border-top: 5px solid var(--color-principal); + border-radius: 8px; + box-shadow: 0 14px 30px rgb(84 37 20 / 12%); +} + +.component-form>a:first-child { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--color-brand-dark); + font-size: 0.9rem; + font-weight: 700; + text-decoration: none; +} + +.component-form>a:first-child:hover, +.component-form>a:first-child:focus-visible { + color: var(--color-principal); +} + +.component-form h3 { + margin: 2rem 0 1.75rem; + color: var(--color-ink); + font-size: clamp(1.45rem, 4vw, 1.85rem); + font-weight: 700; +} + +.custom-component-form .form { + display: grid; + gap: 1rem; + margin-bottom: 1.75rem; +} + +.custom-component-form .form-block { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.custom-component-form .form-label { + margin-bottom: 0; + color: var(--color-brand-dark); + font-weight: 700; +} + +.custom-component-form .form-control { + min-height: 46px; + padding: 0 0.5rem; + color: var(--color-ink); + background-color: #fffdfb; + border: 1px solid var(--color-line); + border-radius: 8px; + font-size: 1rem; + font-family: var(--font-sans); +} + +.custom-component-form .form-control:focus { + color: var(--color-ink); + background-color: #fffdfb; + border-color: var(--color-line); + box-shadow: none; + outline: none; +} + +.price-input { + position: relative; +} + +.price-input .form-control { + padding-right: 2rem; +} + +.price-suffix { + position: absolute; + top: 0; + right: 0.75rem; + display: flex; + align-items: center; + height: 46px; + color: var(--color-muted); + pointer-events: none; +} + +.custom-component-form input[type="file"] { + min-height: 38px; + padding: 0.35rem 0.5rem; +} + +.custom-component-form .invalid-feedback { + display: none; + margin-bottom: 0.25rem; + color: var(--color-brand-dark); + font-size: 0.85rem; +} + +.custom-component-form.was-validated .form-control:invalid~.invalid-feedback { + display: block; +} + +.custom-component-form .pcmod-danger-button, +.custom-component-form>.pcmod-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 46px; + text-decoration: none; +} + +.custom-component-form .pcmod-danger-button { + margin-right: 0.75rem; + padding: 0.75rem 1.25rem; + border-radius: 8px; +} + +@media (max-width: 600px) { + .components-header { + align-items: stretch; + flex-direction: column; + gap: 1rem; + } + + .components-header .pcmod-button { + width: 100%; + } + + .custom-component-form .form-block { + grid-template-columns: 1fr; + } + + .custom-component-form .pcmod-danger-button, + .custom-component-form>.pcmod-button { + width: 100%; + margin: 0 0 0.75rem; + } } \ No newline at end of file diff --git a/frontend/app/components/componentsForm.tsx b/frontend/app/components/componentsForm.tsx new file mode 100644 index 0000000..ba9b845 --- /dev/null +++ b/frontend/app/components/componentsForm.tsx @@ -0,0 +1,116 @@ +import type { SubmitEvent, RefObject } from "react"; +import { FormControl, FormLabel, FormSelect, Button } from "react-bootstrap"; +import { ArrowLeft, Floppy } from "react-bootstrap-icons"; +import { Link } from "react-router"; + +interface ComponentFormProps { + onSubmit: (event: SubmitEvent) => void; + isPending: boolean; + wasValidated: boolean; + componentTypes: string[]; + componentTypeLabels: Record; + formRef: RefObject; + submitLabel?: string; +} + +export default function ComponentForm({ + onSubmit, + isPending, + wasValidated, + componentTypes, + componentTypeLabels, + formRef, + submitLabel = "Guardar componente", +}: Readonly) { + + return ( +
+
+
+ Nombre: + +
+ Por favor, ingrese un nombre válido. +
+
+ +
+ Descripción: + +
+ Por favor, ingrese una descripción válida. +
+
+ +
+
+ Marca: + +
+ Por favor, ingrese una marca válida. +
+
+ +
+ Tipo de componente: + + + {componentTypes.map(type => ( + + ))} + +
+ Por favor, seleccione un tipo válido. +
+
+
+ +
+
+ Precio: +
+ + +
+ Por favor, ingrese un precio válido. +
+
+
+ +
+ Stock: + +
+ Por favor, ingrese un stock válido. +
+
+
+ +
+ Imagen del componente: + +
+ Por favor, seleccione un archivo de imagen válido. +
+
+
+ + + Cancelar + + + + + + ); +} \ No newline at end of file diff --git a/frontend/app/routes.ts b/frontend/app/routes.ts index ccb4fd4..1d44252 100644 --- a/frontend/app/routes.ts +++ b/frontend/app/routes.ts @@ -8,5 +8,6 @@ export default [ route("/me", "routes/profile.tsx"), route("/components", "routes/components.tsx"), route("/components/:id", "routes/detail-component.tsx"), + route("/components/create", "routes/components-create.tsx"), ]), ] satisfies RouteConfig; diff --git a/frontend/app/routes/components-create.tsx b/frontend/app/routes/components-create.tsx new file mode 100644 index 0000000..a795e15 --- /dev/null +++ b/frontend/app/routes/components-create.tsx @@ -0,0 +1,125 @@ +import { useActionState, useEffect, useRef, useState } from "react"; +import type { SubmitEvent } from "react"; +import ComponentForm from "~/components/componentsForm"; +import { ArrowLeft, ExclamationCircleFill } from "react-bootstrap-icons"; +import { Link, useNavigate } from "react-router"; +import { createComponent, addImage, getComponentTypes } from "~/services/components-service"; +import { getComponentFormValues } from "~/utils/component-form-utils"; +import type { ComponentFormValues } from "~/utils/component-form-utils"; +import { validateImage, handleFormSubmit } from "~/utils/general-form-utils"; +import { requireAdminUser } from "~/services/route-guard"; + +export async function clientLoader() { + return await requireAdminUser(); +} + +export default function ComponentsCreate() { + + const navigate = useNavigate(); + const [wasValidated, setWasValidated] = useState(false); + const formRef = useRef(null); + const [errorMessage, setErrorMessage] = useState(""); + const [, formAction, isPending] = useActionState( + createComponentAction, + { name: "", description: "", type: "", brand: "", price: 0.00, stock: 0 }, + ); + const [componentTypes, setComponentTypes] = useState([]); + + const componentTypeLabels: Record = { + CPU: "Procesador", + GPU: "Tarjeta gráfica", + RAM: "Memoria RAM", + STORAGE: "Almacenamiento", + MOTHERBOARD: "Placa base", + PSU: "Fuente de alimentación", + CASE: "Caja" + }; + + useEffect(() => { + async function loadComponentTypes() { + try { + const types = await getComponentTypes(); + setComponentTypes(types); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : "Error al cargar los tipos de componentes." + ); + } + } + void loadComponentTypes(); + }, []); + + async function createComponentAction(_prevState: ComponentFormValues, formData: FormData) { + const { name, description, type, brand, price, stock } = getComponentFormValues(formData); + const image = formData.get("image") as File | null; + + const imageError = validateImage(image); + if (imageError) { + setErrorMessage(imageError); + return { name, description, type, brand, price, stock }; + } + + try { + const component = await createComponent(name, description, type, brand, price, stock); + + if (image && image.size > 0) { + await addImage(component.id, image); + } + + await navigate(`/components/${component.id}`); + } catch (error) { + console.log(error); + + setErrorMessage( + error instanceof Error + ? error.message + : "Hubo un error al crear el componente. Por favor, inténtalo de nuevo.", + ); + } + + return { name, description, type, brand, price, stock }; + } + + function handleSubmit(event: SubmitEvent) { + handleFormSubmit(event, formAction, setWasValidated); + } + + return ( +
+
+
+ + + Volver + + +

+ Ingrese los datos del componente: +

+ + {errorMessage && ( +
+ + Error: + +

+ {errorMessage} +

+
+ )} + + +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/routes/components.tsx b/frontend/app/routes/components.tsx index a54baa3..2264390 100644 --- a/frontend/app/routes/components.tsx +++ b/frontend/app/routes/components.tsx @@ -5,6 +5,8 @@ import Col from "react-bootstrap/esm/Col"; import ComponentCard from "~/components/componentCard"; import { Button } from "react-bootstrap"; import { Plus, XOctagon } from "react-bootstrap-icons"; +import { useUserStore } from "~/stores/user-store"; +import { Link } from "react-router"; export async function clientLoader({ request }: Route.ClientLoaderArgs) { @@ -22,6 +24,8 @@ export default function Components({ loaderData }: Route.ComponentProps) { const [isLoadingMore, setIsLoadingMore] = useState(false); const [loadMoreError, setLoadMoreError] = useState(false); + let { user } = useUserStore(); + useEffect(() => { setComponents(loaderData.items); setHasNext(loaderData.hasNext); @@ -57,7 +61,15 @@ export default function Components({ loaderData }: Route.ComponentProps) { return (
-

Componentes:

+
+

Componentes:

+ {user?.roles?.includes("ADMIN") && ( + + + Crear componente + + )} +
{components.length > 0 ? ( <> diff --git a/frontend/app/routes/detail-component.tsx b/frontend/app/routes/detail-component.tsx index 6b1a922..1d08364 100644 --- a/frontend/app/routes/detail-component.tsx +++ b/frontend/app/routes/detail-component.tsx @@ -47,9 +47,12 @@ export default function ComponentDetail({ loaderData }: Route.ComponentProps) { )} - {component.stock <= 5 && ( + {component.stock <= 5 && component.stock > 0 && (

¡Últimas unidades!

)} + {component.stock === 0 && ( +

¡No hay stock!

+ )}

{component.name}

{component.description} @@ -59,9 +62,11 @@ export default function ComponentDetail({ loaderData }: Route.ComponentProps) {

{price} €

- {user?.roles?.includes("ADMIN") && ( <> diff --git a/frontend/app/routes/profile.tsx b/frontend/app/routes/profile.tsx index ce50250..cb9b386 100644 --- a/frontend/app/routes/profile.tsx +++ b/frontend/app/routes/profile.tsx @@ -7,8 +7,9 @@ import { CartX, Trash, Cart, ArrowLeft, PencilSquare, ExclamationCircleFill, Flo import { Button, FormControl, FormLabel, Image, Modal } from "react-bootstrap"; import { useNavigate } from "react-router"; import { useUserStore } from "~/stores/user-store"; -import { getUserFormValues, validateImage, handleFormSubmit } from "~/utils/user-form-utils"; +import { getUserFormValues } from "~/utils/user-form-utils"; import type { UserFormValues } from "~/utils/user-form-utils"; +import { handleFormSubmit, validateImage } from "~/utils/general-form-utils"; const API_URL = "/api/v1/users"; diff --git a/frontend/app/routes/register.tsx b/frontend/app/routes/register.tsx index 553eaed..a5c0cf3 100644 --- a/frontend/app/routes/register.tsx +++ b/frontend/app/routes/register.tsx @@ -5,8 +5,9 @@ import { ArrowLeft, BoxArrowInRight, ExclamationCircleFill, PersonPlus } from "r import { Link, useNavigate } from "react-router"; import { register, addImage } from "~/services/users-service"; import { useUserStore } from "~/stores/user-store"; -import { getUserFormValues, validateImage, handleFormSubmit } from "~/utils/user-form-utils"; +import { getUserFormValues } from "~/utils/user-form-utils"; import type { UserFormValues } from "~/utils/user-form-utils"; +import { validateImage, handleFormSubmit } from "~/utils/general-form-utils"; export default function Register() { diff --git a/frontend/app/services/components-service.ts b/frontend/app/services/components-service.ts index 1a56c3e..aba0cc3 100644 --- a/frontend/app/services/components-service.ts +++ b/frontend/app/services/components-service.ts @@ -1,4 +1,5 @@ import type ComponentDTO from "~/dtos/ComponentDTO"; +import type ImageDTO from "~/dtos/ImageDTO"; const API_URL = "/api/v1/components"; const PAGE_SIZE = 10; @@ -72,6 +73,56 @@ export async function getComponent(id: number): Promise { return await res.json(); } +export async function createComponent (name: string, description: string, type: string, brand: string, price: number, stock: number): Promise { + + const url = new URL(`${API_URL}/`, getBaseUrl()); + const res = await fetch(url.toString(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + description, + type, + brand, + price, + stock + }), + }); + + if (!res.ok) { + let message: string | undefined; + + try { + const error = await res.json(); + message = typeof error.message === "string" ? error.message : undefined; + } catch { + message = undefined; + } + + throw new Error(message ?? "There was an error while creating the component."); + } + + return await res.json(); +} + +export async function addImage(id: number, image: File): Promise { + + const formData = new FormData(); + formData.append('imageFile', image); + + const url = new URL(`${API_URL}/${id}/image`, getBaseUrl()); + const res = await fetch(url.toString(), { + method: 'POST', + body: formData + }); + + if (!res.ok) { + throw new Error("Hubo un error al subir la imagen. Solo se permiten imágenes JPEG, PNG y WebP."); + } + + return await res.json(); +} + export async function deleteComponent(id: number): Promise { const url = new URL(`${API_URL}/${id}`, getBaseUrl()); @@ -83,4 +134,15 @@ export async function deleteComponent(id: number): Promise { throw new Error("Error deleting component"); } +} + +export async function getComponentTypes(): Promise { + const url = new URL("/api/v1/component-types", getBaseUrl()); + const response = await fetch(url.toString()); + + if (!response.ok) { + throw new Error("No se pudieron obtener los tipos de componentes"); + } + + return await response.json(); } \ No newline at end of file diff --git a/frontend/app/services/route-guard.ts b/frontend/app/services/route-guard.ts new file mode 100644 index 0000000..94148f6 --- /dev/null +++ b/frontend/app/services/route-guard.ts @@ -0,0 +1,19 @@ +import type UserDTO from "~/dtos/UserDTO"; +import { HttpError, reqIsLogged } from "~/services/users-service"; + +export async function requireAdminUser(): Promise { + + const user = await reqIsLogged(); + + if (!user) { + throw new HttpError(401, "Unauthorized"); + } + + const isAdmin = user.roles?.includes("ADMIN"); + + if (!isAdmin) { + throw new HttpError(403, "Forbidden"); + } + + return user; +} diff --git a/frontend/app/utils/component-form-utils.ts b/frontend/app/utils/component-form-utils.ts new file mode 100644 index 0000000..1dbf633 --- /dev/null +++ b/frontend/app/utils/component-form-utils.ts @@ -0,0 +1,21 @@ +export interface ComponentFormValues { + name: string; + description: string; + type: string; + brand: string; + price: number; + stock: number; +} + +export function getComponentFormValues(formData: FormData): ComponentFormValues { + + return { + name: (formData.get("name") as string) ?? "", + description: (formData.get("description") as string) ?? "", + type: (formData.get("type") as string) ?? "", + brand: (formData.get("brand") as string) ?? "", + price: Number((formData.get("price") as string) ?? 0.00), + stock: Number((formData.get("stock") as string) ?? 0), + }; + +} diff --git a/frontend/app/utils/general-form-utils.ts b/frontend/app/utils/general-form-utils.ts new file mode 100644 index 0000000..68d58b7 --- /dev/null +++ b/frontend/app/utils/general-form-utils.ts @@ -0,0 +1,42 @@ +import { startTransition } from "react"; +import type { SubmitEvent } from "react"; + +export function validateImage(image: File | null): string | null { + + if (!image || image.size === 0) { + return null; + } + + const allowedImageTypes = [ + "image/jpeg", + "image/png", + "image/webp", + ]; + + const maxImageSize = 10 * 1024 * 1024; + + if (image.size > maxImageSize || !allowedImageTypes.includes(image.type)) { + return "Hubo un error al subir la imagen. Solo se permiten imágenes JPEG, PNG y WebP."; + } + + return null; +} + +export function handleFormSubmit(event: SubmitEvent, formAction: (formData: FormData) => void, setWasValidated: (value: boolean) => void) { + + event.preventDefault(); + + const form = event.currentTarget; + + setWasValidated(true); + + if (!form.checkValidity()) { + event.stopPropagation(); + return; + } + + startTransition(() => { + formAction(new FormData(form)); + }); + +} diff --git a/frontend/app/utils/user-form-utils.ts b/frontend/app/utils/user-form-utils.ts index 572e8fd..bebf48b 100644 --- a/frontend/app/utils/user-form-utils.ts +++ b/frontend/app/utils/user-form-utils.ts @@ -1,7 +1,3 @@ - -import { startTransition } from "react"; -import type { SubmitEvent } from "react"; - export interface UserFormValues { name: string; surname: string; @@ -22,44 +18,4 @@ export function getUserFormValues(formData: FormData): UserFormValues { password: (formData.get("password") as string) ?? "", }; -} - -export function validateImage(image: File | null): string | null { - - if (!image || image.size === 0) { - return null; - } - - const allowedImageTypes = [ - "image/jpeg", - "image/png", - "image/webp", - ]; - - const maxImageSize = 10 * 1024 * 1024; - - if (image.size > maxImageSize || !allowedImageTypes.includes(image.type)) { - return "Hubo un error al subir la imagen. Solo se permiten imágenes JPEG, PNG y WebP."; - } - - return null; -} - -export function handleFormSubmit(event: SubmitEvent, formAction: (formData: FormData) => void, setWasValidated: (value: boolean) => void) { - - event.preventDefault(); - - const form = event.currentTarget; - - setWasValidated(true); - - if (!form.checkValidity()) { - event.stopPropagation(); - return; - } - - startTransition(() => { - formAction(new FormData(form)); - }); - -} +} \ No newline at end of file diff --git a/frontend/tests/integration/ComponentDetailPage.integration.test.tsx b/frontend/tests/integration/ComponentDetailPage.integration.test.tsx index da0bdc5..47f43d4 100644 --- a/frontend/tests/integration/ComponentDetailPage.integration.test.tsx +++ b/frontend/tests/integration/ComponentDetailPage.integration.test.tsx @@ -74,6 +74,24 @@ describe("ComponentDetailPageIntegration", () => { }); }); + it("renders no stock warning when stock is 0", async () => { + const componentData = await clientLoader({ params: { id: "4" } } as Parameters[0]); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("¡No hay stock!")).toBeInTheDocument(); + expect(document.getElementById("add-to-cart-button")).toBeDisabled(); + }); + + }); + it("shows only the component add to cart button for non-registered users", async () => { const componentData = await clientLoader({ params: { id: "1" } } as Parameters[0]); diff --git a/frontend/tests/integration/ComponentForm.integration.test.tsx b/frontend/tests/integration/ComponentForm.integration.test.tsx new file mode 100644 index 0000000..29386ef --- /dev/null +++ b/frontend/tests/integration/ComponentForm.integration.test.tsx @@ -0,0 +1,264 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import fetchCookie from "fetch-cookie"; +import userEvent from "@testing-library/user-event"; +import { createRoutesStub, Navigate } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import "@testing-library/jest-dom"; +import { CookieJar } from "tough-cookie"; +import Login from "~/routes/login"; +import ComponentsCreate from "~/routes/components-create"; +import Components from "~/routes/components"; +import ComponentDetail, { clientLoader } from "~/routes/detail-component"; + +describe("ProfileIntegration", () => { + + beforeEach(() => { + vi.stubGlobal("fetch", fetchCookie(globalThis.fetch, new CookieJar())); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("renders the create component form", async () => { + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + { path: "/", Component: () => }, + { path: "/components/create", Component: ComponentsCreate }, + ]); + + render(); + + await user.type(screen.getByLabelText("Correo electrónico:"), "admin@example.com"); + await user.type(screen.getByLabelText("Contraseña:"), "adminpass"); + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + await waitFor(() => { + expect(screen.getByLabelText("Nombre:")).toBeInTheDocument(); + expect(screen.getByLabelText("Descripción:")).toBeInTheDocument(); + expect(screen.getByLabelText("Tipo de componente:")).toBeInTheDocument(); + expect(screen.getByLabelText("Marca:")).toBeInTheDocument(); + expect(screen.getByLabelText("Precio:")).toBeInTheDocument(); + expect(screen.getByLabelText("Stock:")).toBeInTheDocument(); + expect(screen.getByLabelText("Imagen del componente:")).toBeInTheDocument(); + }); + }); + + it("creates a new component with valid data", async () => { + + const user = userEvent.setup(); + + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + { path: "/", Component: () => }, + { path: "/components/create", Component: ComponentsCreate }, + { path: "/components/:id", Component: ComponentDetail, loader: clientLoader }, + ]); + + render(); + + await user.type(screen.getByLabelText("Correo electrónico:"), "admin@example.com"); + await user.type(screen.getByLabelText("Contraseña:"), "adminpass"); + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + const file = new File(["dummy content"], "user.jpg", { type: "image/jpeg" }); + + await waitFor(async () => { + expect(screen.getByLabelText("Nombre:")).toBeInTheDocument(); + }); + await user.type(screen.getByLabelText("Nombre:"), "testName"); + await user.type(screen.getByLabelText("Descripción:"), "testDescription"); + await user.type(screen.getByLabelText("Marca:"), "testBrand"); + await screen.findByRole("option", { name: "Procesador" }); + await user.selectOptions(screen.getByLabelText("Tipo de componente:"), "CPU"); + await user.type(screen.getByLabelText("Precio:"), "10"); + await user.type(screen.getByLabelText("Stock:"), "5"); + await user.upload(screen.getByLabelText("Imagen del componente:"), file); + + const createButton = await screen.findByRole("button", { name: /Guardar componente/i }); + await user.click(createButton); + + await waitFor(() => { + expect(screen.getByText("testName")).toBeInTheDocument(); + expect(screen.getByText("testDescription")).toBeInTheDocument(); + expect(screen.getByText(/testBrand/i)).toBeInTheDocument(); + expect(screen.getByText(/CPU/i)).toBeInTheDocument(); + expect(screen.getByText(/testBrand/i)).toBeInTheDocument(); + expect(screen.getByText(/10\.00/i)).toBeInTheDocument(); + }); + + }); + + it("shows validation errors for void data", async () => { + + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + { path: "/", Component: () => }, + { path: "/components/create", Component: ComponentsCreate }, + ]); + + render(); + + await user.type(screen.getByLabelText("Correo electrónico:"), "admin@example.com"); + await user.type(screen.getByLabelText("Contraseña:"), "adminpass"); + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + await waitFor(() => { + expect(screen.getByLabelText("Nombre:")).toBeInTheDocument(); + }); + await user.clear(screen.getByLabelText("Nombre:")); + await user.clear(screen.getByLabelText("Descripción:")); + await user.clear(screen.getByLabelText("Marca:")); + await user.clear(screen.getByLabelText("Precio:")); + await user.clear(screen.getByLabelText("Stock:")); + + const createButton = await screen.findByRole("button", { name: /Guardar componente/i }); + await user.click(createButton); + + await waitFor(() => { + expect(screen.getByText("Por favor, ingrese un nombre válido.")).toBeInTheDocument(); + expect(screen.getByText("Por favor, ingrese una descripción válida.")).toBeInTheDocument(); + expect(screen.getByText("Por favor, seleccione un tipo válido.")).toBeInTheDocument(); + expect(screen.getByText("Por favor, ingrese una marca válida.")).toBeInTheDocument(); + expect(screen.getByText("Por favor, ingrese un precio válido.")).toBeInTheDocument(); + expect(screen.getByText("Por favor, ingrese un stock válido.")).toBeInTheDocument(); + }); + + }); + + it("shows validation errors for existing data", async () => { + + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + { path: "/", Component: () => }, + { path: "/components/create", Component: ComponentsCreate }, + ]); + + render(); + + await user.type(screen.getByLabelText("Correo electrónico:"), "admin@example.com"); + await user.type(screen.getByLabelText("Contraseña:"), "adminpass"); + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + await waitFor(async () => { + expect(screen.getByLabelText("Nombre:")).toBeInTheDocument(); + }); + await user.type(screen.getByLabelText("Nombre:"), "Kingston NV3"); + await user.type(screen.getByLabelText("Descripción:"), "testDescription"); + await user.type(screen.getByLabelText("Marca:"), "testBrand"); + await screen.findByRole("option", { name: "Almacenamiento" }); + await user.selectOptions(screen.getByLabelText("Tipo de componente:"), "STORAGE"); + await user.type(screen.getByLabelText("Precio:"), "10"); + await user.type(screen.getByLabelText("Stock:"), "5"); + + const createButton = await screen.findByRole("button", { name: /Guardar componente/i }); + await user.click(createButton); + + await waitFor(() => { + expect(screen.getByText(/Ya existe un componente con ese nombre\./i)).toBeInTheDocument(); + }); + + }); + + it("shows validation errors for invalid format data", async () => { + + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + { path: "/", Component: () => }, + { path: "/components/create", Component: ComponentsCreate }, + ]); + + render(); + + await user.type(screen.getByLabelText("Correo electrónico:"), "admin@example.com"); + await user.type(screen.getByLabelText("Contraseña:"), "adminpass"); + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + await waitFor(() => { + expect(screen.getByLabelText("Nombre:")).toBeInTheDocument(); + }); + await user.type(screen.getByLabelText("Nombre:"), "testComponent"); + await user.type(screen.getByLabelText("Descripción:"), "testDescription"); + await screen.findByRole("option", { name: "Procesador" }); + await user.selectOptions(screen.getByLabelText("Tipo de componente:"), "CPU"); + await user.type(screen.getByLabelText("Marca:"), "testBrand"); + await user.type(screen.getByLabelText("Precio:"), "-10"); + await user.type(screen.getByLabelText("Stock:"), "-5"); + + const createButton = await screen.findByRole("button", { name: /Guardar componente/i }); + await user.click(createButton); + await waitFor(() => { + expect(screen.getByText("Por favor, ingrese un precio válido.")).toBeInTheDocument(); + expect(screen.getByText("Por favor, ingrese un stock válido.")).toBeInTheDocument(); + }); + + }); + + it("show error when creating a component with invalid image format", async () => { + + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + { path: "/", Component: () => }, + { path: "/components/create", Component: ComponentsCreate }, + ]); + + render(); + + await user.type(screen.getByLabelText("Correo electrónico:"), "user@example.com"); + await user.type(screen.getByLabelText("Contraseña:"), "userpass"); + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + const file = new File(["dummy image"], "user.txt", { type: "text/plain" }); + + await waitFor(async () => { + expect(screen.getByLabelText("Nombre:")).toBeInTheDocument(); + }); + await user.type(screen.getByLabelText("Nombre:"), "testName"); + await user.type(screen.getByLabelText("Descripción:"), "testDescription"); + await user.type(screen.getByLabelText("Marca:"), "testBrand"); + await screen.findByRole("option", { name: "Procesador" }); + await user.selectOptions(screen.getByLabelText("Tipo de componente:"), "CPU"); + await user.type(screen.getByLabelText("Precio:"), "10"); + await user.type(screen.getByLabelText("Stock:"), "5"); + await user.upload(screen.getByLabelText("Imagen del componente:"), file); + + const createButton = await screen.findByRole("button", { name: /Guardar componente/i }); + await user.click(createButton); + + await waitFor(() => { + expect(screen.getByText("Por favor, seleccione un archivo de imagen válido.")).toBeInTheDocument(); + }); + + }); + + it("cancels component creation", async () => { + + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + { path: "/", Component: () => }, + { path: "/components/create", Component: ComponentsCreate }, + { path: "/components", Component: Components, loader: () => ({ items: [], hasNext: false })}, + ]); + + render(); + + await user.type(screen.getByLabelText("Correo electrónico:"), "admin@example.com"); + await user.type(screen.getByLabelText("Contraseña:"), "adminpass"); + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + const cancelButton = await screen.findByRole("link", { name: /Cancelar/i }); + await user.click(cancelButton); + + await waitFor(() => { + expect(screen.getByText(/Componentes:/i)).toBeInTheDocument(); + }); + + }); + +}); \ No newline at end of file diff --git a/frontend/tests/integration/ComponentsPage.integration.test.tsx b/frontend/tests/integration/ComponentsPage.integration.test.tsx index 0556179..66615a2 100644 --- a/frontend/tests/integration/ComponentsPage.integration.test.tsx +++ b/frontend/tests/integration/ComponentsPage.integration.test.tsx @@ -1,12 +1,25 @@ import { render, screen, waitFor } from "@testing-library/react"; +import fetchCookie from "fetch-cookie"; +import { CookieJar } from "tough-cookie"; import { describe, it, expect, vi } from "vitest"; +import { afterEach, beforeEach } from "vitest"; import "@testing-library/jest-dom"; import userEvent from "@testing-library/user-event"; -import { MemoryRouter } from "react-router"; +import { createRoutesStub, MemoryRouter, Navigate } from "react-router"; import Components, { clientLoader } from "~/routes/components"; +import Login from "~/routes/login"; +import ComponentsCreate from "~/routes/components-create"; describe("ComponentsPageIntegration", () => { + beforeEach(() => { + vi.stubGlobal("fetch", fetchCookie(globalThis.fetch, new CookieJar())); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("renders initial components and checks load more functionality", async () => { const user = userEvent.setup(); @@ -116,4 +129,70 @@ describe("ComponentsPageIntegration", () => { fetchSpy.mockRestore(); }); + it("does not show the component create button for non-registered users", async () => { + render( + + + + ); + + await waitFor(() => { + expect(document.getElementById("create-component-button")).not.toBeInTheDocument(); + }); + }); + + +it("does not show the component create button for registered users no admin", async () => { + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + { path: "/", Component: () => }, + ]); + + render(); + + await user.type(screen.getByLabelText("Correo electrónico:"), "user@example.com"); + await user.type(screen.getByLabelText("Contraseña:"), "userpass"); + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + await waitFor(() => { + expect(document.getElementById("create-component-button")).not.toBeInTheDocument(); + }); + }); + + it("shows the component create button for admin users", async () => { + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + { path: "/", Component: () => }, + { path: "/components", Component: Components, loader: () => ({ items: [], hasNext: false }) }, + { path: "/components/create", Component: ComponentsCreate, loader: () => ({ componentTypes: ["CPU", "GPU", "RAM", "STORAGE", "MOTHERBOARD", "PSU", "CASE"] }) }, + ]); + + render(); + + await user.type(screen.getByLabelText("Correo electrónico:"), "admin@example.com"); + await user.type(screen.getByLabelText("Contraseña:"), "adminpass"); + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + await waitFor(() => { + expect(screen.getByRole("link", { name: /Crear componente/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("link", { name: /Crear componente/i })); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: /Ingrese los datos del componente/i })).toBeInTheDocument(); + }); + }); + }); \ No newline at end of file diff --git a/frontend/tests/integration/HomePage.integration.test.tsx b/frontend/tests/integration/HomePage.integration.test.tsx index 73ed43c..25170d9 100644 --- a/frontend/tests/integration/HomePage.integration.test.tsx +++ b/frontend/tests/integration/HomePage.integration.test.tsx @@ -28,7 +28,6 @@ describe("HomePageIntegration", () => { }); await waitFor(() => { - expect(screen.getByText("Seagate BarraCuda 3.5")).toBeInTheDocument(); expect(screen.getByText("Kingston FURY Beast")).toBeInTheDocument(); expect(screen.getByText("Corsair iCUE LINK 3500X RGB")).toBeInTheDocument(); }); diff --git a/frontend/tests/unit/ComponentDetailPage.test.tsx b/frontend/tests/unit/ComponentDetailPage.test.tsx index 2033e67..c846ebb 100644 --- a/frontend/tests/unit/ComponentDetailPage.test.tsx +++ b/frontend/tests/unit/ComponentDetailPage.test.tsx @@ -36,6 +36,17 @@ const component2 = { image: null, }; +const component3 = { + id: 3, + name: "NVIDIA GeForce RTX 4090", + description: "Gráfica de alto rendimiento.", + type: "GPU", + brand: "NVIDIA", + price: 1150, + stock: 0, + image: null, +}; + vi.mock("~/services/users-service", () => ({ reqIsLogged: vi.fn(), })); @@ -118,6 +129,23 @@ describe("ComponentDetailPage", () => { }); + it("renders no stock warning when stock is 0", async () => { + + vi.mocked(getComponent).mockResolvedValue(component3); + + const RouterStub = createRoutesStub([ + { path: "/components/:id", Component: ComponentDetail, loader: () => component3 }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText("¡No hay stock!")).toBeInTheDocument(); + expect(document.getElementById("add-to-cart-button")).toBeDisabled(); + }); + + }); + it("shows only the component add to cart button for non-registered users", async () => { const RouterStub = createRoutesStub([ diff --git a/frontend/tests/unit/ComponentForm.test.tsx b/frontend/tests/unit/ComponentForm.test.tsx new file mode 100644 index 0000000..68a194d --- /dev/null +++ b/frontend/tests/unit/ComponentForm.test.tsx @@ -0,0 +1,289 @@ +import { render, waitFor, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import userEvent from "@testing-library/user-event"; +import { createRoutesStub } from "react-router"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import ComponentsCreate, { clientLoader } from "~/routes/components-create"; +import ComponentDetail from "~/routes/detail-component"; +import { addImage, createComponent, getComponentTypes } from "~/services/components-service"; +import { reqIsLogged } from "~/services/users-service"; +import { useUserStore } from "~/stores/user-store"; +import { requireAdminUser } from "~/services/route-guard"; + +vi.mock("~/services/users-service", () => ({ + reqIsLogged: vi.fn(), +})); + +vi.mock("~/services/components-service", () => ({ + createComponent: vi.fn(), + addImage: vi.fn(), + getComponentTypes: vi.fn(), +})); + +vi.mock("~/services/route-guard", () => ({ + requireAdminUser: vi.fn(), +})); + +const adminAccount = { + id: 2, + name: "Admin", + surname: "Example", + username: "admin@example.com", + address: "Admin address", + email: "admin@example.com", + roles: ["REGISTERED_USER", "ADMIN"], +}; + +describe("ComponentForm", () => { + + beforeEach(() => { + vi.clearAllMocks(); + useUserStore.setState({ user: null }); + vi.mocked(reqIsLogged).mockResolvedValue(null as never); + vi.mocked(getComponentTypes).mockResolvedValue(["CPU", "GPU", "RAM", "MOTHERBOARD", "STORAGE", "PSU", "Case"]); + }); + + it("renders the component form", async () => { + + vi.mocked(reqIsLogged).mockResolvedValue(adminAccount); + + const RouterStub = createRoutesStub([ + { path: "/components/create", Component: ComponentsCreate }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByLabelText("Nombre:")).toBeInTheDocument(); + expect(screen.getByLabelText("Descripción:")).toBeInTheDocument(); + expect(screen.getByLabelText("Tipo de componente:")).toBeInTheDocument(); + expect(screen.getByLabelText("Marca:")).toBeInTheDocument(); + expect(screen.getByLabelText("Precio:")).toBeInTheDocument(); + expect(screen.getByLabelText("Stock:")).toBeInTheDocument(); + expect(screen.getByLabelText("Imagen del componente:")).toBeInTheDocument(); + }); + + }); + + it("clientLoader returns the admin user", async () => { + vi.mocked(requireAdminUser).mockResolvedValue(adminAccount); + + const result = await clientLoader(); + + expect(requireAdminUser).toHaveBeenCalledTimes(1); + expect(result).toEqual(adminAccount); + }); + + it("creates a new component with valid credentials", async () => { + + const user = userEvent.setup(); + vi.mocked(reqIsLogged).mockResolvedValue(adminAccount); + + vi.mocked(createComponent).mockResolvedValue({ + id: 1, + name: "testComponent", + description: "testDescription", + type: "CPU", + brand: "testBrand", + price: 10, + stock: 5, + }); + vi.mocked(addImage).mockResolvedValue({} as Awaited>); + + const RouterStub = createRoutesStub([ + { path: "/components/create", Component: ComponentsCreate }, + { path: "/components/:id", + Component: ComponentDetail, + loader: async ({ params }) => { + return { + id: Number(params.id), + name: "testComponent", + description: "testDescription", + type: "CPU", + brand: "testBrand", + price: 10, + stock: 5, + }; + } + } + ]); + + render(); + + const file = new File(["dummy content"], "component.jpg", { type: "image/jpeg" }); + + await user.type(screen.getByLabelText("Nombre:"), "testComponent"); + await user.type(screen.getByLabelText("Descripción:"), "testDescription"); + await screen.findByRole("option", { name: "Procesador" }); + await user.selectOptions(screen.getByLabelText("Tipo de componente:"), "CPU"); + await user.type(screen.getByLabelText("Marca:"), "testBrand"); + await user.type(screen.getByLabelText("Precio:"), "10"); + await user.type(screen.getByLabelText("Stock:"), "5"); + await user.upload(screen.getByLabelText("Imagen del componente:"), file); + + const createButton = await screen.findByRole("button", { name: /Guardar componente/i }); + await user.click(createButton); + + await waitFor(() => { + expect(createComponent).toHaveBeenCalledTimes(1); + expect(screen.getByText("testComponent")).toBeInTheDocument(); + expect(screen.getByText("testDescription")).toBeInTheDocument(); + expect(screen.getByText(/testBrand/i)).toBeInTheDocument(); + expect(screen.getByText(/CPU/i)).toBeInTheDocument(); + expect(screen.getByText(/10\.00/i)).toBeInTheDocument(); + }); + + }); + + it("show error when creating a new component with void data", async () => { + + const user = userEvent.setup(); + + vi.mocked(reqIsLogged).mockResolvedValue(adminAccount); + + const RouterStub = createRoutesStub([ + { path: "/components/create", Component: ComponentsCreate }, + ]); + + render(); + + await user.clear(screen.getByLabelText("Nombre:")); + await user.clear(screen.getByLabelText("Descripción:")); + await user.clear(screen.getByLabelText("Marca:")); + await user.clear(screen.getByLabelText("Precio:")); + await user.clear(screen.getByLabelText("Stock:")); + + const createButton = await screen.findByRole("button", { name: /Guardar componente/i }); + await user.click(createButton); + + await waitFor(() => { + expect(createComponent).not.toHaveBeenCalled(); + expect(screen.getByText("Por favor, ingrese un nombre válido.")).toBeInTheDocument(); + expect(screen.getByText("Por favor, ingrese una descripción válida.")).toBeInTheDocument(); + expect(screen.getByText("Por favor, seleccione un tipo válido.")).toBeInTheDocument(); + expect(screen.getByText("Por favor, ingrese una marca válida.")).toBeInTheDocument(); + expect(screen.getByText("Por favor, ingrese un precio válido.")).toBeInTheDocument(); + expect(screen.getByText("Por favor, ingrese un stock válido.")).toBeInTheDocument(); + }); + + }); + + it("show error when creating a new component with existing data", async () => { + + const user = userEvent.setup(); + + vi.mocked(reqIsLogged).mockResolvedValue(adminAccount); + + vi.mocked(createComponent).mockRejectedValue(new Error( + "Validación fallida:\n• Ya existe un componente con ese nombre." + )); + + const RouterStub = createRoutesStub([ + { path: "/components/create", Component: ComponentsCreate }, + ]); + + render(); + + await user.type(screen.getByLabelText("Nombre:"), "testComponent"); + await user.type(screen.getByLabelText("Descripción:"), "testDescription"); + await screen.findByRole("option", { name: "Procesador" }); + await user.selectOptions(screen.getByLabelText("Tipo de componente:"), "CPU"); + await user.type(screen.getByLabelText("Marca:"), "testBrand"); + await user.type(screen.getByLabelText("Precio:"), "10"); + await user.type(screen.getByLabelText("Stock:"), "5"); + + const createButton = await screen.findByRole("button", { name: /Guardar componente/i }); + await user.click(createButton); + + await waitFor(() => { + expect(createComponent).toHaveBeenCalledTimes(1); + expect(screen.getByText(/Ya existe un componente con ese nombre\./i)).toBeInTheDocument(); + }); + + }); + + it("show error when creating a new component with invalid format", async () => { + + const user = userEvent.setup(); + + vi.mocked(reqIsLogged).mockResolvedValue(adminAccount); + + const RouterStub = createRoutesStub([ + { path: "/components/create", Component: ComponentsCreate }, + ]); + + render(); + + await user.type(screen.getByLabelText("Nombre:"), "testComponent"); + await user.type(screen.getByLabelText("Descripción:"), "testDescription"); + await screen.findByRole("option", { name: "Procesador" }); + await user.selectOptions(screen.getByLabelText("Tipo de componente:"), "CPU"); + await user.type(screen.getByLabelText("Marca:"), "testBrand"); + await user.type(screen.getByLabelText("Precio:"), "-10"); + await user.type(screen.getByLabelText("Stock:"), "-5"); + + const createButton = await screen.findByRole("button", { name: /Guardar componente/i }); + await user.click(createButton); + + await waitFor(() => { + expect(createComponent).not.toHaveBeenCalled(); + expect(screen.getByText("Por favor, ingrese un precio válido.")).toBeInTheDocument(); + expect(screen.getByText("Por favor, ingrese un stock válido.")).toBeInTheDocument(); + }); + + }); + + it("shows error when uploading an image with invalid format", async () => { + + const user = userEvent.setup(); + + vi.mocked(reqIsLogged).mockResolvedValue(adminAccount); + + const file = new File(["dummy image"], "user.txt", { type: "text/plain" }); + + const RouterStub = createRoutesStub([ + { path: "/components/create", Component: ComponentsCreate }, + ]); + + render(); + + await user.type(screen.getByLabelText("Nombre:"), "testComponent"); + await user.type(screen.getByLabelText("Descripción:"), "testDescription"); + await screen.findByRole("option", { name: "Procesador" }); + await user.selectOptions(screen.getByLabelText("Tipo de componente:"), "CPU"); + await user.type(screen.getByLabelText("Marca:"), "testBrand"); + await user.type(screen.getByLabelText("Precio:"), "10"); + await user.type(screen.getByLabelText("Stock:"), "5"); + await user.upload(screen.getByLabelText("Imagen del componente:"), file); + + const createButton = await screen.findByRole("button", { name: /Guardar componente/i }); + await user.click(createButton); + + await waitFor(() => { + expect(addImage).not.toHaveBeenCalled(); + expect(screen.getByText("Por favor, seleccione un archivo de imagen válido.")).toBeInTheDocument(); + }); + + }); + + it("cancel component creation", async () => { + + const user = userEvent.setup(); + + vi.mocked(reqIsLogged).mockResolvedValue(adminAccount); + + const RouterStub = createRoutesStub([ + { path: "/components/create", Component: ComponentsCreate } + ]); + + render(); + + const cancelButton = await screen.findByRole("link", { name: /Cancelar/ }); + await user.click(cancelButton); + + await waitFor(() => { + expect(createComponent).not.toHaveBeenCalled(); + }); + + }); +}); \ No newline at end of file diff --git a/frontend/tests/unit/ComponentsPage.test.tsx b/frontend/tests/unit/ComponentsPage.test.tsx index d3a07ed..7656de7 100644 --- a/frontend/tests/unit/ComponentsPage.test.tsx +++ b/frontend/tests/unit/ComponentsPage.test.tsx @@ -1,19 +1,48 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { reqIsLogged } from "~/services/users-service"; +import { useUserStore } from "~/stores/user-store"; 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 { MemoryRouter, createRoutesStub } from "react-router"; +import ComponentsCreate from "~/routes/components-create"; vi.mock("~/services/components-service", () => ({ getComponents: vi.fn(), })); +vi.mock("~/services/users-service", () => ({ + reqIsLogged: vi.fn(), +})); + +const userAccount = { + id: 1, + name: "User", + surname: "Example", + username: "user@example.com", + address: "User address", + email: "user@example.com", + roles: ["REGISTERED_USER"], +}; + +const adminAccount = { + id: 2, + name: "Admin", + surname: "Example", + username: "admin@example.com", + address: "Admin address", + email: "admin@example.com", + roles: ["REGISTERED_USER", "ADMIN"], +}; + describe("ComponentsPage", () => { beforeEach(() => { vi.clearAllMocks(); + useUserStore.setState({ user: null }); + vi.mocked(reqIsLogged).mockResolvedValue(null as never); }); it("renders all components", async () => { @@ -205,4 +234,55 @@ describe("ComponentsPage", () => { expect(getComponents).toHaveBeenCalledWith(1); expect(screen.getByRole("button", { name: /Cargar más componentes/i })).toBeInTheDocument(); }); + + it("does not show create component button for no registered users", async () => { + const RouterStub = createRoutesStub([ + { path: "/components", Component: Components, loader: () => ({ items: [], hasNext: false }) }, + { path: "/components/create", Component: ComponentsCreate, loader: () => ({ componentTypes: ["CPU", "GPU", "RAM", "STORAGE", "MOTHERBOARD", "PSU", "CASE"] }) }, + ]); + + render(); + + await waitFor(() => { + expect(document.getElementById("create-component-button")).not.toBeInTheDocument(); + }); + }); + + it("does not show create component button when user is logged in as no admin", async () => { + const RouterStub = createRoutesStub([ + { path: "/components", Component: Components, loader: () => ({ items: [], hasNext: false }) }, + { path: "/components/create", Component: ComponentsCreate, loader: () => ({ componentTypes: ["CPU", "GPU", "RAM", "STORAGE", "MOTHERBOARD", "PSU", "CASE"] }) }, + ]); + + vi.mocked(reqIsLogged).mockResolvedValue(userAccount); + useUserStore.setState({ user: userAccount }); + + render(); + + await waitFor(() => { + expect(document.getElementById("create-component-button")).not.toBeInTheDocument(); + }); + }); + + it("shows create component button when user is logged in as admin", async () => { + const RouterStub = createRoutesStub([ + { path: "/components", Component: Components, loader: () => ({ items: [], hasNext: false }) }, + { path: "/components/create", Component: ComponentsCreate, loader: () => ({ componentTypes: ["CPU", "GPU", "RAM", "STORAGE", "MOTHERBOARD", "PSU", "CASE"] }) }, + ]); + + vi.mocked(reqIsLogged).mockResolvedValue(adminAccount); + useUserStore.setState({ user: adminAccount }); + + render(); + + await waitFor(() => { + expect(screen.getByRole("link", { name: /Crear componente/i })).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole("link", { name: /Crear componente/i })); + await waitFor(() => { + expect(screen.getByRole("heading", { name: /Ingrese los datos del componente/i })).toBeInTheDocument(); + }); + }); + }); \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties index 8dced39..a5b7aa4 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -8,6 +8,6 @@ sonar.java.binaries=backend/pcmod/target/classes sonar.coverage.jacoco.xmlReportPaths=backend/pcmod/target/site/jacoco/jacoco.xml sonar.javascript.lcov.reportPaths=frontend/coverage/lcov.info -sonar.exclusions=backend/pcmod/target/**,backend/**/PcmodApplication.java,backend/**/SpaRoutingConfig.java,backend/**/security/**,frontend/node_modules/**,frontend/dist/**,frontend/build/**,frontend/app/components/header.tsx,frontend/app/components/footer.tsx,frontend/app/routes/home.tsx,frontend/app/app.css,frontend/app/root.tsx,frontend/app/routes.ts,frontend/app/stores/** +sonar.exclusions=backend/pcmod/target/**,backend/**/PcmodApplication.java,backend/**/SpaRoutingConfig.java,backend/**/security/**,frontend/node_modules/**,frontend/dist/**,frontend/build/**,frontend/app/components/header.tsx,frontend/app/components/footer.tsx,frontend/app/routes/home.tsx,frontend/app/app.css,frontend/app/root.tsx,frontend/app/routes.ts,frontend/app/stores/**,frontend/app/services/route-guard.ts sonar.coverage.exclusions=backend/**/model/** sonar.qualitygate.wait=true \ No newline at end of file