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 6006aa5..047e7d7 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 @@ -17,6 +17,7 @@ import org.springframework.http.ResponseEntity; import es.codeurjcstudents.pcmod.dto.ComponentDTO; +import es.codeurjcstudents.pcmod.model.Component; import es.codeurjcstudents.pcmod.service.ComponentsService; @RestController @@ -36,6 +37,14 @@ public List findTop3MoreRecent() { return componentService.findTop3MoreRecent().stream().map(componentService::toDTO).toList(); } + @GetMapping("/{id}") + public ResponseEntity getComponent (@PathVariable long id) { + + Component component = componentService.getComponent(id); + + return ResponseEntity.ok(componentService.toDTO(component)); + } + @GetMapping("/{id}/image") public ResponseEntity getComponentImage(@PathVariable long id) throws SQLException { 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 3a7c6d0..af13137 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 @@ -9,6 +9,8 @@ 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.web.server.ResponseStatusException; import org.springframework.stereotype.Service; import es.codeurjcstudents.pcmod.dto.ComponentDTO; @@ -22,11 +24,11 @@ public class ComponentsService { private final ComponentsRepository componentRepository; - @Autowired - private ComponentMapper componentMapper; + private final ComponentMapper componentMapper; - public ComponentsService(ComponentsRepository componentRepository) { + public ComponentsService(ComponentsRepository componentRepository, ComponentMapper componentMapper) { this.componentRepository = componentRepository; + this.componentMapper = componentMapper; } public Page findAll(Pageable pageable) { @@ -41,6 +43,11 @@ public List findTop3MoreRecent() { return componentRepository.findTop3ByOrderByIdDesc(); } + public Component getComponent(long id) { + return componentRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Componente no encontrado.")); + } + public Resource getImageFile(long id) throws SQLException { Optional imageOptional = componentRepository.findImageById(id); 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 b762b66..367a543 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 @@ -51,6 +51,28 @@ public void getComponents() { .body("content[0].stock", equalTo(5)); } + @Test + public void getComponent() { + given().header("Content-Type", "application/json") + .when().get("/components/1") + .then().statusCode(200).contentType(ContentType.JSON) + .body("id", notNullValue()) + .body("name", equalTo("AMD Ryzen 7 7800X3D")) + .body("type", equalTo("CPU")) + .body("brand", equalTo("AMD")) + .body("price", equalTo(350.0f)) + .body("stock", equalTo(5)); + } + + @Test + public void getComponentNotFound() { + given().header("Content-Type", "application/json") + .when().get("/components/12") + .then().statusCode(404).contentType(ContentType.JSON) + .body("error", equalTo("Not Found")) + .body("message", equalTo("Componente no encontrado.")); + } + @Test public void getComponentImage() throws IOException { byte[] expectedImage; 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 f9953e0..96e29cf 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 @@ -116,4 +116,120 @@ public void loadErrorTest() { assertThat(errorMessage).isEqualTo("Hubo un problema al cargar más componentes. Por favor, pruebe de nuevo."); } + + @Test + public void componentDetailRenderTest() { + + driver.get("http://localhost:5173/components/1"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-stock"))); + String componentStockMessage = driver.findElement(By.id("component-stock")).getText(); + assertThat(componentStockMessage).isEqualTo("¡Últimas unidades!"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-name"))); + String loadedComponentName = driver.findElement(By.id("component-name")).getText(); + assertThat(loadedComponentName).isEqualTo("AMD Ryzen 7 7800X3D"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-description"))); + String loadedComponentDescription = driver.findElement(By.id("component-description")).getText(); + assertThat(loadedComponentDescription).contains("8 núcleos y 16 hilos ideales para gaming"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-brand-type"))); + String loadedComponentBrandType = driver.findElement(By.id("component-brand-type")).getText(); + assertThat(loadedComponentBrandType).isEqualTo("AMD | CPU"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-price"))); + String loadedComponentPrice = driver.findElement(By.id("component-price")).getText(); + assertThat(loadedComponentPrice).isEqualTo("350.00 €"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-reviews-title"))); + String loadedComponentReviewsTitle = driver.findElement(By.id("component-reviews-title")).getText(); + assertThat(loadedComponentReviewsTitle).isEqualTo("Reseñas del producto:"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-average-rating"))); + String loadedComponentAverageRating = driver.findElement(By.id("component-average-rating")).getText(); + assertThat(loadedComponentAverageRating).isEqualTo("Puntuación media: N/A"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-no-reviews"))); + String loadedComponentNoReviews = driver.findElement(By.id("component-no-reviews")).getText(); + assertThat(loadedComponentNoReviews).isEqualTo("No hay reseñas de este producto todavía."); + + } + + @Test + public void componentDetailRenderNoLastUnitsTest() { + + driver.get("http://localhost:5173/components/3"); + + assertThat(driver.findElements(By.id("component-stock"))).isEmpty(); + + } + + @Test + public void componentDetailRenderLastUnitsTest() { + + driver.get("http://localhost:5173/components/1"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-stock"))); + String componentStockMessage = driver.findElement(By.id("component-stock")).getText(); + assertThat(componentStockMessage).isEqualTo("¡Últimas unidades!"); + + } + + @Test + public void componentDetailOptionsAsNonLoggedInTest() { + + driver.get("http://localhost:5173/components/1"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("add-to-cart-button"))); + + assertThat(driver.findElements(By.id("add-to-cart-button"))).isNotEmpty(); + assertThat(driver.findElements(By.id("edit-component-button"))).isEmpty(); + assertThat(driver.findElements(By.id("delete-component-button"))).isEmpty(); + + } + + @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")); + + wait.until(ExpectedConditions.urlToBe("http://localhost:5173/")); + + driver.get("http://localhost:5173/components/1"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("add-to-cart-button"))); + + assertThat(driver.findElements(By.id("add-to-cart-button"))).isNotEmpty(); + assertThat(driver.findElements(By.id("edit-component-button"))).isEmpty(); + assertThat(driver.findElements(By.id("delete-component-button"))).isEmpty(); + + } + + @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/")); + + driver.get("http://localhost:5173/components/1"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("add-to-cart-button"))); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("edit-component-button"))); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("delete-component-button"))); + + assertThat(driver.findElements(By.id("add-to-cart-button"))).isNotEmpty(); + assertThat(driver.findElements(By.id("edit-component-button"))).isNotEmpty(); + assertThat(driver.findElements(By.id("delete-component-button"))).isNotEmpty(); + + } } \ No newline at end of file diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/ComponentsIntegrationTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/ComponentsIntegrationTests.java index d7930d5..7ae011d 100644 --- a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/ComponentsIntegrationTests.java +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/ComponentsIntegrationTests.java @@ -1,7 +1,9 @@ package es.codeurjcstudents.pcmod.integration; import static org.junit.jupiter.api.Assertions.assertArrayEquals; +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 java.io.IOException; import java.math.BigDecimal; @@ -18,7 +20,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; - +import org.springframework.web.server.ResponseStatusException; import org.testcontainers.containers.MySQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; @@ -88,6 +90,27 @@ void loadComponents() { assertEquals("NVIDIA GeForce RTX 5060 WINDFORCE MAX OC", componentList.getContent().get(1).getName()); } + @Test + public void testGetComponent() { + Component component = componentsRepository.save(new Component("Intel i5 12400F", "Procesador de 6 núcleos y 12 hilos", ComponentType.CPU, "Intel", BigDecimal.valueOf(153.97), 14)); + + Component loadedComponent = componentsService.getComponent(component.getId()); + + assertDoesNotThrow(() -> componentsService.getComponent(component.getId())); + assertEquals(component.getId(), loadedComponent.getId()); + assertEquals(component.getName(), loadedComponent.getName()); + assertEquals(component.getDescription(), loadedComponent.getDescription()); + assertEquals(component.getType(), loadedComponent.getType()); + assertEquals(component.getBrand(), loadedComponent.getBrand()); + assertEquals(component.getPrice(), loadedComponent.getPrice()); + assertEquals(component.getStock(), loadedComponent.getStock()); + } + + @Test + public void testGetComponentNotFound() { + assertThrows(ResponseStatusException.class, () -> componentsService.getComponent(99L)); + } + @Test public void testGetComponentImage() throws IOException, SQLException { Component component = componentsRepository.findAll().get(0); 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 075f0f7..1ecf415 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 @@ -5,6 +5,7 @@ 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.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -14,6 +15,7 @@ import java.util.List; import java.util.Optional; import java.io.IOException; +import java.math.BigDecimal; import javax.sql.rowset.serial.SerialBlob; @@ -21,7 +23,10 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import org.springframework.web.server.ResponseStatusException; +import es.codeurjcstudents.pcmod.dto.ComponentMapper; +import es.codeurjcstudents.pcmod.enums.ComponentType; import es.codeurjcstudents.pcmod.model.Component; import es.codeurjcstudents.pcmod.model.Image; import es.codeurjcstudents.pcmod.repository.ComponentsRepository; @@ -33,7 +38,8 @@ public class ComponentsUnitTests { @Test public void testComponentsLoad() { ComponentsRepository componentsRepository = mock(ComponentsRepository.class); - ComponentsService componentsService = new ComponentsService(componentsRepository); + ComponentMapper componentMapper = mock(ComponentMapper.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper); Pageable pageable = mock(Pageable.class); List componentList = Arrays.asList(new Component(), new Component()); @@ -46,10 +52,39 @@ public void testComponentsLoad() { verify(componentsRepository).findAll(pageable); } + @Test + public void testGetComponent() { + ComponentsRepository componentsRepository = mock(ComponentsRepository.class); + ComponentMapper componentMapper = mock(ComponentMapper.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper); + + Component componentToReturn = 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(componentToReturn)); + + Component component = componentsService.getComponent(1L); + + assertEquals(componentToReturn, component); + verify(componentsRepository).findById(1L); + } + + @Test + public void testGetComponentNotFound() { + ComponentsRepository componentsRepository = mock(ComponentsRepository.class); + ComponentMapper componentMapper = mock(ComponentMapper.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper); + + when(componentsRepository.findById(12L)).thenReturn(Optional.empty()); + + assertThrows(ResponseStatusException.class, () -> componentsService.getComponent(12L)); + verify(componentsRepository).findById(12L); + } + @Test public void testGetComponentImage() throws SQLException, IOException { ComponentsRepository componentsRepository = mock(ComponentsRepository.class); - ComponentsService componentsService = new ComponentsService(componentsRepository); + ComponentMapper componentMapper = mock(ComponentMapper.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper); byte[] originalBytes = new byte[] { 1, 2, 3 }; Image existingImage = new Image(); @@ -65,7 +100,8 @@ public void testGetComponentImage() throws SQLException, IOException { @Test public void testGetNullComponentImage() throws SQLException { ComponentsRepository componentsRepository = mock(ComponentsRepository.class); - ComponentsService componentsService = new ComponentsService(componentsRepository); + ComponentMapper componentMapper = mock(ComponentMapper.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper); when(componentsRepository.findImageById(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 bd39071..ff4a339 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 @@ -11,6 +11,7 @@ import java.util.Arrays; import java.util.List; +import es.codeurjcstudents.pcmod.dto.ComponentMapper; import es.codeurjcstudents.pcmod.enums.ComponentType; import es.codeurjcstudents.pcmod.model.Component; import es.codeurjcstudents.pcmod.repository.ComponentsRepository; @@ -23,7 +24,8 @@ public class HomePageUnitTests { public void testRecentComponentsLoad() { ComponentsRepository componentsRepository = mock(ComponentsRepository.class); - ComponentsService componentsService = new ComponentsService(componentsRepository); + ComponentMapper componentMapper = mock(ComponentMapper.class); + ComponentsService componentsService = new ComponentsService(componentsRepository, componentMapper); 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 a824017..7230931 100644 --- a/frontend/app/app.css +++ b/frontend/app/app.css @@ -507,6 +507,177 @@ body { background-color: var(--color-brand-dark); } +/* Component detail */ + +.main:has(.component-data) { + padding-top: clamp(1.5rem, 4vw, 3rem); +} + +.main:has(.component-data) .container { + max-width: 1080px; +} + +.component-data, +.component-reviews { + background-color: var(--color-surface); + border: 1px solid var(--color-line); + border-radius: 12px; + box-shadow: 0 8px 22px rgb(84 37 20 / 8%); +} + +.component-data { + display: grid; + grid-template-columns: minmax(240px, 0.9fr) minmax(0, 1.1fr); + align-items: center; + gap: clamp(1.5rem, 4vw, 3.5rem); + margin-bottom: 1.5rem; + padding: clamp(1.25rem, 4vw, 2.5rem); +} + +.component-data>.component-image, +.component-data>.component-info { + width: auto; + max-width: none; + padding: 0; +} + +.component-image { + display: flex; + align-items: center; + justify-content: center; + min-height: 280px; + padding: 1rem; + background-color: #fffaf6; + border: 1px solid var(--color-line); + border-radius: 8px; +} + +.component-image img { + width: 100%; + max-height: 340px; + object-fit: contain; +} + +.component-image svg { + width: 4rem; + height: 4rem; + color: var(--color-muted); +} + +.component-info { + min-width: 0; +} + +.component-info h1 { + margin-bottom: 1rem; + color: var(--color-ink); + font-size: clamp(1.7rem, 4vw, 2.35rem); + font-weight: 700; + overflow-wrap: anywhere; +} + +.component-info h4 { + margin-bottom: 0.75rem; + color: var(--color-brand-dark); + font-size: 1rem; + font-weight: 700; +} + +.component-info h4:last-of-type { + margin-top: 1.25rem; + color: var(--color-ink); + font-size: 1.5rem; +} + +.component-description { + margin-bottom: 1.25rem; + color: var(--color-muted); + line-height: 1.6; + text-align: left; +} + +.component-stock { + display: inline-block; + margin-bottom: 0.75rem; + padding: 0.35rem 0.65rem; + color: var(--color-brand-dark); + background-color: #f9dcca; + border-left: 4px solid var(--color-brand); + border-radius: 4px; + font-size: 0.85rem; +} + +.component-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.component-actions .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.45rem; + height: 50px; + min-height: 50px; +} + +.component-actions .btn-secondary { + color: #ffffff; + background-color: var(--color-principal); + border-color: var(--color-line); +} + +.component-actions .btn-secondary:hover, +.component-actions .btn-secondary:focus-visible { + color: #ffffff; + background-color: var(--color-brand-dark); + border-color: var(--color-line); +} + +.component-reviews { + padding: clamp(1.25rem, 3vw, 2rem); +} + +.component-reviews h2 { + margin-bottom: 0.5rem; + color: var(--color-ink); + font-size: 1.35rem; +} + +.component-reviews>h3 { + margin-bottom: 1.25rem; + color: var(--color-muted); + font-size: 1.25rem; + font-weight: 400; +} + +.reviews-list { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.65rem; + padding-top: 1.25rem; + color: var(--color-muted); + border-top: 1px solid var(--color-line); + text-align: center; +} + +.reviews-list svg { + flex: 0 0 auto; + font-size: 1.3rem; +} + +.reviews-list h4 { + margin: 0; + color: var(--color-muted); + font-size: 0.95rem; + font-weight: 400; +} + /* Modal */ .modal-content { @@ -647,6 +818,10 @@ body { @media (max-width: 768px) { + .component-data { + grid-template-columns: 1fr; + } + .main section.row, .home-page section.row { flex-direction: column; diff --git a/frontend/app/routes.ts b/frontend/app/routes.ts index 388dd3b..ccb4fd4 100644 --- a/frontend/app/routes.ts +++ b/frontend/app/routes.ts @@ -7,5 +7,6 @@ export default [ route("/register", "routes/register.tsx"), route("/me", "routes/profile.tsx"), route("/components", "routes/components.tsx"), + route("/components/:id", "routes/detail-component.tsx"), ]), ] satisfies RouteConfig; diff --git a/frontend/app/routes/detail-component.tsx b/frontend/app/routes/detail-component.tsx new file mode 100644 index 0000000..7b064a2 --- /dev/null +++ b/frontend/app/routes/detail-component.tsx @@ -0,0 +1,76 @@ +import { Button, Col, Image } from "react-bootstrap"; +import { 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 { useUserStore } from "~/stores/user-store"; + +const API_URL = "/api/v1/components"; + +export async function clientLoader({params}: Route.ClientLoaderArgs): Promise { + return await getComponent(Number(params.id)); +} + +export default function ComponentDetail({ loaderData }: Route.ComponentProps) { + + const component = loaderData; + const price = component.price.toFixed(2); + let { user } = useUserStore(); + + return ( +
+
+
+ + {component.image ? ( + {component.name} + ) : ( + + )} + + + {component.stock <= 5 && ( +

¡Últimas unidades!

+ )} +

{component.name}

+

+ {component.description} +

+

+ {component.brand} | {component.type} +

+

{price} €

+
+ + {user?.roles?.includes("ADMIN") && ( + <> + + + + )} +
+ +
+
+

Reseñas del producto:

+

Puntuación media: N/A

+
+ +

No hay reseñas de este producto todavía.

+
+
+
+
+ ); + +} \ No newline at end of file diff --git a/frontend/app/services/components-service.ts b/frontend/app/services/components-service.ts index 4e5a6cd..8c279e7 100644 --- a/frontend/app/services/components-service.ts +++ b/frontend/app/services/components-service.ts @@ -55,4 +55,19 @@ export async function getRecentComponents(): Promise { } return []; +} + +export async function getComponent(id: number): Promise { + + const baseUrl = getBaseUrl(); + + const url = new URL(`${API_URL}/${id}`, baseUrl); + + const res = await fetch(url.toString()); + + if (!res.ok) { + throw new Error("Error fetching component"); + } + + return await res.json(); } \ No newline at end of file diff --git a/frontend/tests/integration/ComponentDetailPage.integration.test.tsx b/frontend/tests/integration/ComponentDetailPage.integration.test.tsx new file mode 100644 index 0000000..76c729e --- /dev/null +++ b/frontend/tests/integration/ComponentDetailPage.integration.test.tsx @@ -0,0 +1,129 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import fetchCookie from "fetch-cookie"; +import userEvent from "@testing-library/user-event"; +import { createRoutesStub, MemoryRouter, Navigate } from "react-router"; +import { beforeEach, afterEach, describe, it, expect, vi } from "vitest"; +import "@testing-library/jest-dom"; +import { CookieJar } from "tough-cookie"; +import ComponentDetail, { clientLoader } from "~/routes/detail-component"; +import Login from "~/routes/login"; + +describe("ComponentDetailPageIntegration", () => { + + beforeEach(() => { + vi.stubGlobal("fetch", fetchCookie(globalThis.fetch, new CookieJar())); + + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("renders the component detail page", async () => { + const componentData = await clientLoader({ params: { id: "1" } } as Parameters[0]); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(componentData.name)).toBeInTheDocument(); + expect(screen.getByRole("paragraph")).toHaveTextContent("8 núcleos y 16 hilos ideales para gaming"); + expect(screen.getByText(`${componentData.price.toFixed(2)} €`)).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: `${componentData.brand} | ${componentData.type}` })).toBeInTheDocument(); + expect(screen.getByText("Reseñas del producto:")).toBeInTheDocument(); + expect(screen.getByText("No hay reseñas de este producto todavía.")).toBeInTheDocument(); + }); + }); + + it("renders the last units warning when stock is low", async () => { + const componentData = await clientLoader({ params: { id: "1" } } as Parameters[0]); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("¡Últimas unidades!")).toBeInTheDocument(); + }); + }); + + it("does not render the last units warning when stock is not low", async () => { + const componentData = await clientLoader({ params: { id: "3" } } as Parameters[0]); + + render( + , + ); + + await waitFor(() => { + expect(screen.queryByText("¡Últimas unidades!")).not.toBeInTheDocument(); + }); + }); + + it("shows only the component add to cart button for non-registered users", async () => { + const componentData = await clientLoader({ params: { id: "1" } } as Parameters[0]); + + render( + + ); + + await waitFor(() => { + expect(document.getElementById("add-to-cart-button")).toBeInTheDocument(); + expect(document.getElementById("edit-component-button")).not.toBeInTheDocument(); + expect(document.getElementById("delete-component-button")).not.toBeInTheDocument(); + }); + }); + + it("shows only the component add to cart button for logged-in non-admin users", 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:"), "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("add-to-cart-button")).toBeInTheDocument(); + expect(document.getElementById("edit-component-button")).not.toBeInTheDocument(); + expect(document.getElementById("delete-component-button")).not.toBeInTheDocument(); + }); + }); + + it("shows the component edit and delete buttons for admin users", 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("add-to-cart-button")).toBeInTheDocument(); + expect(document.getElementById("edit-component-button")).toBeInTheDocument(); + expect(document.getElementById("delete-component-button")).toBeInTheDocument(); + }); + }); + +}); \ No newline at end of file diff --git a/frontend/tests/integration/Components.integration.test.tsx b/frontend/tests/integration/ComponentsPage.integration.test.tsx similarity index 100% rename from frontend/tests/integration/Components.integration.test.tsx rename to frontend/tests/integration/ComponentsPage.integration.test.tsx diff --git a/frontend/tests/unit/ComponentDetailPage.test.tsx b/frontend/tests/unit/ComponentDetailPage.test.tsx new file mode 100644 index 0000000..a9cf287 --- /dev/null +++ b/frontend/tests/unit/ComponentDetailPage.test.tsx @@ -0,0 +1,183 @@ +import { render, screen, waitFor } from "@testing-library/react"; +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 { reqIsLogged } from "~/services/users-service"; +import { useUserStore } from "~/stores/user-store"; + +vi.mock("~/services/components-service", () => ({ + getComponent: vi.fn(), +})); + +const component1 = { + id: 1, + name: "AMD Ryzen 7 7800X3D", + description: "Procesador gaming de alto rendimiento.", + type: "CPU", + brand: "AMD", + price: 350, + stock: 10, + image: null, +}; + +const component2 = { + id: 2, + name: "Intel Core i5-12400F", + description: "Procesador de alto rendimiento para gaming y productividad.", + type: "CPU", + brand: "Intel", + price: 150, + stock: 2, + image: null, +}; + +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("ComponentDetailPage", () => { + + beforeEach(() => { + vi.clearAllMocks(); + useUserStore.setState({ user: null }); + vi.mocked(reqIsLogged).mockResolvedValue(null as never); + }); + + it("renders the component detail page", async () => { + + const RouterStub = createRoutesStub([ + { path: "/components/:id", Component: ComponentDetail, loader: () => component1}, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText(component1.name)).toBeInTheDocument(); + expect(screen.getByText(component1.description)).toBeInTheDocument(); + expect(screen.getByText(component1.price.toFixed(2).toString() + " €")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /AMD\s*\|\s*CPU/ })).toBeInTheDocument(); + expect(screen.getByText("Reseñas del producto:")).toBeInTheDocument(); + expect(screen.getByText("No hay reseñas de este producto todavía.")).toBeInTheDocument(); + }); + + }); + + it("renders the last units warning when stock is low", async () => { + + vi.mocked(getComponent).mockResolvedValue(component2); + + const RouterStub = createRoutesStub([ + { path: "/components/:id", Component: ComponentDetail, loader: () => component2 }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText("¡Últimas unidades!")).toBeInTheDocument(); + }); + + }); + + it("does not render the last units warning when stock is not low", async () => { + vi.mocked(getComponent).mockResolvedValue(component1); + + const RouterStub = createRoutesStub([ + { path: "/components/:id", Component: ComponentDetail, loader: () => component1 }, + ]); + + render(); + + await waitFor(() => { + expect(screen.queryByText("¡Últimas unidades!")).not.toBeInTheDocument(); + }); + + }); + + it("shows only the component add to cart button for non-registered users", async () => { + + const RouterStub = createRoutesStub([ + { path: "/components/:id", Component: ComponentDetail, loader: () => component1}, + ]); + + render(); + + await waitFor(() => { + expect(document.getElementById("add-to-cart-button")).toBeInTheDocument(); + expect(document.getElementById("edit-component-button")).not.toBeInTheDocument(); + expect(document.getElementById("delete-component-button")).not.toBeInTheDocument(); + }); + + }); + + it("shows only the component add to cart button for logged-in non-admin users", async () => { + + const RouterStub = createRoutesStub([ + { path: "/components/:id", Component: ComponentDetail, loader: () => component1}, + ]); + + vi.mocked(reqIsLogged).mockResolvedValue(userAccount); + useUserStore.setState({ user: userAccount }); + + render(); + + await waitFor(() => { + expect(document.getElementById("add-to-cart-button")).toBeInTheDocument(); + expect(document.getElementById("edit-component-button")).not.toBeInTheDocument(); + expect(document.getElementById("delete-component-button")).not.toBeInTheDocument(); + }); + + }); + + it("shows the component edit and delete buttons for admin users", async () => { + + const RouterStub = createRoutesStub([ + { path: "/components/:id", Component: ComponentDetail, loader: () => component1}, + ]); + + vi.mocked(reqIsLogged).mockResolvedValue(adminAccount); + useUserStore.setState({ user: adminAccount }); + + render(); + + await waitFor(() => { + expect(document.getElementById("add-to-cart-button")).toBeInTheDocument(); + expect(document.getElementById("edit-component-button")).toBeInTheDocument(); + expect(document.getElementById("delete-component-button")).toBeInTheDocument(); + }); + + }); + + it("clientLoader returns the component", async () => { + + vi.mocked(getComponent).mockResolvedValue(component1); + + const result = await clientLoader({ params: { id: "1" } } as Parameters[0]); + + expect(getComponent).toHaveBeenCalledTimes(1); + expect(result).toEqual(component1); + + }); + +}); \ No newline at end of file