From 47872b25a960b33c188fcbaaf2fad8b7112b97ff Mon Sep 17 00:00:00 2001 From: jaimeeg5 Date: Mon, 5 Oct 2026 13:45:59 +0200 Subject: [PATCH 1/5] feat: add backend user deletion and tests --- .../pcmod/controller/UsersRestController.java | 8 ++ .../pcmod/security/SecurityConfig.java | 1 + .../pcmod/service/UsersService.java | 13 ++ .../pcmod/e2e/api/UsersSystemTests.java | 117 ++++++++++++++++++ .../integration/UsersIntegrationTests.java | 54 ++++++++ .../pcmod/unit/UsersUnitTests.java | 32 +++++ 6 files changed, 225 insertions(+) diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/UsersRestController.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/UsersRestController.java index fc551cc..647876d 100644 --- a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/UsersRestController.java +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/UsersRestController.java @@ -13,6 +13,7 @@ import org.springframework.http.MediaType; import org.springframework.http.MediaTypeFactory; import org.springframework.http.ResponseEntity; +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; @@ -80,4 +81,11 @@ public ResponseEntity addUserImage(@PathVariable long id, @RequestPara return ResponseEntity.created(location).body(new ImageDTO(image.getId())); } + @DeleteMapping("/{id}") + public void deleteUser(@PathVariable long id) { + + usersService.deleteUser(id); + + } + } \ 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 323dc8f..53ac402 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 @@ -60,6 +60,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(authorize -> authorize .requestMatchers(HttpMethod.GET, "/api/v1/users/**").hasRole("REGISTERED_USER") + .requestMatchers(HttpMethod.DELETE, "/api/v1/users/**").hasRole("REGISTERED_USER") .anyRequest().permitAll()); http.formLogin(formLogin -> formLogin.disable()); diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/UsersService.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/UsersService.java index d106981..18c3fc8 100644 --- a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/UsersService.java +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/UsersService.java @@ -124,6 +124,19 @@ public Image addUserImage(long id, MultipartFile imageFile) throws IOException { return userRepository.save(user).getImage(); } + @PreAuthorize("hasRole('ADMIN') or principal.username == #id.toString()") + public void deleteUser(long id) { + + Optional user = userRepository.findById(id); + + if (user.isPresent()) { + + userRepository.deleteById(id); + + } + + } + public UserDTO toDTO(User user) { return userMapper.toDTO(user); } diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/UsersSystemTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/UsersSystemTests.java index ee48e58..718d801 100644 --- a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/UsersSystemTests.java +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/UsersSystemTests.java @@ -478,4 +478,121 @@ public void postImageWithInvalidFile() { .body("message", equalTo("El tipo de archivo no es válido. Solo se permiten imágenes JPEG, PNG y WebP.")); } + @Test + public void deleteUserAsNotLoggedIn() { + + given() + .when().delete("users/1") + .then().statusCode(401).contentType(ContentType.JSON) + .body("status", equalTo(401)) + .body("error", equalTo("Unauthorized")); + } + + @Test + public void deleteOwnUser() { + 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("users/1") + .then().statusCode(200); + + given() + .header("Content-Type", "application/json") + .body(""" + { + "username": "user@example.com", + "password": "userpass" + } + """) + .when().post("auth/login") + .then().statusCode(401).contentType(ContentType.JSON) + .body("error", equalTo("Unauthorized")); + } + + @Test + public void deleteOtherUserAsNoAdmin() { + 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("users/2") + .then().statusCode(403).contentType(ContentType.JSON) + .body("error", equalTo("Forbidden")); + } + + @Test + public void deleteUsersAsAdmin() { + 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("users/1") + .then().statusCode(200); + + given() + .header("Content-Type", "application/json") + .cookie("AuthToken", authToken) + .when().delete("users/2") + .then().statusCode(200); + + given() + .header("Content-Type", "application/json") + .body(""" + { + "username": "user@example.com", + "password": "userpass" + } + """) + .when().post("auth/login") + .then().statusCode(401).contentType(ContentType.JSON) + .body("error", equalTo("Unauthorized")); + + given() + .header("Content-Type", "application/json") + .body(""" + { + "username": "admin@example.com", + "password": "adminpass" + } + """) + .when().post("auth/login") + .then().statusCode(401).contentType(ContentType.JSON) + .body("error", equalTo("Unauthorized")); + + } + } diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/UsersIntegrationTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/UsersIntegrationTests.java index f54e507..3dd78d9 100644 --- a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/UsersIntegrationTests.java +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/UsersIntegrationTests.java @@ -403,4 +403,58 @@ void postImageWithInvalidFile() { } + @Test + void deleteUserAsNotLoggedIn() { + + assertThrows(AuthenticationCredentialsNotFoundException.class, () -> usersService.deleteUser(1L)); + + } + + @Test + void deleteOwnUser() { + 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, List.of(new SimpleGrantedAuthority("ROLE_REGISTERED_USER")))); + + assertDoesNotThrow(() -> usersService.deleteUser(user.getId())); + assertEquals(1, usersRepository.count()); + + } + + @Test + void deleteOtherUserAsLoggedIn() { + 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, List.of(new SimpleGrantedAuthority("ROLE_REGISTERED_USER")))); + + assertThrows(AuthorizationDeniedException.class, () -> usersService.deleteUser(2L)); + assertEquals(2, usersRepository.count()); + + } + + @Test + void deleteUsersAsAdmin() { + User admin = usersRepository.findByEmail("testadmin1@example.com").orElseThrow(); + User user = usersRepository.findByEmail("testuser1@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")))); + + assertDoesNotThrow(() -> usersService.deleteUser(user.getId())); + assertDoesNotThrow(() -> usersService.deleteUser(admin.getId())); + assertEquals(0, usersRepository.count()); + + } + } diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/UsersUnitTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/UsersUnitTests.java index 8550b98..8dec84f 100644 --- a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/UsersUnitTests.java +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/UsersUnitTests.java @@ -337,4 +337,36 @@ public void testAddImageWithInvalidFile() throws IOException { assertThrows(IllegalArgumentException.class, () -> usersService.addUserImage(1L, invalidTypeImage)); } + + @Test + public void testDeleteUser() { + + UsersRepository usersRepository = mock(UsersRepository.class); + UsersService usersService = new UsersService(usersRepository, null, null, null); + + User user = new User("user", "test", "userTest", "c/testaddress", "testuser@example.com", + passwordEncoder.encode("pass"), "REGISTERED_USER"); + + when(usersRepository.findById(1L)).thenReturn(java.util.Optional.of(user)); + + usersService.deleteUser(1L); + + verify(usersRepository).deleteById(1L); + + } + + @Test + public void testDeleteUserNotFound() { + + UsersRepository usersRepository = mock(UsersRepository.class); + UsersService usersService = new UsersService(usersRepository, null, null, null); + + when(usersRepository.findById(1L)).thenReturn(java.util.Optional.empty()); + + usersService.deleteUser(1L); + + verify(usersRepository, never()).deleteById(1L); + + } + } From 263ee3965fc116cbc2124e1d3f42277d4a162789 Mon Sep 17 00:00:00 2001 From: jaimeeg5 Date: Wed, 7 Oct 2026 21:00:56 +0200 Subject: [PATCH 2/5] feat: add frontend user deletion and tests --- .../pcmod/e2e/ui/UsersSystemTests.java | 107 ++++++++++++- frontend/app/app.css | 111 ++++++++++++++ frontend/app/routes/login.tsx | 2 +- frontend/app/routes/profile.tsx | 143 ++++++++++++------ frontend/app/services/users-service.ts | 13 ++ frontend/app/stores/user-store.tsx | 2 +- .../integration/Login.integration.test.tsx | 2 +- .../integration/Profile.integration.test.tsx | 86 +++++++++++ frontend/tests/unit/LoginPage.test.tsx | 2 +- frontend/tests/unit/ProfilePage.test.tsx | 91 ++++++++++- 10 files changed, 508 insertions(+), 51 deletions(-) diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/UsersSystemTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/UsersSystemTests.java index fef7880..d4cbff3 100644 --- a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/UsersSystemTests.java +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/UsersSystemTests.java @@ -182,7 +182,7 @@ public void loginWrongCredentialsTest(String testName, String email, String pass wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("error-message"))); assertThat(driver.findElement(By.id("error-message")).getText()) - .isEqualTo("Error al iniciar sesión. Por favor, inténtenlo de nuevo"); + .isEqualTo("Error al iniciar sesión. Por favor, inténtelo de nuevo"); assertThat(driver.findElement(By.id("invalid-email")).isDisplayed()).isFalse(); assertThat(driver.findElement(By.id("invalid-password")).isDisplayed()).isFalse(); @@ -436,4 +436,109 @@ public void loadProfilePageTest() { assertThat(loadedPurchasesTitle).isEqualTo("Mis compras:"); } + + @Test + public void cancelAccountDeletionTest() { + + 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")); + wait.until(ExpectedConditions.urlToBe("http://localhost:5173/")); + + driver.get("http://localhost:5173/me"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.name("delete-button"))); + scrollToAndClick(By.name("delete-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 su cuenta?"); + assertThat(deleteModal.findElement(By.className("modal-body")).getText()) + .contains("Todos sus datos se perderán y no podrá recuperarlos.") + .contains("Esta acción no se puede deshacer."); + + scrollToAndClick(By.cssSelector(".modal-footer .btn-secondary")); + + wait.until(ExpectedConditions.urlToBe("http://localhost:5173/me")); + + } + + @Test + public void cancelAccountDeletionClosingModalTest() { + + 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")); + wait.until(ExpectedConditions.urlToBe("http://localhost:5173/")); + + driver.get("http://localhost:5173/me"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.name("delete-button"))); + scrollToAndClick(By.name("delete-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 su cuenta?"); + assertThat(deleteModal.findElement(By.className("modal-body")).getText()) + .contains("Todos sus datos se perderán y no podrá recuperarlos.") + .contains("Esta acción no se puede deshacer."); + scrollToAndClick(By.cssSelector(".modal-header .btn-close")); + + wait.until(ExpectedConditions.urlToBe("http://localhost:5173/me")); + + } + + @Test + public void deleteAccountTest() { + + driver.get("http://localhost:5173/register"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("name"))); + driver.findElement(By.id("name")).sendKeys("Delete"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("surname"))); + driver.findElement(By.id("surname")).sendKeys("Test"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("username"))); + driver.findElement(By.id("username")).sendKeys("delete_test"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("address"))); + driver.findElement(By.id("address")).sendKeys("c/delete_test"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("email"))); + driver.findElement(By.id("email")).sendKeys("delete_test@example.com"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("password"))); + driver.findElement(By.id("password")).sendKeys("Delete_Test1"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.name("register-button"))); + scrollToAndClick(By.name("register-button")); + wait.until(ExpectedConditions.urlToBe("http://localhost:5173/")); + + driver.get("http://localhost:5173/me"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.name("delete-button"))); + scrollToAndClick(By.name("delete-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 su cuenta?"); + assertThat(deleteModal.findElement(By.className("modal-body")).getText()) + .contains("Todos sus datos se perderán y no podrá recuperarlos.") + .contains("Esta acción no se puede deshacer."); + scrollToAndClick(By.cssSelector(".modal-footer .pcmod-btn-danger")); + + wait.until(ExpectedConditions.urlToBe("http://localhost:5173/")); + + 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("delete_test@example.com"); + driver.findElement(By.id("password")).sendKeys("Delete_Test1"); + scrollToAndClick(By.name("login-button")); + + WebElement errorMessage = wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("error-message"))); + assertThat(errorMessage.getText()).isEqualTo("Error al iniciar sesión. Por favor, inténtelo de nuevo"); + + } } \ No newline at end of file diff --git a/frontend/app/app.css b/frontend/app/app.css index ee1dbc7..85b15ce 100644 --- a/frontend/app/app.css +++ b/frontend/app/app.css @@ -449,6 +449,117 @@ body { overflow-wrap: anywhere; } +.profile-actions { + display: grid; + gap: 0.75rem; + margin-top: 0.75rem; + padding-top: 1rem; + border-top: 1px solid var(--color-line); +} + +.profile-actions .pcmod-button, +.profile-actions .pcmod-danger-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: 100%; + padding: 0.6rem 0.8rem !important; + font-size: 0.9rem !important; +} + +.pcmod-danger-button { + color: var(--color-page); + background-color: #ff0000dc; + border: 1px solid transparent; + font-family: var(--font-sans); +} + +.pcmod-danger-button:hover, +.pcmod-danger-button:focus-visible { + background-color: var(--color-brand-dark); +} + +/* Modal */ + +.modal-content { + overflow: hidden; + color: var(--color-ink); + 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 / 18%); + font-family: var(--font-sans); +} + +.modal-header { + align-items: flex-start; + gap: 1rem; + padding: 1.25rem 1.5rem; +} + +.modal-title { + color: var(--color-ink); + font-size: 1.25rem; + font-weight: 700; +} + +.modal-header .btn-close { + margin: 0; + padding: 0.35rem; +} + +.modal-body { + padding: 1.25rem 1.25rem; +} + +.modal-body p { + margin: 0; + color: var(--color-muted); + line-height: 1.5; + text-align: left; +} + +.modal-footer { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.5rem 1.25rem; +} + +.modal-footer .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 42px; + padding: 0.6rem 0.85rem; + border-radius: 8px; + font-family: var(--font-sans); + font-size: 0.9rem; + font-weight: 700; + transition: background-color 160ms ease, border-color 160ms ease, transform 160ms ease; +} + +.modal-footer .btn:hover, +.modal-footer .btn:focus-visible { + transform: translateY(-1px); +} + +.modal-footer .btn-pcmod-btn-danger { + color: #ffffff; + background-color: #ff0000dc; + border-color: transparent; +} + +.modal-footer .btn-pcmod-btn-danger:hover, +.modal-footer .btn-pcmod-btn-danger:focus-visible { + color: #ffffff; + background-color: var(--color-brand-dark); + border-color: transparent; +} + .purchases-data { min-height: 360px; overflow: hidden; diff --git a/frontend/app/routes/login.tsx b/frontend/app/routes/login.tsx index 04dc0d3..6a092c4 100644 --- a/frontend/app/routes/login.tsx +++ b/frontend/app/routes/login.tsx @@ -40,7 +40,7 @@ export default function Login() { } catch (error) { console.log(error); - setErrorMessage("Hubo un error al iniciar sesión. Por favor, inténtalo de nuevo."); + setErrorMessage("Hubo un error al iniciar sesión. Por favor, inténtelo de nuevo."); } diff --git a/frontend/app/routes/profile.tsx b/frontend/app/routes/profile.tsx index 6e545e4..6a89673 100644 --- a/frontend/app/routes/profile.tsx +++ b/frontend/app/routes/profile.tsx @@ -1,9 +1,11 @@ import { useState } from "react"; import type { Route } from "./+types/profile"; -import { getUser } from "~/services/users-service"; +import { deleteUser, getUser } from "~/services/users-service"; import type UserDTO from "~/dtos/UserDTO"; -import { CartX, PencilSquare, Trash, Cart } from 'react-bootstrap-icons'; -import { Button, Image } from "react-bootstrap"; +import { CartX, Trash, Cart, ArrowLeft } from 'react-bootstrap-icons'; +import { Button, Image, Modal } from "react-bootstrap"; +import { useNavigate } from "react-router"; +import { useUserStore } from "~/stores/user-store"; const API_URL = "/api/v1/users" @@ -13,56 +15,107 @@ export async function clientLoader() { export default function Profile({ loaderData }: Route.ClientLoaderArgs) { + let { logoutUser } = useUserStore(); + const [profile] = useState(loaderData); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const navigate = useNavigate(); + + async function handleDeleteAccount() { + + try { + + await deleteUser(profile.id); + await logoutUser(); + await navigate("/"); + + } catch (err) { + + console.error(err); + + } + } return ( -
-
-
- UserImage -
-
-
Nombre:
-
{profile.name}
-
-
-
Apellidos:
-
{profile.surname}
-
-
-
Nombre de usuario:
-
{profile.username}
-
-
-
Dirección:
-
{profile.address}
+ <> +
+
+
+ UserImage + +
+
+
Nombre:
+
{profile.name}
+
+
+
Apellidos:
+
{profile.surname}
+
+
+
Nombre de usuario:
+
{profile.username}
+
+
+
Dirección:
+
{profile.address}
+
+
+
Correo electrónico:
+
{profile.email}
+
+
+ +
+
-
-
Correo electrónico:
-
{profile.email}
+
+ +
+
+
+
-
-
-
-
-
-
-
-
-
-
+ + + setIsDeleteModalOpen(false)}> + + ¿Está seguro de que desea borrar su cuenta? + + +

