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 047e7d7..3b5dbff 100644 --- a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/ComponentsRestController.java +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/ComponentsRestController.java @@ -1,5 +1,6 @@ package es.codeurjcstudents.pcmod.controller; +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.RequestMapping; @@ -54,4 +55,11 @@ public ResponseEntity getComponentImage(@PathVariable long id) throws return ResponseEntity.ok().contentType(mediaType).body(imageFile); } + @DeleteMapping("/{id}") + public void deleteComponent(@PathVariable long id) { + + componentService.delete(id); + + } + } diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/model/Component.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/model/Component.java index 193620c..f0abcf5 100644 --- a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/model/Component.java +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/model/Component.java @@ -4,6 +4,7 @@ import java.math.BigDecimal; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -22,7 +23,7 @@ public class Component { @GeneratedValue(strategy = GenerationType.AUTO) private Long id = null; - @OneToOne + @OneToOne(cascade = CascadeType.REMOVE, orphanRemoval = true) private Image image; @NotBlank(message = "El nombre del componente es obligatorio") 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 af13137..b4d531a 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 @@ -4,12 +4,12 @@ import java.util.List; import java.util.Optional; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.Resource; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.server.ResponseStatusException; import org.springframework.stereotype.Service; @@ -58,4 +58,17 @@ public Resource getImageFile(long id) throws SQLException { return null; } + + @PreAuthorize("hasRole('ADMIN')") + public void delete(long id) { + + Optional deletedComponent = componentRepository.findById(id); + + if (deletedComponent.isPresent()) { + + componentRepository.deleteById(id); + + } + + } } 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 16ee900..0af5c5b 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 @@ -164,6 +164,13 @@ public void init() throws IOException { setComponentImage(component11, "/sample_images/kingston-fury.jpg"); componentsRepository.save(component11); + Component component12 = new Component("Corsair iCUE LINK 3500X RGB", + "ENSAMBLAJES SIN LÍMITES. No hace falta ocupar mucho espacio para montar un PC con un diseño de primera. El 3500X tiene espacio para la mayoría de tarjetas gráficas y refrigeradores de CPU grandes, radiadores de 360 mm e incluso placas base EATX, sin ocupar todo el escritorio. Para un diseño realmente impecable, el 3500X es compatible con las placas base ASUS BTF y MSI PROJECT ZERO, manteniendo las conexiones y cables ocultos.\r\n" + // + "INCLUYE VENTILADORES iCUE LINK RX RGB PERFORMANCE. Se incluye un iCUE LINK System Hub y tres ventiladores RX RGB, lo que hacen de este chasis una maravilla lista para montar dentro de un revolucionario ecosistema. iCUE LINK elimina el desorden de los cables y transforma su diseño en una elegante exposición.", + ComponentType.CASE, "Corsair", BigDecimal.valueOf(118.99), 5); + setComponentImage(component12, "/sample_images/corsair-icue-link.jpg"); + componentsRepository.save(component12); + // Sample Users userService.createUser(new User("user", "example", "user_example", "c/example_address 1", "user@example.com", userPasswordEncoded, "REGISTERED_USER")); diff --git a/backend/pcmod/src/main/resources/sample_images/corsair-icue-link.jpg b/backend/pcmod/src/main/resources/sample_images/corsair-icue-link.jpg new file mode 100644 index 0000000..1fcf5ef Binary files /dev/null and b/backend/pcmod/src/main/resources/sample_images/corsair-icue-link.jpg differ diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/ComponentsSystemTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/ComponentsSystemTests.java index 367a543..8bd32fa 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 @@ -36,8 +36,8 @@ public void getComponents() { .when().get("/components/") .then().statusCode(200).contentType(ContentType.JSON) // Global checks - .body("totalElements", equalTo(11)) - .body("content", hasSize(11)) + .body("totalElements", greaterThan(10)) + .body("content", hasSize(greaterThan(10))) .body("content.brand", hasItems("AMD", "Gigabyte", "Corsair", "MSI", "Kingston", "Asus", "Seagate", "Lian Li", "Intel")) .body("content.price", everyItem(greaterThanOrEqualTo(0.0f))) @@ -67,7 +67,7 @@ public void getComponent() { @Test public void getComponentNotFound() { given().header("Content-Type", "application/json") - .when().get("/components/12") + .when().get("/components/13") .then().statusCode(404).contentType(ContentType.JSON) .body("error", equalTo("Not Found")) .body("message", equalTo("Componente no encontrado.")); @@ -88,4 +88,58 @@ public void getComponentImage() throws IOException { assertArrayEquals(expectedImage, actualImage); } + @Test + public void deleteComponentAsNotLoggedIn() { + + given() + .when().delete("components/1") + .then().statusCode(401).contentType(ContentType.JSON) + .body("status", equalTo(401)) + .body("error", equalTo("Unauthorized")); + } + + @Test + public void deleteComponentAsLoggedInNoAdmin() { + 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() + .header("Content-Type", "application/json") + .cookie("AuthToken", authToken) + .when().delete("components/1") + .then().statusCode(403).contentType(ContentType.JSON) + .body("status", equalTo(403)) + .body("error", equalTo("Forbidden")); + } + + @Test + public void deleteComponentAsAdmin() { + 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() + .header("Content-Type", "application/json") + .cookie("AuthToken", authToken) + .when().delete("components/1") + .then().statusCode(200); + } + } diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/HomePageSystemTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/HomePageSystemTests.java index 04073ad..4c546f0 100644 --- a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/HomePageSystemTests.java +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/HomePageSystemTests.java @@ -34,9 +34,9 @@ public void getComponents() { // Global checks .body("size()", equalTo(3)) // Specific checks - .body("[0].name", equalTo("Kingston FURY Beast")) - .body("[1].name", equalTo("Seagate BarraCuda 3.5")) - .body("[2].name", equalTo("AMD Radeon RX 9060 XT DUAL WHITE")); + .body("[0].name", equalTo("Corsair iCUE LINK 3500X RGB")) + .body("[1].name", equalTo("Kingston FURY Beast")) + .body("[2].name", equalTo("Seagate BarraCuda 3.5")); } } 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 96e29cf..2e6ef34 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 @@ -232,4 +232,98 @@ public void componentDetailOptionsAsAdminTest() { assertThat(driver.findElements(By.id("delete-component-button"))).isNotEmpty(); } + + @Test + public void deleteComponentTest() { + + 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/")); + + driver.get("http://localhost:5173/components/8"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("delete-component-button"))); + scrollToAndClick(By.id("delete-component-button")); + WebElement deleteModal = wait.until(ExpectedConditions.visibilityOfElementLocated(By.cssSelector(".modal.show"))); + assertThat(deleteModal.findElement(By.className("modal-title")).getText()) + .isEqualTo("¿Está seguro de que desea borrar este componente?"); + assertThat(deleteModal.findElement(By.className("modal-body")).getText()) + .contains("Toda su información se perderá y no se podrá recuperar.") + .contains("Esta acción no se puede deshacer."); + scrollToAndClick(By.cssSelector(".modal-footer .pcmod-danger-button")); + + driver.get("http://localhost:5173/components?page=0"); + assertThat(driver.getPageSource()).doesNotContain("Intel Core i5-12400F"); + + } + + @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/")); + + driver.get("http://localhost:5173/components/1"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("delete-component-button"))); + scrollToAndClick(By.id("delete-component-button")); + WebElement deleteModal = wait.until(ExpectedConditions.visibilityOfElementLocated(By.cssSelector(".modal.show"))); + assertThat(deleteModal.findElement(By.className("modal-title")).getText()) + .isEqualTo("¿Está seguro de que desea borrar este componente?"); + assertThat(deleteModal.findElement(By.className("modal-body")).getText()) + .contains("Toda su información se perderá y no se podrá recuperar.") + .contains("Esta acción no se puede deshacer."); + scrollToAndClick(By.cssSelector(".modal-footer .btn-secondary")); + + driver.get("http://localhost:5173/components"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-1"))); + assertThat(driver.findElement(By.id("component-1")).getText()) + .isEqualTo("AMD Ryzen 7 7800X3D"); + + } + + @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/")); + + driver.get("http://localhost:5173/components/1"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("delete-component-button"))); + scrollToAndClick(By.id("delete-component-button")); + WebElement deleteModal = wait.until(ExpectedConditions.visibilityOfElementLocated(By.cssSelector(".modal.show"))); + assertThat(deleteModal.findElement(By.className("modal-title")).getText()) + .isEqualTo("¿Está seguro de que desea borrar este componente?"); + assertThat(deleteModal.findElement(By.className("modal-body")).getText()) + .contains("Toda su información se perderá y no se podrá recuperar.") + .contains("Esta acción no se puede deshacer."); + scrollToAndClick(By.cssSelector(".modal-header .btn-close")); + + driver.get("http://localhost:5173/components"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-1"))); + assertThat(driver.findElement(By.id("component-1")).getText()) + .isEqualTo("AMD Ryzen 7 7800X3D"); + + } } \ No newline at end of file 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 fca1b6e..f56c799 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 @@ -57,10 +57,6 @@ public void loadHomePage() { String logoMessage = driver.findElement(By.id("logo")).getAttribute("alt"); assertThat(logoMessage).isEqualTo("PCMod Logo"); - wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-9"))); - String componentName9 = driver.findElement(By.id("component-9")).getText(); - assertThat(componentName9).isEqualTo("AMD Radeon RX 9060 XT DUAL WHITE"); - wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-10"))); String componentName10 = driver.findElement(By.id("component-10")).getText(); assertThat(componentName10).isEqualTo("Seagate BarraCuda 3.5"); @@ -69,6 +65,10 @@ public void loadHomePage() { 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"); + 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 7ae011d..c6f319b 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 @@ -8,6 +8,7 @@ import java.io.IOException; import java.math.BigDecimal; import java.sql.SQLException; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; @@ -18,6 +19,13 @@ import org.springframework.core.io.Resource; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.springframework.web.server.ResponseStatusException; @@ -27,7 +35,9 @@ import es.codeurjcstudents.pcmod.enums.ComponentType; import es.codeurjcstudents.pcmod.model.Component; +import es.codeurjcstudents.pcmod.model.User; import es.codeurjcstudents.pcmod.repository.ComponentsRepository; +import es.codeurjcstudents.pcmod.repository.UsersRepository; import es.codeurjcstudents.pcmod.service.ComponentsService; import es.codeurjcstudents.pcmod.service.ImageService; @@ -61,6 +71,11 @@ static void configureProperties(DynamicPropertyRegistry registry) { @Autowired private ImageService imageService; + @Autowired + private UsersRepository usersRepository; + + private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + @BeforeEach void setUp() { componentsRepository.deleteAll(); @@ -77,6 +92,14 @@ void setUp() { "DisplayPort 2.1 y HDMI 2.1b hasta 8K\r\n" + // "Tensor Cores 5ª gen y Reflex 2 para IA avanzada", ComponentType.GPU, "Gigabyte", BigDecimal.valueOf(359.85), 2)); + + SecurityContextHolder.clearContext(); + usersRepository.deleteAll(); + usersRepository.save(new User("user1", "test", "userTest1", "c/testaddress", "testuser1@example.com", + passwordEncoder.encode("pass"), "REGISTERED_USER")); + usersRepository.save(new User("admin", "test", "adminTest1", "c/testaddress", "testadmin1@example.com", + passwordEncoder.encode("adminpass"), "REGISTERED_USER", "ADMIN")); + } @Test @@ -129,4 +152,43 @@ public void testGetNullComponentImage() throws SQLException { assertEquals(null, actualImage); } + @Test + void deleteComponentAsNotLoggedIn() { + + assertThrows(AuthenticationCredentialsNotFoundException.class, () -> componentsService.delete(1L)); + + } + + @Test + void deleteComponentAsLoggedInNoAdmin() { + User user = usersRepository.findByEmail("testuser1@example.com").orElseThrow(); + Component component = componentsRepository.findAll().get(0); + + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + org.springframework.security.core.userdetails.User.withUsername(user.getId().toString()) + .password("").authorities("ROLE_REGISTERED_USER").build(), + null, List.of(new SimpleGrantedAuthority("ROLE_REGISTERED_USER")))); + + assertThrows(AuthorizationDeniedException.class, () -> componentsService.delete(component.getId())); + assertEquals(2, componentsRepository.count()); + + } + + @Test + void deleteComponentAsAdmin() { + User admin = usersRepository.findByEmail("testadmin1@example.com").orElseThrow(); + Component component = componentsRepository.findAll().get(0); + + 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")))); + + assertDoesNotThrow(() -> componentsService.delete(component.getId())); + assertEquals(1, componentsRepository.count()); + + } + } \ No newline at end of file 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 1ecf415..05357c9 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 @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -111,4 +112,36 @@ public void testGetNullComponentImage() throws SQLException { verify(componentsRepository).findImageById(1L); } + @Test + public void testDeleteComponent() { + + ComponentsRepository componentsRepository = mock(ComponentsRepository.class); + ComponentMapper componentMapper = mock(ComponentMapper.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper); + + 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.findById(1L)).thenReturn(Optional.of(component)); + + componentsService.delete(1L); + + verify(componentsRepository).deleteById(1L); + + } + + @Test + public void testDeleteNonExistentComponent() { + + ComponentsRepository componentsRepository = mock(ComponentsRepository.class); + ComponentMapper componentMapper = mock(ComponentMapper.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper); + + when(componentsRepository.findById(1L)).thenReturn(Optional.empty()); + + componentsService.delete(1L); + + verify(componentsRepository, never()).deleteById(1L); + + } + } diff --git a/frontend/app/routes/detail-component.tsx b/frontend/app/routes/detail-component.tsx index 7b064a2..6b1a922 100644 --- a/frontend/app/routes/detail-component.tsx +++ b/frontend/app/routes/detail-component.tsx @@ -1,9 +1,11 @@ -import { Button, Col, Image } from "react-bootstrap"; -import { CartPlus, Image as ImageIcon, PencilSquare, Trash, XOctagon } from "react-bootstrap-icons"; +import { Button, Col, Image, Modal } from "react-bootstrap"; +import { ArrowLeft, CartPlus, Image as ImageIcon, PencilSquare, Trash, XOctagon } from "react-bootstrap-icons"; import type { Route } from "./+types/detail-component"; import type ComponentDTO from "~/dtos/ComponentDTO"; -import { getComponent } from "~/services/components-service"; +import { deleteComponent, getComponent } from "~/services/components-service"; import { useUserStore } from "~/stores/user-store"; +import { useState } from "react"; +import { useNavigate } from "react-router"; const API_URL = "/api/v1/components"; @@ -16,8 +18,21 @@ export default function ComponentDetail({ loaderData }: Route.ComponentProps) { const component = loaderData; const price = component.price.toFixed(2); let { user } = useUserStore(); + const navigate = useNavigate(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + async function handleDeleteComponent() { + try { + await deleteComponent(component.id); + await navigate("/components"); + } catch (error) { + console.error(error); + } + } return ( + <>
@@ -53,7 +68,7 @@ export default function ComponentDetail({ loaderData }: Route.ComponentProps) { - @@ -71,6 +86,28 @@ export default function ComponentDetail({ loaderData }: Route.ComponentProps) {
+ + setIsDeleteModalOpen(false)}> + + ¿Está seguro de que desea borrar este componente? + + +

+ Toda su información se perderá y no se podrá recuperar. Esta acción no se puede deshacer. +

+
+ + + + +
+ ); } \ No newline at end of file diff --git a/frontend/app/services/components-service.ts b/frontend/app/services/components-service.ts index 8c279e7..1a56c3e 100644 --- a/frontend/app/services/components-service.ts +++ b/frontend/app/services/components-service.ts @@ -70,4 +70,17 @@ export async function getComponent(id: number): Promise { } return await res.json(); +} + +export async function deleteComponent(id: number): Promise { + + const url = new URL(`${API_URL}/${id}`, getBaseUrl()); + const response = await fetch(url.toString(), { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error("Error deleting component"); + } + } \ 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 76c729e..da0bdc5 100644 --- a/frontend/tests/integration/ComponentDetailPage.integration.test.tsx +++ b/frontend/tests/integration/ComponentDetailPage.integration.test.tsx @@ -7,6 +7,8 @@ import "@testing-library/jest-dom"; import { CookieJar } from "tough-cookie"; import ComponentDetail, { clientLoader } from "~/routes/detail-component"; import Login from "~/routes/login"; +import Components from "~/routes/components"; +import { getComponents } from "~/services/components-service"; describe("ComponentDetailPageIntegration", () => { @@ -44,9 +46,11 @@ describe("ComponentDetailPageIntegration", () => { const componentData = await clientLoader({ params: { id: "1" } } as Parameters[0]); render( - , + + + ); await waitFor(() => { @@ -58,9 +62,11 @@ describe("ComponentDetailPageIntegration", () => { const componentData = await clientLoader({ params: { id: "3" } } as Parameters[0]); render( - , + + + ); await waitFor(() => { @@ -72,9 +78,11 @@ describe("ComponentDetailPageIntegration", () => { const componentData = await clientLoader({ params: { id: "1" } } as Parameters[0]); render( - + + + ); await waitFor(() => { @@ -126,4 +134,106 @@ describe("ComponentDetailPageIntegration", () => { }); }); + it("cancel component deletion", async () => { + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + { path: "/", Component: () => }, + { 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 })); + + await waitFor(() => { + expect(document.getElementById("delete-component-button")).toBeInTheDocument(); + }); + await user.click(document.getElementById("delete-component-button")!); + + expect(await screen.findByText("¿Está seguro de que desea borrar este componente?")).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /Cancelar/ })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /Eliminar componente/ })).toBeInTheDocument(); + + await user.click(await screen.findByRole("button", { name: /Cancelar/ })); + + await waitFor(() => { + expect(screen.queryByText("AMD Ryzen 7 7800X3D")).toBeInTheDocument(); + }); + + }); + + it("cancel component deletion with the close button", async () => { + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + { path: "/", Component: () => }, + { 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 })); + + await waitFor(() => { + expect(document.getElementById("delete-component-button")).toBeInTheDocument(); + }); + await user.click(document.getElementById("delete-component-button")!); + + expect(await screen.findByText("¿Está seguro de que desea borrar este componente?")).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /Cancelar/ })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /Eliminar componente/ })).toBeInTheDocument(); + + await user.click(await screen.findByRole("button", { name: "Close" })); + + await waitFor(() => { + expect(screen.queryByText("¿Está seguro de que desea borrar este componente?")).not.toBeInTheDocument(); + expect(screen.queryByText("AMD Ryzen 7 7800X3D")).toBeInTheDocument(); + }); + + }); + + it("deletes component", async () => { + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + { path: "/", Component: () => }, + { path: "/components/:id", Component: ComponentDetail, loader: clientLoader }, + { path: "/components", Component: Components, + loader: async ({ request }) => { + const url = new URL(request.url); + const page = Number(url.searchParams.get("page") ?? 0); + const result = await getComponents(page); + return { items: result.items, hasNext: result.hasNext }; + } + } + ]); + + render(); + + 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(document.getElementById("delete-component-button")).toBeInTheDocument(); + }); + await user.click(document.getElementById("delete-component-button")!); + + expect(await screen.findByText("¿Está seguro de que desea borrar este componente?")).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /Cancelar/ })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /Eliminar componente/ })).toBeInTheDocument(); + + await user.click(await screen.findByRole("button", { name: /Eliminar componente/ })); + + await waitFor(() => { + expect(screen.queryByText("Intel Core i5-12400F")).not.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 d5ad5de..0556179 100644 --- a/frontend/tests/integration/ComponentsPage.integration.test.tsx +++ b/frontend/tests/integration/ComponentsPage.integration.test.tsx @@ -44,8 +44,7 @@ describe("ComponentsPageIntegration", () => { await waitFor(() => { const updatedComponentsNames = screen.getAllByRole("heading", { level: 3 }); - expect(updatedComponentsNames).toHaveLength(11); - expect(updatedComponentsNames[10]).toHaveTextContent("Kingston FURY Beast"); + expect(updatedComponentsNames.length).toBeGreaterThan(10); }); const buttonHasNextFalse = screen.queryByRole("button", { name: /Cargar más componentes/i }); diff --git a/frontend/tests/integration/HomePage.integration.test.tsx b/frontend/tests/integration/HomePage.integration.test.tsx index adad74b..73ed43c 100644 --- a/frontend/tests/integration/HomePage.integration.test.tsx +++ b/frontend/tests/integration/HomePage.integration.test.tsx @@ -28,9 +28,9 @@ describe("HomePageIntegration", () => { }); await waitFor(() => { - expect(screen.getByText("AMD Radeon RX 9060 XT DUAL WHITE")).toBeInTheDocument(); expect(screen.getByText("Seagate BarraCuda 3.5")).toBeInTheDocument(); expect(screen.getByText("Kingston FURY Beast")).toBeInTheDocument(); + expect(screen.getByText("Corsair iCUE LINK 3500X RGB")).toBeInTheDocument(); }); expect(screen.queryByRole("button", { name: /Ver todos los componentes/i })).toBeInTheDocument(); diff --git a/frontend/tests/unit/ComponentDetailPage.test.tsx b/frontend/tests/unit/ComponentDetailPage.test.tsx index a9cf287..2033e67 100644 --- a/frontend/tests/unit/ComponentDetailPage.test.tsx +++ b/frontend/tests/unit/ComponentDetailPage.test.tsx @@ -3,12 +3,15 @@ import { beforeEach, describe, it, expect, vi } from "vitest"; import "@testing-library/jest-dom"; import { createRoutesStub } from "react-router"; import ComponentDetail, { clientLoader } from "~/routes/detail-component"; -import { getComponent } from "~/services/components-service"; +import { getComponent, deleteComponent, getComponents } from "~/services/components-service"; import { reqIsLogged } from "~/services/users-service"; import { useUserStore } from "~/stores/user-store"; +import userEvent from "@testing-library/user-event"; +import Components from "~/routes/components"; vi.mock("~/services/components-service", () => ({ getComponent: vi.fn(), + deleteComponent: vi.fn(), })); const component1 = { @@ -169,6 +172,118 @@ describe("ComponentDetailPage", () => { }); + it("deletes component", async () => { + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/components/:id", Component: ComponentDetail, loader: () => component1 }, + { path: "/components", + Component: Components, + loader: async ({ request }) => { + const url = new URL(request.url); + const page = Number(url.searchParams.get("page") ?? 0); + const result = await getComponents(page); + return { items: result.items, hasNext: result.hasNext }; + } + }, + ]); + + vi.mocked(deleteComponent).mockResolvedValue(); + vi.mocked(reqIsLogged).mockResolvedValue(adminAccount); + useUserStore.setState({ user: adminAccount }); + + render(); + + await waitFor(() => { + expect(document.getElementById("delete-component-button")).toBeInTheDocument(); + }); + await user.click(document.getElementById("delete-component-button")!); + + expect(await screen.findByText("¿Está seguro de que desea borrar este componente?")).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /Cancelar/ })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /Eliminar componente/ })).toBeInTheDocument(); + + await user.click(await screen.findByRole("button", { name: /Eliminar componente/ })); + + await waitFor(() => { + expect(deleteComponent).toHaveBeenCalledWith(component1.id); + expect(screen.queryByText(component1.name)).not.toBeInTheDocument(); + }); + + }); + + it("cancel component deletion", async () => { + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/components/:id", Component: ComponentDetail, loader: () => component1 }, + { path: "/components", + Component: Components, + loader: async ({ request }) => { + const url = new URL(request.url); + const page = Number(url.searchParams.get("page") ?? 0); + const result = await getComponents(page); + return { items: result.items, hasNext: result.hasNext }; + } + }, + ]); + + vi.mocked(deleteComponent).mockResolvedValue(); + vi.mocked(reqIsLogged).mockResolvedValue(adminAccount); + useUserStore.setState({ user: adminAccount }); + + render(); + + await waitFor(() => { + expect(document.getElementById("delete-component-button")).toBeInTheDocument(); + }); + await user.click(document.getElementById("delete-component-button")!); + + expect(await screen.findByText("¿Está seguro de que desea borrar este componente?")).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /Cancelar/ })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /Eliminar componente/ })).toBeInTheDocument(); + + await user.click(await screen.findByRole("button", { name: /Cancelar/ })); + + await waitFor(() => { + expect(deleteComponent).not.toHaveBeenCalled(); + expect(screen.queryByText(component1.name)).toBeInTheDocument(); + }); + + }); + + it("cancel component deletion with the close button", async () => { + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/components/:id", Component: ComponentDetail, loader: () => component1 }, + { path: "/components", + Component: Components, + loader: async ({ request }) => { + const url = new URL(request.url); + const page = Number(url.searchParams.get("page") ?? 0); + const result = await getComponents(page); + return { items: result.items, hasNext: result.hasNext }; + } + }, + ]); + + vi.mocked(deleteComponent).mockResolvedValue(); + vi.mocked(reqIsLogged).mockResolvedValue(adminAccount); + useUserStore.setState({ user: adminAccount }); + + render(); + + await waitFor(() => { + expect(document.getElementById("delete-component-button")).toBeInTheDocument(); + }); + await user.click(document.getElementById("delete-component-button")!); + + await user.click(await screen.findByRole("button", { name: "Close" })); + + await waitFor(() => { + expect(screen.queryByText("¿Está seguro de que desea borrar este componente?")).not.toBeInTheDocument(); + expect(deleteComponent).not.toHaveBeenCalled(); + }); + }); + it("clientLoader returns the component", async () => { vi.mocked(getComponent).mockResolvedValue(component1);