+ Todos sus datos se perderán y no podrá recuperarlos. Esta acción no se puede deshacer. +

+
+ + + + +
+ ); } \ No newline at end of file diff --git a/frontend/app/services/users-service.ts b/frontend/app/services/users-service.ts index 33bd80a..ae2f49d 100644 --- a/frontend/app/services/users-service.ts +++ b/frontend/app/services/users-service.ts @@ -124,4 +124,17 @@ export async function addImage(id: number, image: File): Promise { return await res.json(); +} + +export async function deleteUser(id: number): Promise { + + const url = new URL(`${API_USERS_URL}/${id}`, getBaseUrl()); + const response = await fetch(url.toString(), { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error("Hubo un error al borrar la cuenta."); + } + } \ No newline at end of file diff --git a/frontend/app/stores/user-store.tsx b/frontend/app/stores/user-store.tsx index 9e41bb6..86f5112 100644 --- a/frontend/app/stores/user-store.tsx +++ b/frontend/app/stores/user-store.tsx @@ -50,7 +50,7 @@ export const useUserStore = create((set, get) => ({ } catch (error) { console.log(error); - const message = "Error al iniciar sesión. Por favor, inténtenlo de nuevo"; + const message = "Error al iniciar sesión. Por favor, inténtelo de nuevo"; set({ loginError: message }); } diff --git a/frontend/tests/integration/Login.integration.test.tsx b/frontend/tests/integration/Login.integration.test.tsx index 5eac9d6..8f540ed 100644 --- a/frontend/tests/integration/Login.integration.test.tsx +++ b/frontend/tests/integration/Login.integration.test.tsx @@ -96,7 +96,7 @@ describe("LoginIntegration", () => { await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); await waitFor(() => { - expect(screen.getByText("Error al iniciar sesión. Por favor, inténtenlo de nuevo")).toBeInTheDocument(); + expect(screen.getByText("Error al iniciar sesión. Por favor, inténtelo de nuevo")).toBeInTheDocument(); }); }); diff --git a/frontend/tests/integration/Profile.integration.test.tsx b/frontend/tests/integration/Profile.integration.test.tsx index 572cf79..cb4c75d 100644 --- a/frontend/tests/integration/Profile.integration.test.tsx +++ b/frontend/tests/integration/Profile.integration.test.tsx @@ -7,6 +7,13 @@ import "@testing-library/jest-dom"; import { CookieJar } from "tough-cookie"; import Login from "~/routes/login"; import Profile, { clientLoader } from "~/routes/profile"; +import { useUserStore } from "~/stores/user-store"; + +function RootRoute() { + const user = useUserStore((state) => state.user); + + return user ? :

HomePage

; +} describe("ProfileIntegration", () => { @@ -38,6 +45,85 @@ describe("ProfileIntegration", () => { expect(screen.getByText("user_example")).toBeInTheDocument(); expect(screen.getByText("c/example_address 1")).toBeInTheDocument(); expect(screen.getByText("user@example.com")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Mis compras:" })).toBeInTheDocument(); + expect(screen.getByText("No se han realizado compras todavía.")).toBeInTheDocument(); + }); + }); + + it("cancels deleting the user account", async () => { + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + { path: "/", Component: () => }, + { path: "/me", Component: Profile, loader: clientLoader }, + ]); + + 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 user.click(await screen.findByRole("button", { name: "Borrar cuenta" })); + await user.click(await screen.findByRole("button", { name: /Cancelar/ })); + + await waitFor(() => { + expect(screen.queryByText("¿Está seguro de que desea borrar su cuenta?")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Borrar cuenta" })).toBeInTheDocument(); + expect(screen.getByText("user@example.com")).toBeInTheDocument(); + }); + }); + + it("cancels deleting the user account closing the modal", async () => { + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + { path: "/", Component: () => }, + { path: "/me", Component: Profile, loader: clientLoader }, + ]); + + 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 user.click(await screen.findByRole("button", { name: "Borrar cuenta" })); + await user.click(await screen.findByRole("button", { name: "Close" })); + + await waitFor(() => { + expect(screen.queryByText("¿Está seguro de que desea borrar su cuenta?")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Borrar cuenta" })).toBeInTheDocument(); + expect(screen.getByText("user@example.com")).toBeInTheDocument(); + }); + }); + + it("deletes user account", async () => { + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + { path: "/", Component: RootRoute }, + { path: "/me", Component: Profile, loader: clientLoader }, + ]); + + 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 user.click(await screen.findByRole("button", { name: "Borrar cuenta" })); + + expect(await screen.findByText( + "¿Está seguro de que desea borrar su cuenta?", + )).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /Cancelar/ })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /Eliminar cuenta/ })).toBeInTheDocument(); + + await user.click(await screen.findByRole("button", { name: /Eliminar cuenta/ })); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "HomePage" })).toBeInTheDocument(); }); }); diff --git a/frontend/tests/unit/LoginPage.test.tsx b/frontend/tests/unit/LoginPage.test.tsx index edddb39..1789386 100644 --- a/frontend/tests/unit/LoginPage.test.tsx +++ b/frontend/tests/unit/LoginPage.test.tsx @@ -128,7 +128,7 @@ describe("LoginPage", () => { await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); await waitFor(() => { - expect(screen.getByText("Error al iniciar sesión. Por favor, inténtenlo de nuevo")).toBeInTheDocument(); + expect(screen.getByText("Error al iniciar sesión. Por favor, inténtelo de nuevo")).toBeInTheDocument(); }); expect(logIn).toHaveBeenCalledWith("wrongUser@example.com", "wrongPass"); diff --git a/frontend/tests/unit/ProfilePage.test.tsx b/frontend/tests/unit/ProfilePage.test.tsx index fb9a22a..aa6fcd6 100644 --- a/frontend/tests/unit/ProfilePage.test.tsx +++ b/frontend/tests/unit/ProfilePage.test.tsx @@ -3,7 +3,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import "@testing-library/jest-dom"; import Profile, { clientLoader } from "~/routes/profile"; import { createRoutesStub } from "react-router"; -import { getUser } from "~/services/users-service"; +import userEvent from "@testing-library/user-event"; +import { deleteUser, getUser, logOut, reqIsLogged } from "~/services/users-service"; const userAccount = { id: 1, @@ -18,7 +19,9 @@ const userAccount = { vi.mock("~/services/users-service", () => ({ getUser: vi.fn(), logIn: vi.fn(), + logOut: vi.fn(), reqIsLogged: vi.fn(), + deleteUser: vi.fn(), })); describe("ProfilePage", () => { @@ -41,6 +44,7 @@ describe("ProfilePage", () => { expect(screen.getByText("Nombre de usuario:")).toBeInTheDocument(); expect(screen.getByText("Dirección:")).toBeInTheDocument(); expect(screen.getByText("Correo electrónico:")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Borrar cuenta" })).toBeInTheDocument(); expect(screen.getByRole("img", { name: "UserImage" })).toBeInTheDocument(); expect(screen.getByRole("heading", { level: 2, name: "Mis compras:" })).toBeInTheDocument(); }); @@ -56,4 +60,89 @@ describe("ProfilePage", () => { expect(result).toEqual(userAccount); }); + it("deletes user account", async () => { + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/me", Component: Profile, loader: () => userAccount }, + { path: "/", Component: () =>

HomePage

} + ]); + + vi.mocked(reqIsLogged).mockResolvedValue(userAccount); + vi.mocked(deleteUser).mockResolvedValue(); + vi.mocked(logOut).mockResolvedValue(); + + render(); + + await user.click(await screen.findByRole("button", { name: "Borrar cuenta" })); + + expect(await screen.findByText( + "¿Está seguro de que desea borrar su cuenta?", + )).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /Cancelar/ })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /Eliminar cuenta/ })).toBeInTheDocument(); + + await user.click(await screen.findByRole("button", { name: /Eliminar cuenta/ })); + + await waitFor(() => { + expect(deleteUser).toHaveBeenCalledWith(userAccount.id); + expect(logOut).toHaveBeenCalledTimes(1); + expect(screen.getByRole("heading", { name: "HomePage" })).toBeInTheDocument(); + }); + + }); + + it("cancel user account deletion", async () => { + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/me", Component: Profile, loader: () => userAccount } + ]); + + vi.mocked(reqIsLogged).mockResolvedValue(userAccount); + + render(); + + await user.click(await screen.findByRole("button", { name: "Borrar cuenta" })); + + expect(await screen.findByText( + "¿Está seguro de que desea borrar su cuenta?", + )).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /Cancelar/ })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /Eliminar cuenta/ })).toBeInTheDocument(); + + await user.click(await screen.findByRole("button", { name: /Cancelar/ })); + + await waitFor(() => { + expect(deleteUser).not.toHaveBeenCalled(); + expect(logOut).not.toHaveBeenCalled(); + }); + + }); + + it("closes user account deletion modal with the close button", async () => { + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/me", Component: Profile, loader: () => userAccount } + ]); + + vi.mocked(reqIsLogged).mockResolvedValue(userAccount); + + render(); + + await user.click(await screen.findByRole("button", { name: "Borrar cuenta" })); + + expect(await screen.findByText( + "¿Está seguro de que desea borrar su cuenta?", + )).toBeInTheDocument(); + + await user.click(await screen.findByRole("button", { name: "Close" })); + + await waitFor(() => { + expect(screen.queryByText( + "¿Está seguro de que desea borrar su cuenta?", + )).not.toBeInTheDocument(); + expect(deleteUser).not.toHaveBeenCalled(); + expect(logOut).not.toHaveBeenCalled(); + }); + }); + }); \ No newline at end of file From 5da073d052958040227084316d2097bc3ea25930 Mon Sep 17 00:00:00 2001 From: jaimeeg5 Date: Thu, 8 Oct 2026 12:31:44 +0200 Subject: [PATCH 3/5] fix: wait for page rendering in frontend test --- frontend/tests/unit/Header.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/tests/unit/Header.test.tsx b/frontend/tests/unit/Header.test.tsx index 72db684..5a967db 100644 --- a/frontend/tests/unit/Header.test.tsx +++ b/frontend/tests/unit/Header.test.tsx @@ -109,7 +109,8 @@ describe("Header", () => { await user.click(await screen.findByRole("button", { name: "User" })); await user.click(screen.getByRole("link", { name: /Mi perfil/i })); - expect(screen.getByRole("heading", { name: "Mis compras:" })).toBeInTheDocument(); + const purchasesTitle = await screen.findByRole("heading", { name: "Mis compras:" }); + expect(purchasesTitle).toBeInTheDocument(); }); From 8ee2c973ac1693f6099337263eb45ee51066f0ee Mon Sep 17 00:00:00 2001 From: jaimeeg5 Date: Thu, 8 Oct 2026 13:08:47 +0200 Subject: [PATCH 4/5] fix: sonar reliability issues --- frontend/app/routes/login.tsx | 2 +- frontend/app/routes/register.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/app/routes/login.tsx b/frontend/app/routes/login.tsx index 6a092c4..d6a9509 100644 --- a/frontend/app/routes/login.tsx +++ b/frontend/app/routes/login.tsx @@ -34,7 +34,7 @@ export default function Login() { if (error) { setErrorMessage(error) } else { - navigate(`/`); + await navigate(`/`); } } catch (error) { diff --git a/frontend/app/routes/register.tsx b/frontend/app/routes/register.tsx index c0d0953..613dac3 100644 --- a/frontend/app/routes/register.tsx +++ b/frontend/app/routes/register.tsx @@ -53,7 +53,7 @@ export default function Register() { await addImage(user.id, image); } - navigate("/"); + await navigate("/"); } catch (error) { From 09d4c9e99920e212deb0118f535b33717f4774e8 Mon Sep 17 00:00:00 2001 From: jaimeeg5 Date: Thu, 8 Oct 2026 20:49:21 +0200 Subject: [PATCH 5/5] fix: prevent user deletion test from affecting other tests --- .../tests/integration/Profile.integration.test.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/tests/integration/Profile.integration.test.tsx b/frontend/tests/integration/Profile.integration.test.tsx index cb4c75d..47d4021 100644 --- a/frontend/tests/integration/Profile.integration.test.tsx +++ b/frontend/tests/integration/Profile.integration.test.tsx @@ -7,6 +7,7 @@ import "@testing-library/jest-dom"; import { CookieJar } from "tough-cookie"; import Login from "~/routes/login"; import Profile, { clientLoader } from "~/routes/profile"; +import { register } from "~/services/users-service"; import { useUserStore } from "~/stores/user-store"; function RootRoute() { @@ -106,10 +107,19 @@ describe("ProfileIntegration", () => { { path: "/me", Component: Profile, loader: clientLoader }, ]); + await register( + "delete", + "account", + "delete_account", + "c/delete_account_address 1", + "delete.account@example.com", + "delete_Pass1", + ); + render(); - await user.type(screen.getByLabelText("Correo electrónico:"), "user@example.com"); - await user.type(screen.getByLabelText("Contraseña:"), "userpass"); + await user.type(screen.getByLabelText("Correo electrónico:"), "delete.account@example.com"); + await user.type(screen.getByLabelText("Contraseña:"), "delete_Pass1"); await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); await user.click(await screen.findByRole("button", { name: "Borrar cuenta" }));