From 1fc68a202f97e7e3e3214489cf7cfe0e2c4a2f2a Mon Sep 17 00:00:00 2001 From: Angel Date: Fri, 20 Jun 2025 20:57:30 -0500 Subject: [PATCH 01/21] feat(e2e/order): add test for guest checkout flow and reusable order form helpers --- src/e2e/guest-create-order.spec.ts | 36 ++++++++++++++++++++++++++++++ src/e2e/utils-tests-e2e.ts | 18 +++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 src/e2e/guest-create-order.spec.ts create mode 100644 src/e2e/utils-tests-e2e.ts diff --git a/src/e2e/guest-create-order.spec.ts b/src/e2e/guest-create-order.spec.ts new file mode 100644 index 0000000..ad57978 --- /dev/null +++ b/src/e2e/guest-create-order.spec.ts @@ -0,0 +1,36 @@ +// import { createOrderFormData } from "@/lib/utils.tests"; +import { expect, test } from "@playwright/test"; +import { createOrderFormData } from "./utils-tests-e2e"; + +export type OrderFormData = Record; + +test.describe("Guest", () => { + test("Guest can create an order", async ({ page }) => { + // Navegar a la tienda y agregar un producto + await page.goto("http://localhost:5173/"); + + await page.getByRole("menuitem", { name: "Polos" }).click(); + await page.getByTestId("product-item").first().click(); + + await page.getByRole("button", { name: "Agregar al Carrito" }).click(); + await page.getByRole("link", { name: "Carrito de compras" }).click(); + + await page.getByRole("link", { name: "Continuar Compra" }).click(); + + // Llenar correctamente los campos + const orderForm = createOrderFormData(); + for (const [key, value] of Object.entries(orderForm)) { + const input = await page.getByRole("textbox", { name: key }); + await input.click(); + await input.fill(value); + } + await page.getByRole("combobox", { name: "País" }).selectOption("PE"); + + await page.getByRole("button", { name: "Confirmar Orden" }).click(); + + await expect( + page.getByText("¡Muchas gracias por tu compra!") + ).toBeVisible(); + await expect(page.getByTestId("orderId")).toBeVisible(); + }); +}); diff --git a/src/e2e/utils-tests-e2e.ts b/src/e2e/utils-tests-e2e.ts new file mode 100644 index 0000000..6bca144 --- /dev/null +++ b/src/e2e/utils-tests-e2e.ts @@ -0,0 +1,18 @@ +/* Helper functions → Playwright */ + +export type OrderFormData = Record; + +export const createOrderFormData = ( + overrides?: Partial +): OrderFormData => ({ + "Correo electrónico": "testinodp@codeable.com", + Nombre: "Testino", + Apellido: "Diprueba", + Compañia: "", + Dirección: "Calle Di Prueba 123", + Ciudad: "Lima", + "Provincia/Estado": "Lima", + "Código Postal": "51111", + Teléfono: "987456321", + ...overrides, +}); From 7c752a4370509a3aa6bcf175eacde8b1ed028ea3 Mon Sep 17 00:00:00 2001 From: Angel Date: Thu, 12 Jun 2025 12:14:10 -0500 Subject: [PATCH 02/21] feat: add test for OrderConfirmation loader to verify orderId extraction --- .../order-confirmation.loader.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/routes/order-confirmation/order-confirmation.loader.test.ts diff --git a/src/routes/order-confirmation/order-confirmation.loader.test.ts b/src/routes/order-confirmation/order-confirmation.loader.test.ts new file mode 100644 index 0000000..a8475b1 --- /dev/null +++ b/src/routes/order-confirmation/order-confirmation.loader.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { loader } from "."; + +describe("OrderConfirmation loader", () => { + // Helper function to create loader arguments + const createLoaderArgs = (orderId: string) => ({ + params: { orderId }, + request: new Request(`http://localhost/order-confirmation/${orderId}`), + context: {}, + }); + + it("should return orderId from params", async () => { + // Step 1: Setup - Create test data + const testOrderId = "testOrderId-123"; // Example order ID + + // Step 2: Mock - Not needed as loader has no dependencies + + // Step 3: Call service function + const result = await loader(createLoaderArgs(testOrderId)); + + // Step 4: Verify expected behavior + expect(result).toEqual({ + orderId: testOrderId, + }); + }); +}); From 3d26466c73df0705461f19858409ba25bc8ddbb8 Mon Sep 17 00:00:00 2001 From: Angel Date: Thu, 12 Jun 2025 15:27:39 -0500 Subject: [PATCH 03/21] feat: add OrderConfirmation component tests for success messages, tracking information, and layout structure --- .../order-confirmation.test.tsx | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/routes/order-confirmation/order-confirmation.test.tsx diff --git a/src/routes/order-confirmation/order-confirmation.test.tsx b/src/routes/order-confirmation/order-confirmation.test.tsx new file mode 100644 index 0000000..5afc55c --- /dev/null +++ b/src/routes/order-confirmation/order-confirmation.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import OrderConfirmation from "."; +import type { Route } from "./+types"; + +// Mock Container component +vi.mock("@/components/ui", () => ({ + Container: vi.fn(({ children }) => ( +
{children}
+ )), +})); + +// Creates minimal test props for OrderConfirmation component +const createTestProps = (orderId = "test-123"): Route.ComponentProps => ({ + loaderData: { orderId }, + params: vi.fn() as any, + matches: vi.fn() as any, +}); + +describe("OrderConfirmation", () => { + describe("Success Messages Display", () => { + it("should display all success messages correctly", () => { + // Step 1: Setup - Create test props + const props = createTestProps(); + // Step 2: Mock + // Step 3: Call - Render component + render(); + // Step 4: Verify - Check all success messages + const expectedMessages = [ + "¡Muchas gracias por tu compra!", + "Tu orden está en camino", + "Llegaremos a la puerta de tu domicilio lo antes posible", + ]; + expectedMessages.forEach((message) => { + expect(screen.getByText(message)).toBeInTheDocument(); + }); + }); + }); + + describe("Order Tracking Information", () => { + it("should display correct tracking code section", () => { + // Step 1: Setup - Create test props with a specific order ID + const testOrderId = "order-456"; + const props = createTestProps(testOrderId); + // Step 2: Mock + // Step 3: Call - Render component + render(); + // Step 4: Verify - Check tracking code section + const trackingCodeLabel = screen.getByText("Código de seguimiento"); + expect(trackingCodeLabel).toBeInTheDocument(); + + const trackingCode = screen.getByText(testOrderId); + expect(trackingCode).toBeInTheDocument(); + }); + }); + + describe("Layout Structure", () => { + it("should render with correct layout structure and classes", () => { + // Step 1: Setup - Create test props + const props = createTestProps(); + // Step 2: Mock + // Step 3: Call - Render component + render(); + // Step 4: Verify - Check layout structure + const container = screen.getByTestId("mock-container"); + expect(container).toBeInTheDocument(); + + const section = container.parentElement; + expect(section).toHaveClass( + "pt-12", + "pb-12", + "sm:pt-14", + "sm:pb-14", + "lg:pt-16", + "lg:pb-16" + ); + }); + }); +}); From 0885fe3c36d67ae2703c3d8748bd1b015c6ef529 Mon Sep 17 00:00:00 2001 From: Angel Date: Wed, 18 Jun 2025 19:25:31 -0500 Subject: [PATCH 04/21] refactor: simplify order-confirmation.test component by removing unnecessary mocks ans tests --- .../order-confirmation.test.tsx | 36 ++----------------- 1 file changed, 3 insertions(+), 33 deletions(-) diff --git a/src/routes/order-confirmation/order-confirmation.test.tsx b/src/routes/order-confirmation/order-confirmation.test.tsx index 5afc55c..becd17c 100644 --- a/src/routes/order-confirmation/order-confirmation.test.tsx +++ b/src/routes/order-confirmation/order-confirmation.test.tsx @@ -4,13 +4,6 @@ import { describe, expect, it, vi } from "vitest"; import OrderConfirmation from "."; import type { Route } from "./+types"; -// Mock Container component -vi.mock("@/components/ui", () => ({ - Container: vi.fn(({ children }) => ( -
{children}
- )), -})); - // Creates minimal test props for OrderConfirmation component const createTestProps = (orderId = "test-123"): Route.ComponentProps => ({ loaderData: { orderId }, @@ -33,7 +26,7 @@ describe("OrderConfirmation", () => { "Llegaremos a la puerta de tu domicilio lo antes posible", ]; expectedMessages.forEach((message) => { - expect(screen.getByText(message)).toBeInTheDocument(); + expect(screen.queryByText(message)).toBeInTheDocument(); }); }); }); @@ -47,34 +40,11 @@ describe("OrderConfirmation", () => { // Step 3: Call - Render component render(); // Step 4: Verify - Check tracking code section - const trackingCodeLabel = screen.getByText("Código de seguimiento"); + const trackingCodeLabel = screen.queryByText("Código de seguimiento"); expect(trackingCodeLabel).toBeInTheDocument(); - const trackingCode = screen.getByText(testOrderId); + const trackingCode = screen.queryByText(testOrderId); expect(trackingCode).toBeInTheDocument(); }); }); - - describe("Layout Structure", () => { - it("should render with correct layout structure and classes", () => { - // Step 1: Setup - Create test props - const props = createTestProps(); - // Step 2: Mock - // Step 3: Call - Render component - render(); - // Step 4: Verify - Check layout structure - const container = screen.getByTestId("mock-container"); - expect(container).toBeInTheDocument(); - - const section = container.parentElement; - expect(section).toHaveClass( - "pt-12", - "pb-12", - "sm:pt-14", - "sm:pb-14", - "lg:pt-16", - "lg:pb-16" - ); - }); - }); }); From 900d04e57be3ae8de97345857de113d8dc395585 Mon Sep 17 00:00:00 2001 From: Diego Torres Date: Fri, 20 Jun 2025 20:26:13 -0500 Subject: [PATCH 05/21] feat: add Prisma setup with user model and seed script --- .gitignore | 2 + package-lock.json | 120 ++++++++++++++++++ package.json | 5 + .../migration.sql | 15 +++ .../migration.sql | 3 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 27 ++++ prisma/seed.ts | 16 +++ 8 files changed, 191 insertions(+) create mode 100644 prisma/migrations/20250621010244_create_user_table/migration.sql create mode 100644 prisma/migrations/20250621010843_update_ts_on_users/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts diff --git a/.gitignore b/.gitignore index a1533a2..304ff76 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,5 @@ dist-ssr /playwright-report/ /blob-report/ /playwright/.cache/ + +/generated/prisma diff --git a/package-lock.json b/package-lock.json index 84f8416..65ac888 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@hookform/resolvers": "^4.1.3", + "@prisma/client": "^6.10.1", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-select": "^2.1.5", "@radix-ui/react-separator": "^1.1.0", @@ -58,6 +59,7 @@ "globals": "^15.12.0", "jsdom": "^26.1.0", "postcss": "^8.5.3", + "prisma": "^6.10.1", "react-router-devtools": "^1.1.10", "tailwindcss": "^3.4.17", "tsx": "^4.19.4", @@ -1899,6 +1901,98 @@ "node": ">=18" } }, + "node_modules/@prisma/client": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.10.1.tgz", + "integrity": "sha512-Re4pMlcUsQsUTAYMK7EJ4Bw2kg3WfZAAlr8GjORJaK4VOP6LxRQUQ1TuLnxcF42XqGkWQ36q5CQF1yVadANQ6w==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.10.1.tgz", + "integrity": "sha512-kz4/bnqrOrzWo8KzYguN0cden4CzLJJ+2VSpKtF8utHS3l1JS0Lhv6BLwpOX6X9yNreTbZQZwewb+/BMPDCIYQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "jiti": "2.4.2" + } + }, + "node_modules/@prisma/config/node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@prisma/debug": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.10.1.tgz", + "integrity": "sha512-k2YT53cWxv9OLjW4zSYTZ6Z7j0gPfCzcr2Mj99qsuvlxr8WAKSZ2NcSR0zLf/mP4oxnYG842IMj3utTgcd7CaA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.10.1.tgz", + "integrity": "sha512-Q07P5rS2iPwk2IQr/rUQJ42tHjpPyFcbiH7PXZlV81Ryr9NYIgdxcUrwgVOWVm5T7ap02C0dNd1dpnNcSWig8A==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.10.1", + "@prisma/engines-version": "6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c", + "@prisma/fetch-engine": "6.10.1", + "@prisma/get-platform": "6.10.1" + } + }, + "node_modules/@prisma/engines-version": { + "version": "6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c.tgz", + "integrity": "sha512-ZJFTsEqapiTYVzXya6TUKYDFnSWCNegfUiG5ik9fleQva5Sk3DNyyUi7X1+0ZxWFHwHDr6BZV5Vm+iwP+LlciA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.10.1.tgz", + "integrity": "sha512-clmbG/Jgmrc/n6Y77QcBmAUlq9LrwI9Dbgy4pq5jeEARBpRCWJDJ7PWW1P8p0LfFU0i5fsyO7FqRzRB8mkdS4g==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.10.1", + "@prisma/engines-version": "6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c", + "@prisma/get-platform": "6.10.1" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.10.1.tgz", + "integrity": "sha512-4CY5ndKylcsce9Mv+VWp5obbR2/86SHOLVV053pwIkhVtT9C9A83yqiqI/5kJM9T1v1u1qco/bYjDKycmei9HA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.10.1" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", @@ -9737,6 +9831,32 @@ "dev": true, "license": "MIT" }, + "node_modules/prisma": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.10.1.tgz", + "integrity": "sha512-khhlC/G49E4+uyA3T3H5PRBut486HD2bDqE2+rvkU0pwk9IAqGFacLFUyIx9Uw+W2eCtf6XGwsp+/strUwMNPw==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.10.1", + "@prisma/engines": "6.10.1" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/proc-log": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz", diff --git a/package.json b/package.json index ff9004a..f5ad9cc 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,12 @@ "seed:dev": "tsx src/db/scripts/seed.ts", "test": "vitest" }, + "prisma": { + "seed": "tsx ./prisma/seed.ts" + }, "dependencies": { "@hookform/resolvers": "^4.1.3", + "@prisma/client": "^6.10.1", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-select": "^2.1.5", "@radix-ui/react-separator": "^1.1.0", @@ -70,6 +74,7 @@ "globals": "^15.12.0", "jsdom": "^26.1.0", "postcss": "^8.5.3", + "prisma": "^6.10.1", "react-router-devtools": "^1.1.10", "tailwindcss": "^3.4.17", "tsx": "^4.19.4", diff --git a/prisma/migrations/20250621010244_create_user_table/migration.sql b/prisma/migrations/20250621010244_create_user_table/migration.sql new file mode 100644 index 0000000..8aec2e6 --- /dev/null +++ b/prisma/migrations/20250621010244_create_user_table/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "users" ( + "id" SERIAL NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT, + "password" TEXT, + "is_guest" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); diff --git a/prisma/migrations/20250621010843_update_ts_on_users/migration.sql b/prisma/migrations/20250621010843_update_ts_on_users/migration.sql new file mode 100644 index 0000000..75dae24 --- /dev/null +++ b/prisma/migrations/20250621010843_update_ts_on_users/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "users" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(0), +ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMP(0); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..37149e7 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,27 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + +generator client { + provider = "prisma-client-js" + output = "../generated/prisma" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? + password String? + isGuest Boolean @default(true) @map("is_guest") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + @@map("users") +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..cbdac46 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,16 @@ +import { PrismaClient } from "../generated/prisma"; + +const prisma = new PrismaClient(); + +async function seedDb() { + await prisma.user.create({ + data: { + email: "testino@mail.com", + }, + }); +} + +seedDb().then(() => { + console.log("Database seeded successfully."); + prisma.$disconnect(); +}); From 4ae0f7308b175ece6e9af42dfa8f5c1d2e90ceb0 Mon Sep 17 00:00:00 2001 From: mike Date: Sat, 21 Jun 2025 00:39:49 -0500 Subject: [PATCH 06/21] feat: add initial data and schema for categories and products, update seeding script --- prisma/initial_data.ts | 351 ++++++++++++++++++ .../migration.sql | 119 ++++++ prisma/schema.prisma | 106 ++++++ prisma/seed.ts | 26 +- 4 files changed, 594 insertions(+), 8 deletions(-) create mode 100644 prisma/initial_data.ts create mode 100644 prisma/migrations/20250621053111_proposed_schema/migration.sql diff --git a/prisma/initial_data.ts b/prisma/initial_data.ts new file mode 100644 index 0000000..4d6b185 --- /dev/null +++ b/prisma/initial_data.ts @@ -0,0 +1,351 @@ +export const categories = [ + { + title: "Polos", + slug: "polos", + imgSrc: "/images/polos.jpg", + alt: "Hombre luciendo polo azul", + description: + "Polos exclusivos con diseños que todo desarrollador querrá lucir. Ideales para llevar el código a donde vayas.", + }, + { + title: "Tazas", + slug: "tazas", + imgSrc: "/images/tazas.jpg", + alt: "Tazas con diseño de código", + description: + "Tazas que combinan perfectamente con tu café matutino y tu pasión por la programación. ¡Empieza el día con estilo!", + }, + { + title: "Stickers", + slug: "stickers", + imgSrc: "/images/stickers.jpg", + alt: "Stickers de desarrollo web", + description: + "Personaliza tu espacio de trabajo con nuestros stickers únicos y muestra tu amor por el desarrollo web.", + }, +]; + +export const products = [ + { + title: "Polo React", + imgSrc: "/images/polos/polo-react.png", + price: 20.00, + description: "Viste tu pasión por React con estilo y comodidad en cada línea de código.", + categoryId: 1, + isOnSale: false, + features: [ + "Estampado resistente que mantiene sus colores vibrantes lavado tras lavado.", + "Hecho de algodón suave que asegura comodidad y frescura.", + "Costuras reforzadas para una mayor durabilidad.", + "Corte moderno que se adapta perfectamente al cuerpo." + ] + }, + { + title: "Polo JavaScript", + imgSrc: "/images/polos/polo-js.png", + price: 20.00, + description: "Deja que tu amor por JavaScript hable a través de cada hilo de este polo.", + categoryId: 1, + isOnSale: false, + features: [ + "Logo de JavaScript bordado con precisión y detalle.", + "Tela premium de algodón peinado.", + "Disponible en varios colores.", + "Acabado profesional con doble costura." + ] + }, + { + title: "Polo Node.js", + imgSrc: "/images/polos/polo-node.png", + price: 20.00, + description: "Conéctate al estilo con este polo de Node.js, tan robusto como tu código.", + categoryId: 1, + isOnSale: false, + features: [ + "Diseño minimalista con el logo de Node.js.", + "Material transpirable ideal para largas sesiones de código.", + "Tejido resistente a múltiples lavados.", + "Etiqueta sin costuras para mayor comodidad." + ] + }, + { + title: "Polo TypeScript", + imgSrc: "/images/polos/polo-ts.png", + price: 20.00, + description: "Tipa tu estilo con precisión: lleva tu pasión por TypeScript en cada hilo.", + categoryId: 1, + isOnSale: false, + features: [ + "Logo de TypeScript estampado en alta calidad.", + "Tejido antimanchas y duradero.", + "Cuello reforzado que mantiene su forma.", + "100% algodón hipoalergénico." + ] + }, + { + title: "Polo Backend Developer", + imgSrc: "/images/polos/polo-backend.png", + price: 25.00, + description: "Domina el servidor con estilo: viste con orgullo tu título de Backend Developer.", + categoryId: 1, + isOnSale: false, + features: [ + "Diseño exclusivo para desarrolladores backend.", + "Material premium que mantiene su forma.", + "Costuras reforzadas en puntos de tensión.", + "Estampado de alta durabilidad." + ] + }, + { + title: "Polo Frontend Developer", + imgSrc: "/images/polos/polo-frontend.png", + price: 25.00, + description: "Construye experiencias con estilo: luce con orgullo tu polo de Frontend Developer.", + categoryId: 1, + isOnSale: false, + features: [ + "Diseño inspirado en elementos de UI/UX.", + "Tela suave y ligera perfecta para el día a día.", + "Estampado flexible que no se agrieta.", + "Acabado profesional en cada detalle." + ] + }, + { + title: "Polo Full-Stack Developer", + imgSrc: "/images/polos/polo-fullstack.png", + price: 25.00, + description: "Domina ambos mundos con estilo: lleva tu título de FullStack Developer en cada línea de tu look.", + categoryId: 1, + isOnSale: false, + features: [ + "Diseño que representa ambos mundos del desarrollo.", + "Material premium de larga duración.", + "Proceso de estampado ecológico.", + "Corte moderno y cómodo." + ] + }, + { + title: "Polo It's A Feature", + imgSrc: "/images/polos/polo-feature.png", + price: 15.00, + description: "Cuando el bug se convierte en arte: lleva con orgullo tu polo 'It's a feature'.", + categoryId: 1, + isOnSale: true, + features: [ + "Estampado humorístico de alta calidad.", + "Algodón orgánico certificado.", + "Diseño exclusivo de la comunidad dev.", + "Disponible en múltiples colores." + ] + }, + { + title: "Polo It Works On My Machine", + imgSrc: "/images/polos/polo-works.png", + price: 15.00, + description: "El clásico del desarrollador: presume tu confianza con 'It works on my machine'.", + categoryId: 1, + isOnSale: true, + features: [ + "Frase icónica del mundo del desarrollo.", + "Material durable y cómodo.", + "Estampado que no se desvanece.", + "Ideal para regalo entre desarrolladores." + ] + }, + { + title: "Sticker JavaScript", + imgSrc: "/images/stickers/sticker-js.png", + price: 2.99, + description: "Muestra tu amor por JavaScript con este elegante sticker clásico.", + categoryId: 3, + isOnSale: false, + features: [ + "Vinilo de alta calidad resistente al agua", + "Adhesivo duradero que no deja residuos", + "Colores vibrantes que no se desvanecen", + "Tamaño perfecto para laptops y dispositivos" + ] + }, + { + title: "Sticker React", + imgSrc: "/images/stickers/sticker-react.png", + price: 2.49, + description: "Decora tus dispositivos con el icónico átomo giratorio de React.", + categoryId: 3, + isOnSale: false, + features: [ + "Vinilo de alta calidad resistente al agua", + "Adhesivo duradero que no deja residuos", + "Colores vibrantes que no se desvanecen", + "Tamaño perfecto para laptops y dispositivos" + ] + }, + { + title: "Sticker Git", + imgSrc: "/images/stickers/sticker-git.png", + price: 3.99, + description: "Visualiza el poder del control de versiones con este sticker de Git.", + categoryId: 3, + isOnSale: false, + features: [ + "Vinilo de alta calidad resistente al agua", + "Adhesivo duradero que no deja residuos", + "Colores vibrantes que no se desvanecen", + "Tamaño perfecto para laptops y dispositivos" + ] + }, + { + title: "Sticker Docker", + imgSrc: "/images/stickers/sticker-docker.png", + price: 2.99, + description: "La adorable ballena de Docker llevando contenedores en un sticker único.", + categoryId: 3, + isOnSale: false, + features: [ + "Vinilo de alta calidad resistente al agua", + "Adhesivo duradero que no deja residuos", + "Colores vibrantes que no se desvanecen", + "Tamaño perfecto para laptops y dispositivos" + ] + }, + { + title: "Sticker Linux", + imgSrc: "/images/stickers/sticker-linux.png", + price: 2.49, + description: "El querido pingüino Tux, mascota oficial de Linux, en formato sticker.", + categoryId: 3, + isOnSale: false, + features: [ + "Vinilo de alta calidad resistente al agua", + "Adhesivo duradero que no deja residuos", + "Colores vibrantes que no se desvanecen", + "Tamaño perfecto para laptops y dispositivos" + ] + }, + { + title: "Sticker VS Code", + imgSrc: "/images/stickers/sticker-vscode.png", + price: 2.49, + description: "El elegante logo del editor favorito de los desarrolladores.", + categoryId: 3, + isOnSale: false, + features: [ + "Vinilo de alta calidad resistente al agua", + "Adhesivo duradero que no deja residuos", + "Colores vibrantes que no se desvanecen", + "Tamaño perfecto para laptops y dispositivos" + ] + }, + { + title: "Sticker GitHub", + imgSrc: "/images/stickers/sticker-github.png", + price: 2.99, + description: "El alojamiento de repositorios más popular en un sticker de alta calidad.", + categoryId: 3, + isOnSale: false, + features: [ + "Vinilo de alta calidad resistente al agua", + "Adhesivo duradero que no deja residuos", + "Colores vibrantes que no se desvanecen", + "Tamaño perfecto para laptops y dispositivos" + ] + }, + { + title: "Sticker HTML", + imgSrc: "/images/stickers/sticker-html.png", + price: 2.99, + description: "El escudo naranja de HTML5, el lenguaje que estructura la web.", + categoryId: 3, + isOnSale: false, + features: [ + "Vinilo de alta calidad resistente al agua", + "Adhesivo duradero que no deja residuos", + "Colores vibrantes que no se desvanecen", + "Tamaño perfecto para laptops y dispositivos" + ] + }, + { + title: "Taza JavaScript", + imgSrc: "/images/tazas/taza-js.png", + price: 14.99, + description: "Disfruta tu café mientras programas con el logo de JavaScript.", + categoryId: 2, + isOnSale: false, + features: [ + "Cerámica de alta calidad", + "Apta para microondas y lavavajillas", + "Capacidad de 325ml", + "Diseño que no pierde color con el uso" + ] + }, + { + title: "Taza React", + imgSrc: "/images/tazas/taza-react.png", + price: 13.99, + description: "Una taza que hace render de tu bebida favorita con estilo React.", + categoryId: 2, + isOnSale: false, + features: [ + "Cerámica de alta calidad", + "Apta para microondas y lavavajillas", + "Capacidad de 325ml", + "Diseño que no pierde color con el uso" + ] + }, + { + title: "Taza Git", + imgSrc: "/images/tazas/taza-git.png", + price: 12.99, + description: "Commit a tu rutina diaria de café con esta taza de Git.", + categoryId: 2, + isOnSale: false, + features: [ + "Cerámica de alta calidad", + "Apta para microondas y lavavajillas", + "Capacidad de 325ml", + "Diseño que no pierde color con el uso" + ] + }, + { + title: "Taza SQL", + imgSrc: "/images/tazas/taza-sql.png", + price: 15.99, + description: "Tu amor por los lenguajes estructurados en una taza de SQL.", + categoryId: 2, + isOnSale: false, + features: [ + "Cerámica de alta calidad", + "Apta para microondas y lavavajillas", + "Capacidad de 325ml", + "Diseño que no pierde color con el uso" + ] + }, + { + title: "Taza Linux", + imgSrc: "/images/tazas/taza-linux.png", + price: 13.99, + description: "Toma tu café con la libertad que solo Linux puede ofrecer.", + categoryId: 2, + isOnSale: false, + features: [ + "Cerámica de alta calidad", + "Apta para microondas y lavavajillas", + "Capacidad de 325ml", + "Diseño que no pierde color con el uso" + ] + }, + { + title: "Taza GitHub", + imgSrc: "/images/tazas/taza-github.png", + price: 14.99, + description: "Colabora con tu café en esta taza con el logo de GitHub.", + categoryId: 2, + isOnSale: false, + features: [ + "Cerámica de alta calidad", + "Apta para microondas y lavavajillas", + "Capacidad de 325ml", + "Diseño que no pierde color con el uso" + ] + } +]; \ No newline at end of file diff --git a/prisma/migrations/20250621053111_proposed_schema/migration.sql b/prisma/migrations/20250621053111_proposed_schema/migration.sql new file mode 100644 index 0000000..71e0b5b --- /dev/null +++ b/prisma/migrations/20250621053111_proposed_schema/migration.sql @@ -0,0 +1,119 @@ +-- CreateTable +CREATE TABLE "categories" ( + "id" SERIAL NOT NULL, + "title" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "img_src" TEXT, + "alt" TEXT, + "description" TEXT, + "created_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "categories_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "products" ( + "id" SERIAL NOT NULL, + "title" TEXT NOT NULL, + "img_src" TEXT NOT NULL, + "alt" TEXT, + "price" DECIMAL(10,2) NOT NULL, + "description" TEXT, + "category_id" INTEGER, + "is_on_sale" BOOLEAN NOT NULL DEFAULT false, + "features" TEXT[], + "created_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "products_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "carts" ( + "id" SERIAL NOT NULL, + "session_cart_id" UUID NOT NULL DEFAULT gen_random_uuid(), + "user_id" INTEGER, + "created_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "carts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "cart_items" ( + "id" SERIAL NOT NULL, + "cart_id" INTEGER NOT NULL, + "product_id" INTEGER NOT NULL, + "quantity" INTEGER NOT NULL, + "created_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "cart_items_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "orders" ( + "id" SERIAL NOT NULL, + "user_id" INTEGER NOT NULL, + "total_amount" DECIMAL(10,2) NOT NULL, + "email" TEXT NOT NULL, + "first_name" TEXT NOT NULL, + "last_name" TEXT NOT NULL, + "company" TEXT, + "address" TEXT NOT NULL, + "city" TEXT NOT NULL, + "country" TEXT NOT NULL, + "region" TEXT NOT NULL, + "zip" TEXT NOT NULL, + "phone" TEXT NOT NULL, + "created_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "orders_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "order_items" ( + "id" SERIAL NOT NULL, + "order_id" INTEGER NOT NULL, + "product_id" INTEGER, + "quantity" INTEGER NOT NULL, + "title" TEXT NOT NULL, + "price" DECIMAL(10,2) NOT NULL, + "img_src" TEXT, + "created_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "order_items_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "categories_slug_key" ON "categories"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "carts_session_cart_id_key" ON "carts"("session_cart_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "cart_items_cart_id_product_id_key" ON "cart_items"("cart_id", "product_id"); + +-- AddForeignKey +ALTER TABLE "products" ADD CONSTRAINT "products_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "carts" ADD CONSTRAINT "carts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cart_items" ADD CONSTRAINT "cart_items_cart_id_fkey" FOREIGN KEY ("cart_id") REFERENCES "carts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cart_items" ADD CONSTRAINT "cart_items_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "orders" ADD CONSTRAINT "orders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order_items" ADD CONSTRAINT "order_items_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order_items" ADD CONSTRAINT "order_items_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 37149e7..b4395dc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,6 +22,112 @@ model User { isGuest Boolean @default(true) @map("is_guest") createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + carts Cart[] + orders Order[] @@map("users") } + +model Category { + id Int @id @default(autoincrement()) + title String + slug String @unique + imgSrc String? @map("img_src") + alt String? + description String? + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + products Product[] + + @@map("categories") +} + +model Product { + id Int @id @default(autoincrement()) + title String + imgSrc String @map("img_src") + alt String? + price Decimal @db.Decimal(10, 2) + description String? + categoryId Int? @map("category_id") + isOnSale Boolean @default(false) @map("is_on_sale") + features String[] + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + cartItems CartItem[] + orderItems OrderItem[] + + @@map("products") +} + +model Cart { + id Int @id @default(autoincrement()) + sessionCartId String @unique @default(dbgenerated("gen_random_uuid()")) @map("session_cart_id") @db.Uuid + userId Int? @map("user_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + items CartItem[] + + @@map("carts") +} + +model CartItem { + id Int @id @default(autoincrement()) + cartId Int @map("cart_id") + productId Int @map("product_id") + quantity Int + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + + @@unique([cartId, productId], name: "unique_cart_item") + @@map("cart_items") +} + +model Order { + id Int @id @default(autoincrement()) + userId Int @map("user_id") + totalAmount Decimal @map("total_amount") @db.Decimal(10, 2) + email String + firstName String @map("first_name") + lastName String @map("last_name") + company String? + address String + city String + country String + region String + zip String + phone String + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + items OrderItem[] + + @@map("orders") +} + +model OrderItem { + id Int @id @default(autoincrement()) + orderId Int @map("order_id") + productId Int? @map("product_id") + quantity Int + title String + price Decimal @db.Decimal(10, 2) + imgSrc String? @map("img_src") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) + + @@map("order_items") +} \ No newline at end of file diff --git a/prisma/seed.ts b/prisma/seed.ts index cbdac46..a7de4e4 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,16 +1,26 @@ +import { categories, products } from "./initial_data"; import { PrismaClient } from "../generated/prisma"; const prisma = new PrismaClient(); async function seedDb() { - await prisma.user.create({ - data: { - email: "testino@mail.com", - }, + await prisma.category.createMany({ + data: categories, }); + console.log("1. Categories successfully inserted"); + + await prisma.product.createMany({ + data: products, + }); + console.log("2. Products successfully inserted"); } -seedDb().then(() => { - console.log("Database seeded successfully."); - prisma.$disconnect(); -}); +seedDb() + .catch((e) => { + console.error("Seeding error:", e); + }) + .finally(async () => { + console.log("--- Database seeded successfully. ---"); + await prisma.$disconnect(); + }); + From f92470d6c407a997a4d9422130eed3468c4916f8 Mon Sep 17 00:00:00 2001 From: Diego Torres Date: Tue, 24 Jun 2025 20:22:40 -0500 Subject: [PATCH 07/21] feat: convert category slug to enum and update related services --- prisma/initial_data.ts | 176 ++++++++++-------- .../migration.sql | 26 +++ prisma/schema.prisma | 118 ++++++------ src/db/prisma.ts | 28 +++ src/services/category.service.test.ts | 40 ++-- src/services/category.service.ts | 14 +- 6 files changed, 245 insertions(+), 157 deletions(-) create mode 100644 prisma/migrations/20250625005548_convert_category_slug_to_enum/migration.sql create mode 100644 src/db/prisma.ts diff --git a/prisma/initial_data.ts b/prisma/initial_data.ts index 4d6b185..333eab7 100644 --- a/prisma/initial_data.ts +++ b/prisma/initial_data.ts @@ -1,7 +1,9 @@ +import type { CategorySlug } from "generated/prisma"; + export const categories = [ { title: "Polos", - slug: "polos", + slug: "polos" as CategorySlug, imgSrc: "/images/polos.jpg", alt: "Hombre luciendo polo azul", description: @@ -9,7 +11,7 @@ export const categories = [ }, { title: "Tazas", - slug: "tazas", + slug: "tazas" as CategorySlug, imgSrc: "/images/tazas.jpg", alt: "Tazas con diseño de código", description: @@ -17,7 +19,7 @@ export const categories = [ }, { title: "Stickers", - slug: "stickers", + slug: "stickers" as CategorySlug, imgSrc: "/images/stickers.jpg", alt: "Stickers de desarrollo web", description: @@ -29,198 +31,212 @@ export const products = [ { title: "Polo React", imgSrc: "/images/polos/polo-react.png", - price: 20.00, - description: "Viste tu pasión por React con estilo y comodidad en cada línea de código.", + price: 20.0, + description: + "Viste tu pasión por React con estilo y comodidad en cada línea de código.", categoryId: 1, isOnSale: false, features: [ "Estampado resistente que mantiene sus colores vibrantes lavado tras lavado.", "Hecho de algodón suave que asegura comodidad y frescura.", "Costuras reforzadas para una mayor durabilidad.", - "Corte moderno que se adapta perfectamente al cuerpo." - ] + "Corte moderno que se adapta perfectamente al cuerpo.", + ], }, { title: "Polo JavaScript", imgSrc: "/images/polos/polo-js.png", - price: 20.00, - description: "Deja que tu amor por JavaScript hable a través de cada hilo de este polo.", + price: 20.0, + description: + "Deja que tu amor por JavaScript hable a través de cada hilo de este polo.", categoryId: 1, isOnSale: false, features: [ "Logo de JavaScript bordado con precisión y detalle.", "Tela premium de algodón peinado.", "Disponible en varios colores.", - "Acabado profesional con doble costura." - ] + "Acabado profesional con doble costura.", + ], }, { title: "Polo Node.js", imgSrc: "/images/polos/polo-node.png", - price: 20.00, - description: "Conéctate al estilo con este polo de Node.js, tan robusto como tu código.", + price: 20.0, + description: + "Conéctate al estilo con este polo de Node.js, tan robusto como tu código.", categoryId: 1, isOnSale: false, features: [ "Diseño minimalista con el logo de Node.js.", "Material transpirable ideal para largas sesiones de código.", "Tejido resistente a múltiples lavados.", - "Etiqueta sin costuras para mayor comodidad." - ] + "Etiqueta sin costuras para mayor comodidad.", + ], }, { title: "Polo TypeScript", imgSrc: "/images/polos/polo-ts.png", - price: 20.00, - description: "Tipa tu estilo con precisión: lleva tu pasión por TypeScript en cada hilo.", + price: 20.0, + description: + "Tipa tu estilo con precisión: lleva tu pasión por TypeScript en cada hilo.", categoryId: 1, isOnSale: false, features: [ "Logo de TypeScript estampado en alta calidad.", "Tejido antimanchas y duradero.", "Cuello reforzado que mantiene su forma.", - "100% algodón hipoalergénico." - ] + "100% algodón hipoalergénico.", + ], }, { title: "Polo Backend Developer", imgSrc: "/images/polos/polo-backend.png", - price: 25.00, - description: "Domina el servidor con estilo: viste con orgullo tu título de Backend Developer.", + price: 25.0, + description: + "Domina el servidor con estilo: viste con orgullo tu título de Backend Developer.", categoryId: 1, isOnSale: false, features: [ "Diseño exclusivo para desarrolladores backend.", "Material premium que mantiene su forma.", "Costuras reforzadas en puntos de tensión.", - "Estampado de alta durabilidad." - ] + "Estampado de alta durabilidad.", + ], }, { title: "Polo Frontend Developer", imgSrc: "/images/polos/polo-frontend.png", - price: 25.00, - description: "Construye experiencias con estilo: luce con orgullo tu polo de Frontend Developer.", + price: 25.0, + description: + "Construye experiencias con estilo: luce con orgullo tu polo de Frontend Developer.", categoryId: 1, isOnSale: false, features: [ "Diseño inspirado en elementos de UI/UX.", "Tela suave y ligera perfecta para el día a día.", "Estampado flexible que no se agrieta.", - "Acabado profesional en cada detalle." - ] + "Acabado profesional en cada detalle.", + ], }, { title: "Polo Full-Stack Developer", imgSrc: "/images/polos/polo-fullstack.png", - price: 25.00, - description: "Domina ambos mundos con estilo: lleva tu título de FullStack Developer en cada línea de tu look.", + price: 25.0, + description: + "Domina ambos mundos con estilo: lleva tu título de FullStack Developer en cada línea de tu look.", categoryId: 1, isOnSale: false, features: [ "Diseño que representa ambos mundos del desarrollo.", "Material premium de larga duración.", "Proceso de estampado ecológico.", - "Corte moderno y cómodo." - ] + "Corte moderno y cómodo.", + ], }, { title: "Polo It's A Feature", imgSrc: "/images/polos/polo-feature.png", - price: 15.00, - description: "Cuando el bug se convierte en arte: lleva con orgullo tu polo 'It's a feature'.", + price: 15.0, + description: + "Cuando el bug se convierte en arte: lleva con orgullo tu polo 'It's a feature'.", categoryId: 1, isOnSale: true, features: [ "Estampado humorístico de alta calidad.", "Algodón orgánico certificado.", "Diseño exclusivo de la comunidad dev.", - "Disponible en múltiples colores." - ] + "Disponible en múltiples colores.", + ], }, { title: "Polo It Works On My Machine", imgSrc: "/images/polos/polo-works.png", - price: 15.00, - description: "El clásico del desarrollador: presume tu confianza con 'It works on my machine'.", + price: 15.0, + description: + "El clásico del desarrollador: presume tu confianza con 'It works on my machine'.", categoryId: 1, isOnSale: true, features: [ "Frase icónica del mundo del desarrollo.", "Material durable y cómodo.", "Estampado que no se desvanece.", - "Ideal para regalo entre desarrolladores." - ] + "Ideal para regalo entre desarrolladores.", + ], }, { title: "Sticker JavaScript", imgSrc: "/images/stickers/sticker-js.png", price: 2.99, - description: "Muestra tu amor por JavaScript con este elegante sticker clásico.", + description: + "Muestra tu amor por JavaScript con este elegante sticker clásico.", categoryId: 3, isOnSale: false, features: [ "Vinilo de alta calidad resistente al agua", "Adhesivo duradero que no deja residuos", "Colores vibrantes que no se desvanecen", - "Tamaño perfecto para laptops y dispositivos" - ] + "Tamaño perfecto para laptops y dispositivos", + ], }, { title: "Sticker React", imgSrc: "/images/stickers/sticker-react.png", price: 2.49, - description: "Decora tus dispositivos con el icónico átomo giratorio de React.", + description: + "Decora tus dispositivos con el icónico átomo giratorio de React.", categoryId: 3, isOnSale: false, features: [ "Vinilo de alta calidad resistente al agua", "Adhesivo duradero que no deja residuos", "Colores vibrantes que no se desvanecen", - "Tamaño perfecto para laptops y dispositivos" - ] + "Tamaño perfecto para laptops y dispositivos", + ], }, { title: "Sticker Git", imgSrc: "/images/stickers/sticker-git.png", price: 3.99, - description: "Visualiza el poder del control de versiones con este sticker de Git.", + description: + "Visualiza el poder del control de versiones con este sticker de Git.", categoryId: 3, isOnSale: false, features: [ "Vinilo de alta calidad resistente al agua", "Adhesivo duradero que no deja residuos", "Colores vibrantes que no se desvanecen", - "Tamaño perfecto para laptops y dispositivos" - ] + "Tamaño perfecto para laptops y dispositivos", + ], }, { title: "Sticker Docker", imgSrc: "/images/stickers/sticker-docker.png", price: 2.99, - description: "La adorable ballena de Docker llevando contenedores en un sticker único.", + description: + "La adorable ballena de Docker llevando contenedores en un sticker único.", categoryId: 3, isOnSale: false, features: [ "Vinilo de alta calidad resistente al agua", "Adhesivo duradero que no deja residuos", "Colores vibrantes que no se desvanecen", - "Tamaño perfecto para laptops y dispositivos" - ] + "Tamaño perfecto para laptops y dispositivos", + ], }, { title: "Sticker Linux", imgSrc: "/images/stickers/sticker-linux.png", price: 2.49, - description: "El querido pingüino Tux, mascota oficial de Linux, en formato sticker.", + description: + "El querido pingüino Tux, mascota oficial de Linux, en formato sticker.", categoryId: 3, isOnSale: false, features: [ "Vinilo de alta calidad resistente al agua", "Adhesivo duradero que no deja residuos", "Colores vibrantes que no se desvanecen", - "Tamaño perfecto para laptops y dispositivos" - ] + "Tamaño perfecto para laptops y dispositivos", + ], }, { title: "Sticker VS Code", @@ -233,64 +249,68 @@ export const products = [ "Vinilo de alta calidad resistente al agua", "Adhesivo duradero que no deja residuos", "Colores vibrantes que no se desvanecen", - "Tamaño perfecto para laptops y dispositivos" - ] + "Tamaño perfecto para laptops y dispositivos", + ], }, { title: "Sticker GitHub", imgSrc: "/images/stickers/sticker-github.png", price: 2.99, - description: "El alojamiento de repositorios más popular en un sticker de alta calidad.", + description: + "El alojamiento de repositorios más popular en un sticker de alta calidad.", categoryId: 3, isOnSale: false, features: [ "Vinilo de alta calidad resistente al agua", "Adhesivo duradero que no deja residuos", "Colores vibrantes que no se desvanecen", - "Tamaño perfecto para laptops y dispositivos" - ] + "Tamaño perfecto para laptops y dispositivos", + ], }, { title: "Sticker HTML", imgSrc: "/images/stickers/sticker-html.png", price: 2.99, - description: "El escudo naranja de HTML5, el lenguaje que estructura la web.", + description: + "El escudo naranja de HTML5, el lenguaje que estructura la web.", categoryId: 3, isOnSale: false, features: [ "Vinilo de alta calidad resistente al agua", "Adhesivo duradero que no deja residuos", "Colores vibrantes que no se desvanecen", - "Tamaño perfecto para laptops y dispositivos" - ] + "Tamaño perfecto para laptops y dispositivos", + ], }, { title: "Taza JavaScript", imgSrc: "/images/tazas/taza-js.png", price: 14.99, - description: "Disfruta tu café mientras programas con el logo de JavaScript.", + description: + "Disfruta tu café mientras programas con el logo de JavaScript.", categoryId: 2, isOnSale: false, features: [ "Cerámica de alta calidad", "Apta para microondas y lavavajillas", "Capacidad de 325ml", - "Diseño que no pierde color con el uso" - ] + "Diseño que no pierde color con el uso", + ], }, { title: "Taza React", imgSrc: "/images/tazas/taza-react.png", price: 13.99, - description: "Una taza que hace render de tu bebida favorita con estilo React.", + description: + "Una taza que hace render de tu bebida favorita con estilo React.", categoryId: 2, isOnSale: false, features: [ "Cerámica de alta calidad", "Apta para microondas y lavavajillas", "Capacidad de 325ml", - "Diseño que no pierde color con el uso" - ] + "Diseño que no pierde color con el uso", + ], }, { title: "Taza Git", @@ -303,8 +323,8 @@ export const products = [ "Cerámica de alta calidad", "Apta para microondas y lavavajillas", "Capacidad de 325ml", - "Diseño que no pierde color con el uso" - ] + "Diseño que no pierde color con el uso", + ], }, { title: "Taza SQL", @@ -317,8 +337,8 @@ export const products = [ "Cerámica de alta calidad", "Apta para microondas y lavavajillas", "Capacidad de 325ml", - "Diseño que no pierde color con el uso" - ] + "Diseño que no pierde color con el uso", + ], }, { title: "Taza Linux", @@ -331,8 +351,8 @@ export const products = [ "Cerámica de alta calidad", "Apta para microondas y lavavajillas", "Capacidad de 325ml", - "Diseño que no pierde color con el uso" - ] + "Diseño que no pierde color con el uso", + ], }, { title: "Taza GitHub", @@ -345,7 +365,7 @@ export const products = [ "Cerámica de alta calidad", "Apta para microondas y lavavajillas", "Capacidad de 325ml", - "Diseño que no pierde color con el uso" - ] - } -]; \ No newline at end of file + "Diseño que no pierde color con el uso", + ], + }, +]; diff --git a/prisma/migrations/20250625005548_convert_category_slug_to_enum/migration.sql b/prisma/migrations/20250625005548_convert_category_slug_to_enum/migration.sql new file mode 100644 index 0000000..bb2264d --- /dev/null +++ b/prisma/migrations/20250625005548_convert_category_slug_to_enum/migration.sql @@ -0,0 +1,26 @@ +-- CreateEnum +CREATE TYPE "CategorySlug" AS ENUM ('polos', 'tazas', 'stickers'); + +-- AlterTable: Add temporary column +ALTER TABLE "categories" ADD COLUMN "slug_new" "CategorySlug"; + +-- Update data: Convert existing string values to enum +UPDATE "categories" SET "slug_new" = + CASE + WHEN "slug" = 'polos' THEN 'polos'::"CategorySlug" + WHEN "slug" = 'tazas' THEN 'tazas'::"CategorySlug" + WHEN "slug" = 'stickers' THEN 'stickers'::"CategorySlug" + ELSE 'polos'::"CategorySlug" -- default fallback + END; + +-- Make the new column NOT NULL +ALTER TABLE "categories" ALTER COLUMN "slug_new" SET NOT NULL; + +-- Drop the old column +ALTER TABLE "categories" DROP COLUMN "slug"; + +-- Rename the new column +ALTER TABLE "categories" RENAME COLUMN "slug_new" TO "slug"; + +-- Add unique constraint +ALTER TABLE "categories" ADD CONSTRAINT "categories_slug_key" UNIQUE ("slug"); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b4395dc..10062c8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,83 +22,89 @@ model User { isGuest Boolean @default(true) @map("is_guest") createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) - + carts Cart[] orders Order[] @@map("users") } +enum CategorySlug { + polos + tazas + stickers +} + model Category { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) title String - slug String @unique - imgSrc String? @map("img_src") + slug CategorySlug @unique + imgSrc String? @map("img_src") alt String? description String? - createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) - updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) - - products Product[] + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + products Product[] @@map("categories") } model Product { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) title String - imgSrc String @map("img_src") + imgSrc String @map("img_src") alt String? - price Decimal @db.Decimal(10, 2) + price Decimal @db.Decimal(10, 2) description String? - categoryId Int? @map("category_id") - isOnSale Boolean @default(false) @map("is_on_sale") + categoryId Int? @map("category_id") + isOnSale Boolean @default(false) @map("is_on_sale") features String[] - createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) - updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) - - category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) - cartItems CartItem[] - orderItems OrderItem[] + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + cartItems CartItem[] + orderItems OrderItem[] @@map("products") } model Cart { - id Int @id @default(autoincrement()) - sessionCartId String @unique @default(dbgenerated("gen_random_uuid()")) @map("session_cart_id") @db.Uuid - userId Int? @map("user_id") - createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) - updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) - - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) - items CartItem[] + id Int @id @default(autoincrement()) + sessionCartId String @unique @default(dbgenerated("gen_random_uuid()")) @map("session_cart_id") @db.Uuid + userId Int? @map("user_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + items CartItem[] @@map("carts") } model CartItem { - id Int @id @default(autoincrement()) - cartId Int @map("cart_id") - productId Int @map("product_id") + id Int @id @default(autoincrement()) + cartId Int @map("cart_id") + productId Int @map("product_id") quantity Int - createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) - updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) - - cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade) - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) @@unique([cartId, productId], name: "unique_cart_item") @@map("cart_items") } model Order { - id Int @id @default(autoincrement()) - userId Int @map("user_id") - totalAmount Decimal @map("total_amount") @db.Decimal(10, 2) + id Int @id @default(autoincrement()) + userId Int @map("user_id") + totalAmount Decimal @map("total_amount") @db.Decimal(10, 2) email String - firstName String @map("first_name") - lastName String @map("last_name") + firstName String @map("first_name") + lastName String @map("last_name") company String? address String city String @@ -106,28 +112,28 @@ model Order { region String zip String phone String - createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) - updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - items OrderItem[] + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + items OrderItem[] @@map("orders") } model OrderItem { - id Int @id @default(autoincrement()) - orderId Int @map("order_id") - productId Int? @map("product_id") + id Int @id @default(autoincrement()) + orderId Int @map("order_id") + productId Int? @map("product_id") quantity Int title String - price Decimal @db.Decimal(10, 2) - imgSrc String? @map("img_src") - createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) - updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) - - order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) - product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) + price Decimal @db.Decimal(10, 2) + imgSrc String? @map("img_src") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) @@map("order_items") -} \ No newline at end of file +} diff --git a/src/db/prisma.ts b/src/db/prisma.ts new file mode 100644 index 0000000..f887b70 --- /dev/null +++ b/src/db/prisma.ts @@ -0,0 +1,28 @@ +import { PrismaClient } from "generated/prisma"; + +// Global variable to store the Prisma client instance +declare global { + // eslint-disable-next-line no-var + var __prisma: PrismaClient | undefined; +} + +// Create a singleton instance of PrismaClient +// In development, this prevents multiple instances from being created +// during hot reloads, which can cause connection issues +let prisma: PrismaClient; + +if (process.env.NODE_ENV === "production") { + prisma = new PrismaClient(); +} else { + if (!global.__prisma) { + global.__prisma = new PrismaClient(); + } + prisma = global.__prisma; +} + +export { prisma }; + +// Graceful shutdown +process.on("beforeExit", async () => { + await prisma.$disconnect(); +}); diff --git a/src/services/category.service.test.ts b/src/services/category.service.test.ts index bb50a06..3e434cc 100644 --- a/src/services/category.service.test.ts +++ b/src/services/category.service.test.ts @@ -1,14 +1,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createTestCategory } from "@/lib/utils.tests"; -import * as categoriesRepository from "@/repositories/category.repository"; import { getAllCategories, getCategoryBySlug, } from "@/services/category.service"; -// Mock the repository -vi.mock("@/repositories/category.repository"); +// Mock Prisma client +const mockPrisma = { + category: { + findMany: vi.fn(), + findUnique: vi.fn(), + }, +}; + +vi.mock("@/db/prisma", () => ({ + prisma: mockPrisma, +})); describe("Category Service", () => { beforeEach(() => { @@ -30,23 +38,21 @@ describe("Category Service", () => { }), ]; - vi.mocked(categoriesRepository.getAllCategories).mockResolvedValue( - mockCategories - ); + mockPrisma.category.findMany.mockResolvedValue(mockCategories); const result = await getAllCategories(); expect(result).toEqual(mockCategories); - expect(categoriesRepository.getAllCategories).toHaveBeenCalledTimes(1); + expect(mockPrisma.category.findMany).toHaveBeenCalledTimes(1); }); it("should handle empty categories", async () => { - vi.mocked(categoriesRepository.getAllCategories).mockResolvedValue([]); + mockPrisma.category.findMany.mockResolvedValue([]); const result = await getAllCategories(); expect(result).toEqual([]); - expect(categoriesRepository.getAllCategories).toHaveBeenCalledTimes(1); + expect(mockPrisma.category.findMany).toHaveBeenCalledTimes(1); }); }); @@ -54,23 +60,21 @@ describe("Category Service", () => { it("should return category when found", async () => { const mockCategory = createTestCategory(); - vi.mocked(categoriesRepository.getCategoryBySlug).mockResolvedValue( - mockCategory - ); + mockPrisma.category.findUnique.mockResolvedValue(mockCategory); const result = await getCategoryBySlug("polos"); expect(result).toEqual(mockCategory); - expect(categoriesRepository.getCategoryBySlug).toHaveBeenCalledWith( - "polos" - ); + expect(mockPrisma.category.findUnique).toHaveBeenCalledWith({ + where: { slug: "polos" }, + }); }); it("should throw error when category not found", async () => { - vi.mocked(categoriesRepository.getCategoryBySlug).mockResolvedValue(null); + mockPrisma.category.findUnique.mockResolvedValue(null); - await expect(getCategoryBySlug("non-existent")).rejects.toThrow( - 'Category with slug "non-existent" not found' + await expect(getCategoryBySlug("polos")).rejects.toThrow( + 'Category with slug "polos" not found' ); }); }); diff --git a/src/services/category.service.ts b/src/services/category.service.ts index 15edf13..09ad9a9 100644 --- a/src/services/category.service.ts +++ b/src/services/category.service.ts @@ -1,12 +1,16 @@ -import { type Category } from "@/models/category.model"; -import * as categoriesRepository from "@/repositories/category.repository"; +import { type Category, type CategorySlug } from "generated/prisma"; + +import { prisma } from "@/db/prisma"; export async function getAllCategories(): Promise { - return categoriesRepository.getAllCategories(); + const categories = await prisma.category.findMany(); + return categories; } -export async function getCategoryBySlug(slug: string): Promise { - const category = await categoriesRepository.getCategoryBySlug(slug); +export async function getCategoryBySlug(slug: CategorySlug): Promise { + const category = await prisma.category.findUnique({ + where: { slug }, + }); if (!category) { throw new Error(`Category with slug "${slug}" not found`); From aad9d8bba644a0397e5272aba01a945f6eaf1591 Mon Sep 17 00:00:00 2001 From: Diego Torres Date: Tue, 24 Jun 2025 20:23:08 -0500 Subject: [PATCH 08/21] feat: remove category repository file as part of refactoring --- src/repositories/category.repository.ts | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 src/repositories/category.repository.ts diff --git a/src/repositories/category.repository.ts b/src/repositories/category.repository.ts deleted file mode 100644 index 6bdd153..0000000 --- a/src/repositories/category.repository.ts +++ /dev/null @@ -1,15 +0,0 @@ -import * as db from "@/db"; -import { type Category } from "@/models/category.model"; - -export async function getAllCategories(): Promise { - return await db.query("SELECT * FROM categories"); -} - -export async function getCategoryBySlug( - slug: string -): Promise { - return await db.queryOne( - "SELECT * FROM categories WHERE slug = $1", - [slug] - ); -} From 40e1f816ba3f5173a37ab2dd0b80c8b293d657d8 Mon Sep 17 00:00:00 2001 From: Diego Torres Date: Tue, 24 Jun 2025 20:33:27 -0500 Subject: [PATCH 09/21] fix: update import paths for Prisma client and related types to use client.js --- prisma/initial_data.ts | 2 +- prisma/schema.prisma | 2 +- prisma/seed.ts | 3 +-- src/db/prisma.ts | 2 +- src/services/category.service.ts | 2 +- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/prisma/initial_data.ts b/prisma/initial_data.ts index 333eab7..ecf0a7c 100644 --- a/prisma/initial_data.ts +++ b/prisma/initial_data.ts @@ -1,4 +1,4 @@ -import type { CategorySlug } from "generated/prisma"; +import type { CategorySlug } from "generated/prisma/client.js"; export const categories = [ { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 10062c8..4314c02 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5,7 +5,7 @@ // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init generator client { - provider = "prisma-client-js" + provider = "prisma-client" output = "../generated/prisma" } diff --git a/prisma/seed.ts b/prisma/seed.ts index a7de4e4..655d905 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,5 +1,5 @@ import { categories, products } from "./initial_data"; -import { PrismaClient } from "../generated/prisma"; +import { PrismaClient } from "../generated/prisma/client.js"; const prisma = new PrismaClient(); @@ -23,4 +23,3 @@ seedDb() console.log("--- Database seeded successfully. ---"); await prisma.$disconnect(); }); - diff --git a/src/db/prisma.ts b/src/db/prisma.ts index f887b70..7b8438f 100644 --- a/src/db/prisma.ts +++ b/src/db/prisma.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from "generated/prisma"; +import { PrismaClient } from "generated/prisma/client.js"; // Global variable to store the Prisma client instance declare global { diff --git a/src/services/category.service.ts b/src/services/category.service.ts index 09ad9a9..87d1c96 100644 --- a/src/services/category.service.ts +++ b/src/services/category.service.ts @@ -1,4 +1,4 @@ -import { type Category, type CategorySlug } from "generated/prisma"; +import { type Category, type CategorySlug } from "generated/prisma/client.js"; import { prisma } from "@/db/prisma"; From 96a4058f0413c10ba3e2cf2287a023e5c59d9656 Mon Sep 17 00:00:00 2001 From: mike Date: Wed, 25 Jun 2025 18:50:12 -0500 Subject: [PATCH 10/21] refactor: migrate product repository functions to use Prisma client --- src/lib/utils.tests.ts | 14 +++---- src/repositories/product.repository.ts | 18 --------- src/services/product.service.test.ts | 54 +++++++++++++++----------- src/services/product.service.ts | 20 ++++++---- 4 files changed, 51 insertions(+), 55 deletions(-) delete mode 100644 src/repositories/product.repository.ts diff --git a/src/lib/utils.tests.ts b/src/lib/utils.tests.ts index 827733f..cf59943 100644 --- a/src/lib/utils.tests.ts +++ b/src/lib/utils.tests.ts @@ -1,7 +1,7 @@ +import { Decimal } from "@prisma/client/runtime/library"; +import { type Category, type Product } from "generated/prisma/client.js"; import { vi } from "vitest"; -import type { Category } from "@/models/category.model"; -import type { Product } from "@/models/product.model"; import type { User } from "@/models/user.model"; import type { Session } from "react-router"; @@ -50,13 +50,13 @@ export const createTestProduct = (overrides?: Partial): Product => ({ title: "Test Product", imgSrc: "/test-image.jpg", alt: "Test alt text", - price: 100, + price: new Decimal("100"), description: "Test description", categoryId: 1, isOnSale: false, features: ["Feature 1", "Feature 2"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: new Date(), + updatedAt: new Date(), ...overrides, }); @@ -69,7 +69,7 @@ export const createTestCategory = ( imgSrc: "/images/polos.jpg", alt: "Colección de polos para programadores", description: "Explora nuestra colección de polos para programadores", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: new Date(), + updatedAt: new Date(), ...overrides, }); diff --git a/src/repositories/product.repository.ts b/src/repositories/product.repository.ts deleted file mode 100644 index efab174..0000000 --- a/src/repositories/product.repository.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as db from "@/db"; -import { type Product } from "@/models/product.model"; - -export async function getAllProducts(): Promise { - return await db.query("SELECT * FROM products"); -} - -export async function getProductById(id: number): Promise { - const query = "SELECT * FROM products WHERE id = $1"; - return await db.queryOne(query, [id]); -} - -export async function getProductsByCategory( - categoryId: number -): Promise { - const query = "SELECT * FROM products WHERE category_id = $1"; - return await db.query(query, [categoryId]); -} diff --git a/src/services/product.service.test.ts b/src/services/product.service.test.ts index ea57513..65bc200 100644 --- a/src/services/product.service.test.ts +++ b/src/services/product.service.test.ts @@ -1,15 +1,24 @@ +import { type Category, type Product } from "generated/prisma/client.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createTestCategory, createTestProduct } from "@/lib/utils.tests"; -import type { Category } from "@/models/category.model"; -import type { Product } from "@/models/product.model"; -import * as productRepository from "@/repositories/product.repository"; import { getCategoryBySlug } from "./category.service"; import { getProductById, getProductsByCategorySlug } from "./product.service"; -// Mock dependencies -vi.mock("@/repositories/product.repository"); +// Mock Prisma client +const mockPrisma = { + product: { + findMany: vi.fn(), + findUnique: vi.fn(), + }, +}; + +vi.mock("@/db/prisma", () => ({ + prisma: mockPrisma, +})); + +// Mock category service vi.mock("./category.service"); describe("Product Service", () => { @@ -30,20 +39,18 @@ describe("Product Service", () => { }), ]; - // Step 2: Mock - Configure repository responses + // Step 2: Mock - Configure responses vi.mocked(getCategoryBySlug).mockResolvedValue(testCategory); - vi.mocked(productRepository.getProductsByCategory).mockResolvedValue( - mockedProducts - ); + mockPrisma.product.findMany.mockResolvedValue(mockedProducts); // Step 3: Call service function const products = await getProductsByCategorySlug(testCategory.slug); // Step 4: Verify expected behavior expect(getCategoryBySlug).toHaveBeenCalledWith(testCategory.slug); - expect(productRepository.getProductsByCategory).toHaveBeenCalledWith( - testCategory.id - ); + expect(mockPrisma.product.findMany).toHaveBeenCalledWith({ + where: { categoryId: testCategory.id }, + }); expect(products).toEqual(mockedProducts); }); @@ -62,7 +69,7 @@ describe("Product Service", () => { // Step 4: Verify expected behavior await expect(getProducts).rejects.toThrow(errorMessage); - expect(productRepository.getProductsByCategory).not.toHaveBeenCalled(); + expect(mockPrisma.product.findMany).not.toHaveBeenCalled(); }); }); @@ -71,18 +78,16 @@ describe("Product Service", () => { // Step 1: Setup - Create test data for existing product const testProduct = createTestProduct(); - // Step 2: Mock - Configure repository response - vi.mocked(productRepository.getProductById).mockResolvedValue( - testProduct - ); + // Step 2: Mock - Configure Prisma response + mockPrisma.product.findUnique.mockResolvedValue(testProduct); // Step 3: Call service function const result = await getProductById(testProduct.id); // Step 4: Verify expected behavior - expect(productRepository.getProductById).toHaveBeenCalledWith( - testProduct.id - ); + expect(mockPrisma.product.findUnique).toHaveBeenCalledWith({ + where: { id: testProduct.id }, + }); expect(result).toEqual(testProduct); }); @@ -90,14 +95,17 @@ describe("Product Service", () => { // Step 1: Setup - Configure ID for non-existent product const nonExistentId = 999; - // Step 2: Mock - Configure null response from repository - vi.mocked(productRepository.getProductById).mockResolvedValue(null); + // Step 2: Mock - Configure null response from Prisma + mockPrisma.product.findUnique.mockResolvedValue(null); // Step 3: Call service function const productPromise = getProductById(nonExistentId); // Step 4: Verify expected behavior await expect(productPromise).rejects.toThrow("Product not found"); + expect(mockPrisma.product.findUnique).toHaveBeenCalledWith({ + where: { id: nonExistentId }, + }); }); }); -}); +}); \ No newline at end of file diff --git a/src/services/product.service.ts b/src/services/product.service.ts index 0593f26..0e6aa79 100644 --- a/src/services/product.service.ts +++ b/src/services/product.service.ts @@ -1,6 +1,6 @@ -import { type Category } from "@/models/category.model"; -import { type Product } from "@/models/product.model"; -import * as productRepository from "@/repositories/product.repository"; +import { type Category, type Product } from "generated/prisma/client.js"; + +import { prisma } from "@/db/prisma"; import { getCategoryBySlug } from "./category.service"; @@ -8,15 +8,17 @@ export async function getProductsByCategorySlug( categorySlug: Category["slug"] ): Promise { const category = await getCategoryBySlug(categorySlug); - const products = await productRepository.getProductsByCategory( - Number(category.id) - ); + const products = await prisma.product.findMany({ + where: { categoryId: category.id }, + }); return products; } export async function getProductById(id: number): Promise { - const product = await productRepository.getProductById(id); + const product = await prisma.product.findUnique({ + where: { id }, + }); if (!product) { throw new Error("Product not found"); @@ -24,3 +26,7 @@ export async function getProductById(id: number): Promise { return product; } + +export async function getAllProducts(): Promise { // No la utilizamos en repository. + return await prisma.product.findMany(); +} From 0d95eaa073c31361a32f6308eae74f9a516d077e Mon Sep 17 00:00:00 2001 From: Diego Torres Date: Wed, 25 Jun 2025 21:05:07 -0500 Subject: [PATCH 11/21] fix: update model imports and adjust product price handling --- src/lib/utils.tests.ts | 6 ++-- src/models/category.model.ts | 13 ++------ src/models/product.model.ts | 32 +++++++++++-------- .../components/product-card/index.tsx | 2 +- src/routes/category/index.tsx | 4 +-- src/routes/checkout/index.tsx | 2 ++ src/routes/home/components/categories.tsx | 2 +- src/services/product.service.test.ts | 5 +-- src/services/product.service.ts | 19 +++++++---- 9 files changed, 47 insertions(+), 38 deletions(-) diff --git a/src/lib/utils.tests.ts b/src/lib/utils.tests.ts index cf59943..1c66ecc 100644 --- a/src/lib/utils.tests.ts +++ b/src/lib/utils.tests.ts @@ -1,7 +1,7 @@ -import { Decimal } from "@prisma/client/runtime/library"; -import { type Category, type Product } from "generated/prisma/client.js"; import { vi } from "vitest"; +import type { Category } from "@/models/category.model"; +import type { Product } from "@/models/product.model"; import type { User } from "@/models/user.model"; import type { Session } from "react-router"; @@ -50,7 +50,7 @@ export const createTestProduct = (overrides?: Partial): Product => ({ title: "Test Product", imgSrc: "/test-image.jpg", alt: "Test alt text", - price: new Decimal("100"), + price: 100, description: "Test description", categoryId: 1, isOnSale: false, diff --git a/src/models/category.model.ts b/src/models/category.model.ts index 57b467f..594575e 100644 --- a/src/models/category.model.ts +++ b/src/models/category.model.ts @@ -1,15 +1,8 @@ +import type { Category as PrismaCategory } from "generated/prisma/client"; + export const VALID_SLUGS = ["polos", "stickers", "tazas"] as const; -export interface Category { - id: number; - title: string; - slug: (typeof VALID_SLUGS)[number]; - imgSrc: string; - alt: string | null; - description: string | null; - createdAt: string; - updatedAt: string; -} +export type Category = PrismaCategory; export function isValidCategorySlug( categorySlug: unknown diff --git a/src/models/product.model.ts b/src/models/product.model.ts index c42f14d..2f56d20 100644 --- a/src/models/product.model.ts +++ b/src/models/product.model.ts @@ -1,16 +1,22 @@ // import { type Category } from "./category.model"; -export interface Product { - id: number; - title: string; - imgSrc: string; - alt: string | null; +import type { Product as PrismaProduct } from "generated/prisma/client"; + +// export interface Product { +// id: number; +// title: string; +// imgSrc: string; +// alt: string | null; +// price: number; +// description: string | null; +// categoryId: number; +// // categorySlug: Category["slug"]; +// isOnSale: boolean; +// features: string[]; +// createdAt: string; +// updatedAt: string; +// } + +export type Product = Omit & { price: number; - description: string | null; - categoryId: number; - // categorySlug: Category["slug"]; - isOnSale: boolean; - features: string[]; - createdAt: string; - updatedAt: string; -} +}; diff --git a/src/routes/category/components/product-card/index.tsx b/src/routes/category/components/product-card/index.tsx index 8dbd0b0..2f245ca 100644 --- a/src/routes/category/components/product-card/index.tsx +++ b/src/routes/category/components/product-card/index.tsx @@ -1,6 +1,6 @@ import { Link } from "react-router"; -import { type Product } from "@/models/product.model"; +import type { Product } from "@/models/product.model"; interface ProductCardProps { product: Product; diff --git a/src/routes/category/index.tsx b/src/routes/category/index.tsx index 913b7a5..7c0aef5 100644 --- a/src/routes/category/index.tsx +++ b/src/routes/category/index.tsx @@ -2,7 +2,7 @@ import { redirect } from "react-router"; import { Container } from "@/components/ui"; import { isValidCategorySlug, type Category } from "@/models/category.model"; -import { type Product } from "@/models/product.model"; +import type { Product } from "@/models/product.model"; import { getCategoryBySlug } from "@/services/category.service"; import { getProductsByCategorySlug } from "@/services/product.service"; @@ -32,7 +32,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { products: Product[], minPrice: string, maxPrice: string - ): Product[] => { + ) => { const min = minPrice ? parseFloat(minPrice) : 0; const max = maxPrice ? parseFloat(maxPrice) : Infinity; return products.filter( diff --git a/src/routes/checkout/index.tsx b/src/routes/checkout/index.tsx index fa4fd6e..2e67d52 100644 --- a/src/routes/checkout/index.tsx +++ b/src/routes/checkout/index.tsx @@ -77,6 +77,8 @@ export async function action({ request }: Route.ActionArgs) { imgSrc: item.product.imgSrc, })); + // TODO + // @ts-expect-error Arreglar el tipo de shippingDetails const { id: orderId } = await createOrder(items, shippingDetails); await deleteRemoteCart(request); diff --git a/src/routes/home/components/categories.tsx b/src/routes/home/components/categories.tsx index 5d0f175..1782b1f 100644 --- a/src/routes/home/components/categories.tsx +++ b/src/routes/home/components/categories.tsx @@ -15,7 +15,7 @@ export function Categories({ categories }: CategoriesProps) { >
{category.alt diff --git a/src/services/product.service.test.ts b/src/services/product.service.test.ts index 65bc200..721b428 100644 --- a/src/services/product.service.test.ts +++ b/src/services/product.service.test.ts @@ -1,7 +1,8 @@ -import { type Category, type Product } from "generated/prisma/client.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createTestCategory, createTestProduct } from "@/lib/utils.tests"; +import type { Category } from "@/models/category.model"; +import type { Product } from "@/models/product.model"; import { getCategoryBySlug } from "./category.service"; import { getProductById, getProductsByCategorySlug } from "./product.service"; @@ -108,4 +109,4 @@ describe("Product Service", () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/src/services/product.service.ts b/src/services/product.service.ts index 0e6aa79..43ce73f 100644 --- a/src/services/product.service.ts +++ b/src/services/product.service.ts @@ -1,6 +1,6 @@ -import { type Category, type Product } from "generated/prisma/client.js"; - import { prisma } from "@/db/prisma"; +import type { Category } from "@/models/category.model"; +import type { Product } from "@/models/product.model"; import { getCategoryBySlug } from "./category.service"; @@ -12,7 +12,10 @@ export async function getProductsByCategorySlug( where: { categoryId: category.id }, }); - return products; + return products.map((product) => ({ + ...product, + price: product.price.toNumber(), + })); } export async function getProductById(id: number): Promise { @@ -24,9 +27,13 @@ export async function getProductById(id: number): Promise { throw new Error("Product not found"); } - return product; + return { ...product, price: product.price.toNumber() }; } -export async function getAllProducts(): Promise { // No la utilizamos en repository. - return await prisma.product.findMany(); +export async function getAllProducts(): Promise { + // No la utilizamos en repository. + return (await prisma.product.findMany()).map((p) => ({ + ...p, + price: p.price.toNumber(), + })); } From 89dcc2d37e4ff310a6d3283fef433716a220ceaa Mon Sep 17 00:00:00 2001 From: Angel Date: Thu, 26 Jun 2025 17:37:05 -0500 Subject: [PATCH 12/21] feat: implement order management with Prisma integration and update test utilities --- src/lib/utils.tests.ts | 56 ++++++++ src/models/order.model.ts | 52 ++++---- src/repositories/order.repository.ts | 125 ----------------- src/services/order.service.test.ts | 193 +++++++++++++++++---------- src/services/order.service.ts | 120 +++++++++++++++-- 5 files changed, 318 insertions(+), 228 deletions(-) delete mode 100644 src/repositories/order.repository.ts diff --git a/src/lib/utils.tests.ts b/src/lib/utils.tests.ts index 1c66ecc..ed5bfdb 100644 --- a/src/lib/utils.tests.ts +++ b/src/lib/utils.tests.ts @@ -1,6 +1,7 @@ import { vi } from "vitest"; import type { Category } from "@/models/category.model"; +import type { Order, OrderDetails, OrderItem } from "@/models/order.model"; import type { Product } from "@/models/product.model"; import type { User } from "@/models/user.model"; @@ -73,3 +74,58 @@ export const createTestCategory = ( updatedAt: new Date(), ...overrides, }); + +export const createTestOrderDetails = ( + overrides?: Partial +): OrderDetails => ({ + email: "test@test.com", + firstName: "Test", + lastName: "User", + company: null, + address: "Test Address", + city: "Test City", + country: "Test Country", + region: "Test Region", + zip: "12345", + phone: "123456789", + ...overrides, +}); + +export const createTestOrderItem = ( + overrides?: Partial +): OrderItem => ({ + id: 1, + orderId: 1, + productId: 1, + quantity: 1, + title: "Test Product", + price: 100, + imgSrc: "test-image.jpg", + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}); + +export const createTestOrder = (overrides?: Partial): Order => { + const details = createTestOrderDetails(); + return { + id: 1, + userId: 1, + totalAmount: 100, + items: [createTestOrderItem()], + details, + createdAt: new Date(), + updatedAt: new Date(), + email: details.email, + firstName: details.firstName, + lastName: details.lastName, + company: details.company, + address: details.address, + city: details.city, + country: details.country, + region: details.region, + zip: details.zip, + phone: details.phone, + ...overrides, + }; +}; diff --git a/src/models/order.model.ts b/src/models/order.model.ts index 99349c4..144b72a 100644 --- a/src/models/order.model.ts +++ b/src/models/order.model.ts @@ -1,6 +1,30 @@ -import { type User } from "./user.model"; +import type { + Order as PrismaOrder, + OrderItem as PrismaOrderItem, +} from "generated/prisma/client"; -export interface OrderDetails { +export type OrderDetails = Pick< + PrismaOrder, + | "email" + | "firstName" + | "lastName" + | "company" + | "address" + | "city" + | "country" + | "region" + | "zip" + | "phone" +>; + +export type OrderItem = Omit & { + price: number; +}; + +export type Order = Omit & { + items: OrderItem[]; + totalAmount: number; + details: OrderDetails; email: string; firstName: string; lastName: string; @@ -11,29 +35,7 @@ export interface OrderDetails { region: string; zip: string; phone: string; -} - -export interface OrderItem { - id: number; - orderId: number; - productId: number; - quantity: number; - title: string; - price: number; - imgSrc: string; - createdAt: string; - updatedAt: string; -} - -export interface Order { - id: number; - userId: User["id"]; - items: OrderItem[]; - totalAmount: number; - details: OrderDetails; - createdAt: string; - updatedAt: string; -} +}; export interface OrderItemInput { productId: number; diff --git a/src/repositories/order.repository.ts b/src/repositories/order.repository.ts deleted file mode 100644 index 76ed64b..0000000 --- a/src/repositories/order.repository.ts +++ /dev/null @@ -1,125 +0,0 @@ -import * as db from "@/db"; -import { type Order } from "@/models/order.model"; -import { - type OrderItemInput, - type OrderDetails as ShippingDetails, -} from "@/models/order.model"; - -export async function createOrderWithItems( - userId: number | undefined, - items: OrderItemInput[], - shippingDetails: ShippingDetails, - totalAmount: number -): Promise { - // Use a transaction to ensure data consistency - const client = await db.getClient(); - - try { - await client.query("BEGIN"); - - // Create order - const orderResult = await client.query( - `INSERT INTO orders ( - user_id, total_amount, email, first_name, last_name, company, - address, city, country, region, zip, phone - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - RETURNING *`, - [ - userId, - totalAmount, - shippingDetails.email, - shippingDetails.firstName, - shippingDetails.lastName, - shippingDetails.company, - shippingDetails.address, - shippingDetails.city, - shippingDetails.country, - shippingDetails.region, - shippingDetails.zip, - shippingDetails.phone, - ] - ); - - const order = orderResult.rows[0]; - - // Create order items - for (const item of items) { - await client.query( - `INSERT INTO order_items ( - order_id, product_id, quantity, title, price, img_src - ) VALUES ($1, $2, $3, $4, $5, $6)`, - [ - order.id, - item.productId, - item.quantity, - item.title, - item.price, - item.imgSrc, - ] - ); - } - - await client.query("COMMIT"); - return getOrderById(order.id); // Get complete order with items - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } -} - -export async function getOrdersByUserId(userId: number): Promise { - const query = ` - SELECT - o.*, - COALESCE( - json_agg( - CASE WHEN oi.id IS NOT NULL THEN - json_build_object( - 'id', oi.id, - 'order_id', oi.order_id, - 'product_id', oi.product_id, - 'quantity', oi.quantity, - 'title', oi.title, - 'price', oi.price, - 'img_src', oi.img_src, - 'created_at', oi.created_at, - 'updated_at', oi.updated_at - ) - ELSE NULL END - ) FILTER (WHERE oi.id IS NOT NULL), - '[]' - ) as items - FROM orders o - LEFT JOIN order_items oi ON o.id = oi.order_id - WHERE o.user_id = $1 - GROUP BY o.id - ORDER BY o.created_at DESC - `; - - return await db.query(query, [userId]); -} - -async function getOrderById(orderId: number): Promise { - const query = ` - SELECT - o.*, - json_agg( - json_build_object( - 'id', oi.id, - 'productId', oi.product_id, - 'quantity', oi.quantity, - 'title', oi.title, - 'price', oi.price, - 'imgSrc', oi.img_src - ) - ) as items - FROM orders o - LEFT JOIN order_items oi ON o.id = oi.order_id - WHERE o.id = $1 - GROUP BY o.id - `; - - return await db.queryOne(query, [orderId]); -} diff --git a/src/services/order.service.test.ts b/src/services/order.service.test.ts index 28e99ff..3cbce66 100644 --- a/src/services/order.service.test.ts +++ b/src/services/order.service.test.ts @@ -3,17 +3,30 @@ import { describe, expect, it, vi } from "vitest"; import { calculateTotal } from "@/lib/cart"; import { createMockSession, + createTestOrder, + createTestOrderDetails, + createTestOrderItem, createTestRequest, createTestUser, } from "@/lib/utils.tests"; import type { CartItemInput } from "@/models/cart.model"; -import type { Order, OrderDetails } from "@/models/order.model"; -import * as orderRepository from "@/repositories/order.repository"; import { getSession } from "@/session.server"; import { createOrder, getOrdersByUser } from "./order.service"; import { getOrCreateUser } from "./user.service"; +// Mock Prisma client +const mockPrisma = { + order: { + create: vi.fn(), + findMany: vi.fn(), + }, +}; + +vi.mock("@/db/prisma", () => ({ + prisma: mockPrisma, +})); + vi.mock("./user.service"); vi.mock("@/lib/cart"); vi.mock("@/repositories/order.repository"); @@ -37,85 +50,134 @@ describe("Order Service", () => { }, ]; - const mockedFormData: OrderDetails = { - email: "test@test.com", - firstName: "", - lastName: "", - company: null, - address: "", - city: "", - country: "", - region: "", - zip: "", - phone: "", - }; - + const mockedFormData = createTestOrderDetails(); const mockedUser = createTestUser(); - - const mockedOrder: Order = { - createdAt: "", - id: 1, - items: [ - { - ...mockedItems[0], - id: 2, - orderId: 1, - createdAt: "", - updatedAt: "", - }, - { - ...mockedItems[1], - id: 1, - orderId: 1, - createdAt: "", - updatedAt: "", - }, - ], - totalAmount: 200, - userId: 1, - updatedAt: "", - details: mockedFormData, - }; - + const mockedOrder = createTestOrder(); const mockedTotalAmount = 200; - const mockedRequest = createTestRequest(); it("should create an order", async () => { + const prismaOrder = { + ...createTestOrder(), + items: [createTestOrderItem()], + }; + vi.mocked(getOrCreateUser).mockResolvedValue(mockedUser); vi.mocked(calculateTotal).mockReturnValue(mockedTotalAmount); - vi.mocked(orderRepository.createOrderWithItems).mockResolvedValue( - mockedOrder - ); + mockPrisma.order.create.mockResolvedValue(prismaOrder); const order = await createOrder(mockedItems, mockedFormData); - expect(orderRepository.createOrderWithItems).toBeCalledWith( - mockedUser.id, - mockedItems, - mockedFormData, - mockedTotalAmount - ); - expect(order).toEqual(mockedOrder); + expect(mockPrisma.order.create).toHaveBeenCalledWith({ + data: { + userId: mockedUser.id, + totalAmount: mockedTotalAmount, + email: mockedFormData.email, + firstName: mockedFormData.firstName, + lastName: mockedFormData.lastName, + company: mockedFormData.company, + address: mockedFormData.address, + city: mockedFormData.city, + country: mockedFormData.country, + region: mockedFormData.region, + zip: mockedFormData.zip, + phone: mockedFormData.phone, + items: { + create: mockedItems.map((item) => ({ + productId: item.productId, + quantity: item.quantity, + title: item.title, + price: item.price, + imgSrc: item.imgSrc, + })), + }, + }, + include: { + items: true, + }, + }); + + expect(order).toEqual({ + ...prismaOrder, + totalAmount: Number(prismaOrder.totalAmount), + items: prismaOrder.items.map((item: any) => ({ + ...item, + price: Number(item.price), + imgSrc: item.imgSrc ?? "", + productId: item.productId ?? 0, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + })), + createdAt: prismaOrder.createdAt, + updatedAt: prismaOrder.updatedAt, + details: { + email: prismaOrder.email, + firstName: prismaOrder.firstName, + lastName: prismaOrder.lastName, + company: prismaOrder.company, + address: prismaOrder.address, + city: prismaOrder.city, + country: prismaOrder.country, + region: prismaOrder.region, + zip: prismaOrder.zip, + phone: prismaOrder.phone, + }, + }); }); it("should get orders by user", async () => { - const mockedOrders = [mockedOrder, { ...mockedOrder, id: 3 }]; - const mockedSession = createMockSession(mockedUser.id); // Simulate updated user ID in session + const prismaOrders = [ + { ...createTestOrder(), items: [createTestOrderItem()] }, + { + ...createTestOrder({ id: 2 }), + items: [createTestOrderItem({ id: 2 })], + }, + ]; + const mockedSession = createMockSession(mockedUser.id); vi.mocked(getSession).mockResolvedValue(mockedSession); - vi.mocked(orderRepository.getOrdersByUserId).mockResolvedValue( - mockedOrders - ); + mockPrisma.order.findMany.mockResolvedValue(prismaOrders); const orders = await getOrdersByUser(mockedRequest); - expect(orderRepository.getOrdersByUserId).toBeCalledWith(mockedUser.id); - expect(orders).toEqual(mockedOrders); + expect(mockPrisma.order.findMany).toHaveBeenCalledWith({ + where: { userId: mockedUser.id }, + include: { items: true }, + orderBy: { createdAt: "desc" }, + }); + + expect(orders).toEqual( + prismaOrders.map((order) => ({ + ...order, + totalAmount: Number(order.totalAmount), + items: order.items.map((item: any) => ({ + ...item, + price: Number(item.price), + imgSrc: item.imgSrc ?? "", + productId: item.productId ?? 0, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + })), + createdAt: order.createdAt, + updatedAt: order.updatedAt, + details: { + email: order.email, + firstName: order.firstName, + lastName: order.lastName, + company: order.company, + address: order.address, + city: order.city, + country: order.country, + region: order.region, + zip: order.zip, + phone: order.phone, + }, + })) + ); }); it("should throw error if user is not authenticated", async () => { - const mockedSession = createMockSession(null); // Simulate updated user ID in session + const mockedSession = createMockSession(null); vi.mocked(getSession).mockResolvedValue(mockedSession); @@ -126,20 +188,15 @@ describe("Order Service", () => { expect(getSession).toHaveBeenCalledWith("session=mock-session-id"); }); - it("should throw error if order is null", async () => { + it("should throw error if order creation fails", async () => { vi.mocked(getOrCreateUser).mockResolvedValue(mockedUser); vi.mocked(calculateTotal).mockReturnValue(mockedTotalAmount); - vi.mocked(orderRepository.createOrderWithItems).mockResolvedValue(null); + mockPrisma.order.create.mockResolvedValue(null); await expect(createOrder(mockedItems, mockedFormData)).rejects.toThrow( "Failed to create order" ); - expect(orderRepository.createOrderWithItems).toBeCalledWith( - mockedUser.id, - mockedItems, - mockedFormData, - mockedTotalAmount - ); + expect(mockPrisma.order.create).toHaveBeenCalled(); }); }); diff --git a/src/services/order.service.ts b/src/services/order.service.ts index 54e365a..6fea8ba 100644 --- a/src/services/order.service.ts +++ b/src/services/order.service.ts @@ -1,7 +1,7 @@ +import { prisma } from "@/db/prisma"; import { calculateTotal } from "@/lib/cart"; import { type CartItemInput } from "@/models/cart.model"; import { type Order, type OrderDetails } from "@/models/order.model"; -import * as orderRepository from "@/repositories/order.repository"; import { getSession } from "@/session.server"; import { getOrCreateUser } from "./user.service"; @@ -15,16 +15,73 @@ export async function createOrder( const user = await getOrCreateUser(shippingDetails.email); const totalAmount = calculateTotal(items); - const order = await orderRepository.createOrderWithItems( - user.id, - items, - shippingDetails, - totalAmount - ); + const order = await prisma.order.create({ + data: { + userId: user.id, + totalAmount: totalAmount, + email: shippingDetails.email, + firstName: shippingDetails.firstName, + lastName: shippingDetails.lastName, + company: shippingDetails.company, + address: shippingDetails.address, + city: shippingDetails.city, + country: shippingDetails.country, + region: shippingDetails.region, + zip: shippingDetails.zip, + phone: shippingDetails.phone, + items: { + create: items.map((item) => ({ + productId: item.productId, + quantity: item.quantity, + title: item.title, + price: item.price, + imgSrc: item.imgSrc, + })), + }, + }, + include: { + items: true, + }, + }); if (!order) throw new Error("Failed to create order"); - return order; + return { + ...order, + totalAmount: Number(order.totalAmount), + items: order.items.map((item) => ({ + ...item, + price: Number(item.price), + imgSrc: item.imgSrc ?? "", + productId: item.productId ?? 0, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + })), + createdAt: order.createdAt, + updatedAt: order.updatedAt, + details: { + email: order.email, + firstName: order.firstName, + lastName: order.lastName, + company: order.company, + address: order.address, + city: order.city, + country: order.country, + region: order.region, + zip: order.zip, + phone: order.phone, + }, + email: order.email, + firstName: order.firstName, + lastName: order.lastName, + company: order.company, + address: order.address, + city: order.city, + country: order.country, + region: order.region, + zip: order.zip, + phone: order.phone, + } as Order; } export async function getOrdersByUser(request: Request): Promise { @@ -35,7 +92,50 @@ export async function getOrdersByUser(request: Request): Promise { throw new Error("User not authenticated"); } - const orders = await orderRepository.getOrdersByUserId(userId); + const orders = await prisma.order.findMany({ + where: { userId }, + include: { + items: true, + }, + orderBy: { + createdAt: "desc", + }, + }); - return orders; + return orders.map((order) => ({ + ...order, + totalAmount: Number(order.totalAmount), + items: order.items.map((item) => ({ + ...item, + price: Number(item.price), + imgSrc: item.imgSrc ?? "", + productId: item.productId ?? 0, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + })), + createdAt: order.createdAt, + updatedAt: order.updatedAt, + details: { + email: order.email, + firstName: order.firstName, + lastName: order.lastName, + company: order.company, + address: order.address, + city: order.city, + country: order.country, + region: order.region, + zip: order.zip, + phone: order.phone, + }, + email: order.email, + firstName: order.firstName, + lastName: order.lastName, + company: order.company, + address: order.address, + city: order.city, + country: order.country, + region: order.region, + zip: order.zip, + phone: order.phone, + })) as Order[]; } From cdce7bc0ddbe753550b52f24e90db3b0053829bb Mon Sep 17 00:00:00 2001 From: Angel Date: Fri, 27 Jun 2025 15:29:04 -0500 Subject: [PATCH 13/21] refactor: simplify order creation and details handling in order service --- src/lib/utils.tests.ts | 46 ++++++------- src/models/order.model.ts | 10 --- src/services/order.service.test.ts | 9 --- src/services/order.service.ts | 104 ++++++++++------------------- 4 files changed, 55 insertions(+), 114 deletions(-) diff --git a/src/lib/utils.tests.ts b/src/lib/utils.tests.ts index ed5bfdb..998deb0 100644 --- a/src/lib/utils.tests.ts +++ b/src/lib/utils.tests.ts @@ -76,7 +76,7 @@ export const createTestCategory = ( }); export const createTestOrderDetails = ( - overrides?: Partial + overrides: Partial = {} ): OrderDetails => ({ email: "test@test.com", firstName: "Test", @@ -92,22 +92,23 @@ export const createTestOrderDetails = ( }); export const createTestOrderItem = ( - overrides?: Partial -): OrderItem => ({ - id: 1, - orderId: 1, - productId: 1, - quantity: 1, - title: "Test Product", - price: 100, - imgSrc: "test-image.jpg", - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, -}); + overrides: Partial = {} +): OrderItem => + ({ + id: 1, + orderId: 1, + productId: 1, + quantity: 1, + title: "Test Product", + price: 100, + imgSrc: "test-image.jpg", + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } satisfies OrderItem); -export const createTestOrder = (overrides?: Partial): Order => { - const details = createTestOrderDetails(); +export const createTestOrder = (overrides: Partial = {}): Order => { + const details = overrides.details ?? createTestOrderDetails(); return { id: 1, userId: 1, @@ -116,16 +117,7 @@ export const createTestOrder = (overrides?: Partial): Order => { details, createdAt: new Date(), updatedAt: new Date(), - email: details.email, - firstName: details.firstName, - lastName: details.lastName, - company: details.company, - address: details.address, - city: details.city, - country: details.country, - region: details.region, - zip: details.zip, - phone: details.phone, + ...details, // Expande todos los campos de contacto sin undefined ...overrides, - }; + } satisfies Order; }; diff --git a/src/models/order.model.ts b/src/models/order.model.ts index 144b72a..d2d5c40 100644 --- a/src/models/order.model.ts +++ b/src/models/order.model.ts @@ -25,16 +25,6 @@ export type Order = Omit & { items: OrderItem[]; totalAmount: number; details: OrderDetails; - email: string; - firstName: string; - lastName: string; - company: string | null; - address: string; - city: string; - country: string; - region: string; - zip: string; - phone: string; }; export interface OrderItemInput { diff --git a/src/services/order.service.test.ts b/src/services/order.service.test.ts index 3cbce66..35e9ffe 100644 --- a/src/services/order.service.test.ts +++ b/src/services/order.service.test.ts @@ -52,7 +52,6 @@ describe("Order Service", () => { const mockedFormData = createTestOrderDetails(); const mockedUser = createTestUser(); - const mockedOrder = createTestOrder(); const mockedTotalAmount = 200; const mockedRequest = createTestRequest(); @@ -61,13 +60,10 @@ describe("Order Service", () => { ...createTestOrder(), items: [createTestOrderItem()], }; - vi.mocked(getOrCreateUser).mockResolvedValue(mockedUser); vi.mocked(calculateTotal).mockReturnValue(mockedTotalAmount); mockPrisma.order.create.mockResolvedValue(prismaOrder); - const order = await createOrder(mockedItems, mockedFormData); - expect(mockPrisma.order.create).toHaveBeenCalledWith({ data: { userId: mockedUser.id, @@ -96,7 +92,6 @@ describe("Order Service", () => { items: true, }, }); - expect(order).toEqual({ ...prismaOrder, totalAmount: Number(prismaOrder.totalAmount), @@ -134,18 +129,14 @@ describe("Order Service", () => { }, ]; const mockedSession = createMockSession(mockedUser.id); - vi.mocked(getSession).mockResolvedValue(mockedSession); mockPrisma.order.findMany.mockResolvedValue(prismaOrders); - const orders = await getOrdersByUser(mockedRequest); - expect(mockPrisma.order.findMany).toHaveBeenCalledWith({ where: { userId: mockedUser.id }, include: { items: true }, orderBy: { createdAt: "desc" }, }); - expect(orders).toEqual( prismaOrders.map((order) => ({ ...order, diff --git a/src/services/order.service.ts b/src/services/order.service.ts index 6fea8ba..99f7623 100644 --- a/src/services/order.service.ts +++ b/src/services/order.service.ts @@ -11,24 +11,13 @@ export async function createOrder( formData: OrderDetails ): Promise { const shippingDetails = formData; - const user = await getOrCreateUser(shippingDetails.email); const totalAmount = calculateTotal(items); - const order = await prisma.order.create({ data: { userId: user.id, totalAmount: totalAmount, - email: shippingDetails.email, - firstName: shippingDetails.firstName, - lastName: shippingDetails.lastName, - company: shippingDetails.company, - address: shippingDetails.address, - city: shippingDetails.city, - country: shippingDetails.country, - region: shippingDetails.region, - zip: shippingDetails.zip, - phone: shippingDetails.phone, + ...shippingDetails, items: { create: items.map((item) => ({ productId: item.productId, @@ -43,55 +32,43 @@ export async function createOrder( items: true, }, }); - if (!order) throw new Error("Failed to create order"); - + const details = { + email: order.email, + firstName: order.firstName, + lastName: order.lastName, + company: order.company, + address: order.address, + city: order.city, + country: order.country, + region: order.region, + zip: order.zip, + phone: order.phone, + }; return { ...order, totalAmount: Number(order.totalAmount), items: order.items.map((item) => ({ ...item, price: Number(item.price), - imgSrc: item.imgSrc ?? "", - productId: item.productId ?? 0, + imgSrc: item.imgSrc, + productId: item.productId, createdAt: item.createdAt, updatedAt: item.updatedAt, })), createdAt: order.createdAt, updatedAt: order.updatedAt, - details: { - email: order.email, - firstName: order.firstName, - lastName: order.lastName, - company: order.company, - address: order.address, - city: order.city, - country: order.country, - region: order.region, - zip: order.zip, - phone: order.phone, - }, - email: order.email, - firstName: order.firstName, - lastName: order.lastName, - company: order.company, - address: order.address, - city: order.city, - country: order.country, - region: order.region, - zip: order.zip, - phone: order.phone, - } as Order; + details, + ...details, + }; } export async function getOrdersByUser(request: Request): Promise { const session = await getSession(request.headers.get("Cookie")); const userId = session.get("userId"); - if (!userId) { throw new Error("User not authenticated"); } - const orders = await prisma.order.findMany({ where: { userId }, include: { @@ -101,21 +78,8 @@ export async function getOrdersByUser(request: Request): Promise { createdAt: "desc", }, }); - - return orders.map((order) => ({ - ...order, - totalAmount: Number(order.totalAmount), - items: order.items.map((item) => ({ - ...item, - price: Number(item.price), - imgSrc: item.imgSrc ?? "", - productId: item.productId ?? 0, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - })), - createdAt: order.createdAt, - updatedAt: order.updatedAt, - details: { + return orders.map((order) => { + const details = { email: order.email, firstName: order.firstName, lastName: order.lastName, @@ -126,16 +90,20 @@ export async function getOrdersByUser(request: Request): Promise { region: order.region, zip: order.zip, phone: order.phone, - }, - email: order.email, - firstName: order.firstName, - lastName: order.lastName, - company: order.company, - address: order.address, - city: order.city, - country: order.country, - region: order.region, - zip: order.zip, - phone: order.phone, - })) as Order[]; + }; + return { + ...order, + totalAmount: Number(order.totalAmount), + items: order.items.map((item) => ({ + ...item, + price: Number(item.price), + createdAt: item.createdAt, + updatedAt: item.updatedAt, + })), + createdAt: order.createdAt, + updatedAt: order.updatedAt, + details, + ...details, + }; + }); } From 0b018cb4b98c855baca13296939ec0394d1cd35d Mon Sep 17 00:00:00 2001 From: Diego Torres Date: Fri, 27 Jun 2025 19:27:12 -0500 Subject: [PATCH 14/21] refactor: optimize order sorting and simplify order mapping in orders component --- src/routes/account/orders/index.tsx | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/routes/account/orders/index.tsx b/src/routes/account/orders/index.tsx index 5ccbdc6..98144c2 100644 --- a/src/routes/account/orders/index.tsx +++ b/src/routes/account/orders/index.tsx @@ -13,10 +13,8 @@ export async function loader({ request }: Route.LoaderArgs) { try { const orders = await getOrdersByUser(request); - orders.sort( - (a, b) => - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ); + orders.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + return { orders }; } catch { return {}; @@ -26,16 +24,11 @@ export async function loader({ request }: Route.LoaderArgs) { export default function Orders({ loaderData }: Route.ComponentProps) { const { orders } = loaderData; - const mappedOrders = orders?.map((order) => ({ - ...order, - createdAt: new Date(order.createdAt), - })); - return (
- {mappedOrders!.length > 0 ? ( + {orders!.length > 0 ? (
- {mappedOrders!.map((order) => ( + {orders!.map((order) => (
@@ -99,7 +92,10 @@ export default function Orders({ loaderData }: Route.ComponentProps) {
- {item.title} + {item.title}
From 8d0d2cf6a675546e105b291fa4f577f3a76cf3db Mon Sep 17 00:00:00 2001 From: Jota Date: Wed, 25 Jun 2025 20:10:47 -0500 Subject: [PATCH 15/21] refactor: update user service to use Prisma client for database operations --- src/lib/utils.tests.ts | 4 +-- src/services/user.service.test.ts | 46 ++++++++++++++++++++----------- src/services/user.service.ts | 37 ++++++++++++++----------- 3 files changed, 53 insertions(+), 34 deletions(-) diff --git a/src/lib/utils.tests.ts b/src/lib/utils.tests.ts index 998deb0..be9f7bb 100644 --- a/src/lib/utils.tests.ts +++ b/src/lib/utils.tests.ts @@ -19,8 +19,8 @@ export const createTestUser = (overrides?: Partial): User => ({ name: null, password: null, isGuest: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: new Date(), + updatedAt: new Date(), ...overrides, }); diff --git a/src/services/user.service.test.ts b/src/services/user.service.test.ts index ffe8965..dec96a8 100644 --- a/src/services/user.service.test.ts +++ b/src/services/user.service.test.ts @@ -6,15 +6,24 @@ import { createTestRequest, createTestUser, } from "@/lib/utils.tests"; -import * as userRepository from "@/repositories/user.repository"; import { getSession } from "@/session.server"; +import { prisma } from "@/db/prisma"; +import type { User } from "generated/prisma/client"; import * as userService from "./user.service"; // Mocking dependencies for unit tests vi.mock("@/session.server"); -vi.mock("@/repositories/user.repository"); vi.mock("@/lib/security"); +vi.mock("@/db/prisma", () => ({ + prisma: { + user: { + update: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + }, + }, +})); describe("user service", () => { beforeEach(() => { @@ -29,7 +38,7 @@ describe("user service", () => { const mockSession = createMockSession(updatedUser.id); // Simulate updated user ID in session // Mockeando las funciones que serán llamadas - vi.mocked(userRepository.updateUser).mockResolvedValue(updatedUser); + vi.mocked(prisma.user.update).mockResolvedValue(updatedUser); vi.mocked(getSession).mockResolvedValue(mockSession); // Llamando al servicio y verificando el resultado @@ -51,6 +60,10 @@ describe("user service", () => { // Mockeando las funciones que serán llamadas vi.mocked(getSession).mockResolvedValue(mockSession); vi.mocked(hashPassword).mockResolvedValue("hashed-password"); + vi.mocked(prisma.user.update).mockResolvedValue({ + ...updatedUser, + password: "hashed-password", + }); // Llamando al servicio y verificando el resultado await userService.updateUser(updatedUser, request); @@ -88,15 +101,15 @@ describe("user service", () => { }); // Mock repository function to return existing user - vi.mocked(userRepository.getUserByEmail).mockResolvedValue(existingUser); + vi.mocked(prisma.user.findUnique).mockResolvedValue(existingUser); // Call service function const result = await userService.getOrCreateUser(email); // Verify results expect(result).toEqual(existingUser); - expect(userRepository.getUserByEmail).toHaveBeenCalledWith(email); - expect(userRepository.createUser).not.toHaveBeenCalled(); + expect(prisma.user.findUnique).toHaveBeenCalledWith({ where: { email } }); + expect(prisma.user.create).not.toHaveBeenCalled(); }); it("should create a new guest user when email is not found", async () => { @@ -107,21 +120,22 @@ describe("user service", () => { id: 20, isGuest: true, }); - const createUserDTO = { - email, - password: null, - isGuest: true, - name: null, - }; // Mock repository functions - vi.mocked(userRepository.getUserByEmail).mockResolvedValue(null); - vi.mocked(userRepository.createUser).mockResolvedValue(newUser); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.create).mockResolvedValue(newUser); // Call service function const result = await userService.getOrCreateUser(email); // Verify results expect(result).toEqual(newUser); - expect(userRepository.getUserByEmail).toHaveBeenCalledWith(email); - expect(userRepository.createUser).toHaveBeenCalledWith(createUserDTO); + expect(prisma.user.findUnique).toHaveBeenCalledWith({ where: { email } }); + expect(prisma.user.create).toHaveBeenCalledWith({ + data: { + email, + password: null, + isGuest: true, + name: null, + }, + }); }); }); }); diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 85530a5..7883120 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -1,12 +1,12 @@ import { hashPassword } from "@/lib/security"; -import type { User, AuthResponse, CreateUserDTO } from "@/models/user.model"; -import * as userRepository from "@/repositories/user.repository"; +import type { Prisma, User } from "generated/prisma/client"; +import { prisma } from "@/db/prisma"; import { getSession } from "@/session.server"; export async function updateUser( updatedUser: Partial, request: Request -): Promise { +): Promise { const session = await getSession(request.headers.get("Cookie")); const id = session.get("userId"); @@ -14,30 +14,35 @@ export async function updateUser( throw new Error("User not authenticated"); } + const data: Prisma.UserUpdateInput = { ...updatedUser }; + if (updatedUser.password) { const hashedPassword = await hashPassword(updatedUser.password); - updatedUser.password = hashedPassword; + data.password = hashedPassword; } - const userData = await userRepository.updateUser(id, updatedUser); + const userData = await prisma.user.update({ + where: { id: typeof id === "number" ? id : Number(id) }, + data, + }); return userData; } export async function getOrCreateUser(email: string): Promise { - const existingUser = await userRepository.getUserByEmail(email); + let existingUser = await prisma.user.findUnique({ + where: { email }, + }); if (!existingUser) { - const newUser: CreateUserDTO = { - email, - password: null, - isGuest: true, - name: null, - }; - - const user = await userRepository.createUser(newUser); - - return user; + existingUser = await prisma.user.create({ + data: { + email, + password: null, + isGuest: true, + name: null, + }, + }); } return existingUser; From be9959c4f4184b2ab4130d4d9d9b54512db5e4eb Mon Sep 17 00:00:00 2001 From: Jota Date: Fri, 27 Jun 2025 17:25:13 -0500 Subject: [PATCH 16/21] refactor: update user model and service to streamline user type usage --- src/models/user.model.ts | 17 ++++------------- src/services/user.service.test.ts | 2 -- src/services/user.service.ts | 6 +++--- 3 files changed, 7 insertions(+), 18 deletions(-) diff --git a/src/models/user.model.ts b/src/models/user.model.ts index 9bb9f1b..8d17cf1 100644 --- a/src/models/user.model.ts +++ b/src/models/user.model.ts @@ -1,17 +1,8 @@ -export interface User { - id: number; - email: string; - name: string | null; - password: string | null; - isGuest: boolean; - createdAt: string; - updatedAt: string; -} +import type { User as PrismaUser } from "generated/prisma/client"; + +export type User = PrismaUser; export interface AuthResponse { - user: Omit; + user: User; token: string; } - -// For creating new users (no id, timestamps) -export type CreateUserDTO = Omit; diff --git a/src/services/user.service.test.ts b/src/services/user.service.test.ts index dec96a8..1362c66 100644 --- a/src/services/user.service.test.ts +++ b/src/services/user.service.test.ts @@ -8,8 +8,6 @@ import { } from "@/lib/utils.tests"; import { getSession } from "@/session.server"; import { prisma } from "@/db/prisma"; -import type { User } from "generated/prisma/client"; - import * as userService from "./user.service"; // Mocking dependencies for unit tests diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 7883120..b20e923 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -1,12 +1,12 @@ import { hashPassword } from "@/lib/security"; -import type { Prisma, User } from "generated/prisma/client"; +import type { User, AuthResponse } from "@/models/user.model"; import { prisma } from "@/db/prisma"; import { getSession } from "@/session.server"; export async function updateUser( updatedUser: Partial, request: Request -): Promise { +): Promise { const session = await getSession(request.headers.get("Cookie")); const id = session.get("userId"); @@ -14,7 +14,7 @@ export async function updateUser( throw new Error("User not authenticated"); } - const data: Prisma.UserUpdateInput = { ...updatedUser }; + const data = { ...updatedUser } as any; if (updatedUser.password) { const hashedPassword = await hashPassword(updatedUser.password); From 30ef9c5f6766674ed93e5c3912f45121a25d1e64 Mon Sep 17 00:00:00 2001 From: Jota Date: Fri, 27 Jun 2025 17:51:41 -0500 Subject: [PATCH 17/21] fix: omit password field from AuthResponse user type --- src/models/user.model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/user.model.ts b/src/models/user.model.ts index 8d17cf1..2c7e2f0 100644 --- a/src/models/user.model.ts +++ b/src/models/user.model.ts @@ -3,6 +3,6 @@ import type { User as PrismaUser } from "generated/prisma/client"; export type User = PrismaUser; export interface AuthResponse { - user: User; + user: Omit; token: string; } From 5f2821c42c4b7b49065eaf825808da2f9a0243db Mon Sep 17 00:00:00 2001 From: Diego Torres Date: Fri, 27 Jun 2025 20:01:26 -0500 Subject: [PATCH 18/21] refactor: migrate user repository functions to use Prisma client --- .env.test | 7 +++ playwright.config.ts | 24 ++++---- src/e2e/demo.signin.spec.ts | 22 +++++--- src/e2e/guest-create-order.spec.ts | 1 + src/e2e/user-create-order.spec.ts | 22 +++++--- src/models/user.model.ts | 5 ++ src/repositories/user.repository.ts | 56 ------------------- src/routes/login/index.tsx | 4 +- .../components/auth-nav/auth-nav.test.tsx | 8 +-- src/routes/signup/index.tsx | 10 +++- src/services/auth.service.ts | 14 ++++- 11 files changed, 78 insertions(+), 95 deletions(-) create mode 100644 .env.test delete mode 100644 src/repositories/user.repository.ts diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..5b49967 --- /dev/null +++ b/.env.test @@ -0,0 +1,7 @@ +DATABASE_URL="postgresql://diego@localhost:5432/fullstock?schema=public" + +# Admin Database (for database creation/deletion) +ADMIN_DB_NAME=postgres + +# This was inserted by `prisma init`: +[object Promise] \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts index 53f697b..a51b324 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,8 @@ -import { defineConfig, devices } from '@playwright/test'; +import { defineConfig, devices } from "@playwright/test"; +import dotenv from "dotenv"; + +// Load test environment variables +dotenv.config({ path: ".env.test" }); /** * Read environment variables from file. @@ -12,7 +16,7 @@ import { defineConfig, devices } from '@playwright/test'; * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - testDir: './src/e2e', + testDir: "./src/e2e", /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ @@ -22,31 +26,31 @@ export default defineConfig({ /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', + reporter: "html", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ // baseURL: 'http://localhost:3000', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', + trace: "on-first-retry", }, /* Configure projects for major browsers */ projects: [ { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + name: "chromium", + use: { ...devices["Desktop Chrome"] }, }, { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, + name: "firefox", + use: { ...devices["Desktop Firefox"] }, }, { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, + name: "webkit", + use: { ...devices["Desktop Safari"] }, }, /* Test against mobile viewports. */ diff --git a/src/e2e/demo.signin.spec.ts b/src/e2e/demo.signin.spec.ts index fd643d7..ce33412 100644 --- a/src/e2e/demo.signin.spec.ts +++ b/src/e2e/demo.signin.spec.ts @@ -1,12 +1,8 @@ import { test, expect } from "@playwright/test"; +import { prisma } from "@/db/prisma"; import { hashPassword } from "@/lib/security"; import type { CreateUserDTO } from "@/models/user.model"; -import { - createUser, - deleteUser, - getUserByEmail, -} from "@/repositories/user.repository"; test.describe("Visitante inicio sesion", () => { let testUserId: number; @@ -19,18 +15,26 @@ test.describe("Visitante inicio sesion", () => { isGuest: false, }; - const existingUser = await getUserByEmail(testUser.email); + const existingUser = await prisma.user.findUnique({ + where: { email: testUser.email }, + }); if (existingUser) { - await deleteUser(existingUser.id); + await prisma.user.delete({ + where: { id: existingUser.id }, + }); } - const user = await createUser(testUser); + const user = await prisma.user.create({ + data: testUser, + }); testUserId = user.id; }); test.afterAll(async () => { - await deleteUser(testUserId); + await prisma.user.delete({ + where: { id: testUserId }, + }); }); test("test", async ({ page }) => { diff --git a/src/e2e/guest-create-order.spec.ts b/src/e2e/guest-create-order.spec.ts index ad57978..223637a 100644 --- a/src/e2e/guest-create-order.spec.ts +++ b/src/e2e/guest-create-order.spec.ts @@ -1,5 +1,6 @@ // import { createOrderFormData } from "@/lib/utils.tests"; import { expect, test } from "@playwright/test"; + import { createOrderFormData } from "./utils-tests-e2e"; export type OrderFormData = Record; diff --git a/src/e2e/user-create-order.spec.ts b/src/e2e/user-create-order.spec.ts index 20c4c55..d5f0ed1 100644 --- a/src/e2e/user-create-order.spec.ts +++ b/src/e2e/user-create-order.spec.ts @@ -1,12 +1,8 @@ import { test, expect } from "@playwright/test"; +import { prisma } from "@/db/prisma"; import { hashPassword } from "@/lib/security"; import type { CreateUserDTO } from "@/models/user.model"; -import { - createUser, - deleteUser, - getUserByEmail, -} from "@/repositories/user.repository"; test.describe("User", () => { let testUserId: number; @@ -19,18 +15,26 @@ test.describe("User", () => { isGuest: false, }; - const existingUser = await getUserByEmail(testUser.email); + const existingUser = await prisma.user.findUnique({ + where: { email: testUser.email }, + }); if (existingUser) { - await deleteUser(existingUser.id); + await prisma.user.delete({ + where: { id: existingUser.id }, + }); } - const user = await createUser(testUser); + const user = await prisma.user.create({ + data: testUser, + }); testUserId = user.id; }); test.afterAll(async () => { - await deleteUser(testUserId); + await prisma.user.delete({ + where: { id: testUserId }, + }); }); test("User can create an order", async ({ page }) => { diff --git a/src/models/user.model.ts b/src/models/user.model.ts index 2c7e2f0..f88c5b6 100644 --- a/src/models/user.model.ts +++ b/src/models/user.model.ts @@ -6,3 +6,8 @@ export interface AuthResponse { user: Omit; token: string; } + +export type CreateUserDTO = Pick< + User, + "email" | "password" | "isGuest" | "name" +>; diff --git a/src/repositories/user.repository.ts b/src/repositories/user.repository.ts deleted file mode 100644 index 7bdd423..0000000 --- a/src/repositories/user.repository.ts +++ /dev/null @@ -1,56 +0,0 @@ -import * as db from "@/db"; -import { toSnakeCase } from "@/lib/case-converter"; -import { type CreateUserDTO, type User } from "@/models/user.model"; - -export async function createUser(user: CreateUserDTO): Promise { - const { email, name, password, isGuest } = user; - const newUser = await db.queryOne( - "INSERT INTO users (email, name, password, is_guest) VALUES ($1, $2, $3, $4) RETURNING *", - [email, name, password, isGuest] - ); - - if (!newUser) { - throw new Error("Failed to create user"); - } - - return newUser; -} - -export async function getUserById(id: User["id"]): Promise { - return await db.queryOne("SELECT * FROM users WHERE id = $1", [id]); -} - -export async function getUserByEmail(email: string): Promise { - return await db.queryOne("SELECT * FROM users WHERE email = $1", [ - email, - ]); -} - -export async function updateUser( - id: User["id"], - data: Partial> -): Promise { - const setClause = Object.keys(data) - .map((key, index) => `${toSnakeCase(key)} = $${index + 1}`) - .join(", "); - - const updateUser = await db.queryOne( - `UPDATE users SET ${setClause} WHERE id = $${ - Object.keys(data).length + 1 - } RETURNING *`, - [...Object.values(data), id] - ); - - if (!updateUser) { - throw new Error("Failed to update user"); - } - - return updateUser; -} - -export async function deleteUser(id: User["id"]): Promise { - return await db.queryOne( - "DELETE FROM users WHERE id = $1 RETURNING *", - [id] - ); -} diff --git a/src/routes/login/index.tsx b/src/routes/login/index.tsx index dbfee4f..ebab02b 100644 --- a/src/routes/login/index.tsx +++ b/src/routes/login/index.tsx @@ -4,8 +4,8 @@ import { Link, redirect, useNavigation, useSubmit } from "react-router"; import { z } from "zod"; import { Button, Container, InputField, Section } from "@/components/ui"; +import { prisma } from "@/db/prisma"; import { comparePasswords } from "@/lib/security"; -import { getUserByEmail } from "@/repositories/user.repository"; import { redirectIfAuthenticated } from "@/services/auth.service"; import { getRemoteCart, @@ -31,7 +31,7 @@ export async function action({ request }: Route.ActionArgs) { try { // Proceso de login nuevo - const user = await getUserByEmail(email); + const user = await prisma.user.findUnique({ where: { email } }); if (!user) { return { error: "Correo electrónico o contraseña inválidos" }; } diff --git a/src/routes/root/components/auth-nav/auth-nav.test.tsx b/src/routes/root/components/auth-nav/auth-nav.test.tsx index 09a5357..0201bca 100644 --- a/src/routes/root/components/auth-nav/auth-nav.test.tsx +++ b/src/routes/root/components/auth-nav/auth-nav.test.tsx @@ -39,8 +39,8 @@ describe("AuthNav Component", () => { email: "testino@mail.com", name: "Testino", isGuest: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: new Date(), + updatedAt: new Date(), }; renderWithRouter(); @@ -57,8 +57,8 @@ describe("AuthNav Component", () => { email: "testino@mail.com", name: null, isGuest: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: new Date(), + updatedAt: new Date(), }; renderWithRouter(); diff --git a/src/routes/signup/index.tsx b/src/routes/signup/index.tsx index 8c35e4b..bd32067 100644 --- a/src/routes/signup/index.tsx +++ b/src/routes/signup/index.tsx @@ -4,10 +4,10 @@ import { Link, redirect, useNavigation, useSubmit } from "react-router"; import { z } from "zod"; import { Button, Container, InputField, Section } from "@/components/ui"; +import { prisma } from "@/db/prisma"; import { hashPassword } from "@/lib/security"; import { debounceAsync } from "@/lib/utils"; import type { CreateUserDTO } from "@/models/user.model"; -import { createUser, getUserByEmail } from "@/repositories/user.repository"; import { redirectIfAuthenticated } from "@/services/auth.service"; import { linkCartToUser } from "@/services/cart.service"; import { findEmail } from "@/services/user.client-service"; @@ -42,7 +42,9 @@ export async function action({ request }: Route.ActionArgs) { const sessionCartId = session.get("sessionCartId"); try { - const existingUser = await getUserByEmail(email); + const existingUser = await prisma.user.findUnique({ + where: { email: email }, + }); if (existingUser) { return { error: "El correo electrónico ya existe" }; } @@ -56,7 +58,9 @@ export async function action({ request }: Route.ActionArgs) { name: null, }; - const user = await createUser(newUser); + const user = await prisma.user.create({ + data: newUser, + }); session.set("userId", user.id); if (sessionCartId) { diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 9758738..4fd5217 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -1,7 +1,7 @@ import { redirect } from "react-router"; +import { prisma } from "@/db/prisma"; import { type AuthResponse } from "@/models/user.model"; -import { getUserById } from "@/repositories/user.repository"; import { getSession } from "@/session.server"; export async function getCurrentUser( @@ -14,7 +14,17 @@ export async function getCurrentUser( if (!userId) return null; try { - return await getUserById(userId); + return await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + name: true, + isGuest: true, + createdAt: true, + updatedAt: true, + }, + }); } catch (error) { console.error("Error fetching current user:", error); return null; From 62958de260f012cf82eacb835799f84f61e2fd7d Mon Sep 17 00:00:00 2001 From: mike Date: Fri, 27 Jun 2025 20:05:56 -0500 Subject: [PATCH 19/21] refactor: migrate cart service to use Prisma client --- src/services/cart.service.ts | 291 +++++++++++++++++++++++++++-------- 1 file changed, 231 insertions(+), 60 deletions(-) diff --git a/src/services/cart.service.ts b/src/services/cart.service.ts index c0c8b86..ddda1bc 100644 --- a/src/services/cart.service.ts +++ b/src/services/cart.service.ts @@ -1,29 +1,107 @@ -import { type Cart, type CartItem } from "@/models/cart.model"; -import type { User } from "@/models/user.model"; -import * as cartRepository from "@/repositories/cart.repository"; +import { type Cart, type CartItem, type User } from "generated/prisma/client"; + +import { prisma } from "@/db/prisma"; import { getSession } from "@/session.server"; -export async function getRemoteCart(userId: User["id"]): Promise { - const cart = await cartRepository.getCart(userId, undefined); - return cart; +import type { Decimal } from "@prisma/client/runtime/library"; + +// Tipo para representar un producto simplificado en el carrito +type CartProductInfo = { + id: number; + title: string; + imgSrc: string; + alt: string | null; + price: Decimal; + isOnSale: boolean; +}; + +// Tipo para representar un item de carrito con su producto +type CartItemWithProduct = { + product: CartProductInfo; + quantity: number; +}; + +// Tipo para el carrito con items y productos incluidos +type CartWithItems = Cart & { + items: Array +}; + +// Función para obtener un carrito con sus ítems +async function getCart( + userId?: number, + sessionCartId?: string, + id?: number +): Promise { + const whereCondition = userId + ? { userId } + : sessionCartId + ? { sessionCartId } + : id + ? { id } + : undefined; + + if (!whereCondition) return null; + + return await prisma.cart.findFirst({ + where: whereCondition, + include: { + items: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + price: true, + isOnSale: true, + }, + }, + }, + orderBy: { + id: "asc", + }, + }, + }, + }); +} + +export async function getRemoteCart(userId: User["id"]): Promise { + return await getCart(userId); } export async function getOrCreateCart( userId: User["id"] | undefined, sessionCartId: string | undefined -) { - let cart: Cart | null = null; - - cart = await cartRepository.getCart(userId, sessionCartId); +): Promise { + let cart = await getCart(userId, sessionCartId); // Si no se encontró un carrito creamos uno nuevo if (!cart) { - // Creamos un carrito sin userId ni sessionCartId, dejando que la BD genere el UUID - cart = await cartRepository.createCart(); - // Si se crea el carrito, lo vinculamos a un usuario si se proporciona un userId - if (cart && userId) { - await cartRepository.updateCartWithUserId(cart.id, userId); - } + // Creamos un carrito con userId si se proporciona + cart = await prisma.cart.create({ + data: { + userId: userId || null, + }, + include: { + items: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + price: true, + isOnSale: true, + }, + }, + }, + }, + }, + }); } if (!cart) throw new Error("Failed to create cart"); @@ -34,29 +112,29 @@ export async function getOrCreateCart( export async function createRemoteItems( userId: User["id"] | undefined, sessionCartId: string | undefined, - items: CartItem[] = [] -): Promise { - const mappedItems = items.map(({ product, quantity }) => ({ - productId: product.id, - quantity, - })); - + items: CartItemWithProduct[] = [] +): Promise { const cart = await getOrCreateCart(userId, sessionCartId); + // Eliminar todos los ítems existentes en el carrito if (cart.items.length > 0) { - await cartRepository.clearCart(cart.id); + await prisma.cartItem.deleteMany({ + where: { cartId: cart.id }, + }); } // Si hay elementos para agregar, agregarlos if (items.length > 0) { - await cartRepository.addCartItems(cart.id, mappedItems); + await prisma.cartItem.createMany({ + data: items.map(item => ({ + cartId: cart.id, + productId: item.product.id, + quantity: item.quantity + })), + }); } - const updatedCart = await cartRepository.getCart( - userId, - sessionCartId, - cart.id - ); + const updatedCart = await getCart(userId, sessionCartId, cart.id); if (!updatedCart) throw new Error("Cart not found after creation"); @@ -68,7 +146,7 @@ export async function alterQuantityCartItem( sessionCartId: string | undefined, productId: number, quantity: number = 1 -): Promise { +): Promise { const cart = await getOrCreateCart(userId, sessionCartId); const existingItem = cart.items.find((item) => item.product.id === productId); @@ -78,12 +156,25 @@ export async function alterQuantityCartItem( if (newQuantity <= 0) throw new Error("Cannot set item quantity to 0 or less"); - await cartRepository.updateCartItem(cart.id, existingItem.id, newQuantity); + await prisma.cartItem.update({ + where: { + id: existingItem.id, + }, + data: { + quantity: newQuantity, + }, + }); } else { - await cartRepository.addCartItem(cart.id, productId, quantity); + await prisma.cartItem.create({ + data: { + cartId: cart.id, + productId, + quantity, + }, + }); } - const updatedCart = await cartRepository.getCart( + const updatedCart = await getCart( userId, cart.sessionCartId, cart.id @@ -97,17 +188,18 @@ export async function alterQuantityCartItem( export async function deleteRemoteCartItem( userId: User["id"] | undefined, sessionCartId: string | undefined, - itemId: CartItem["id"] -): Promise { - let cart: Cart | null = null; - - if (userId || sessionCartId) { - cart = await cartRepository.getCart(userId, sessionCartId); - } + itemId: number +): Promise { + const cart = await getCart(userId, sessionCartId); if (!cart) throw new Error("Cart not found"); - await cartRepository.removeCartItem(cart.id, itemId); + await prisma.cartItem.delete({ + where: { + id: itemId, + cartId: cart.id, + }, + }); const updatedCart = await getOrCreateCart(userId, sessionCartId); return updatedCart; @@ -118,27 +210,48 @@ export async function deleteRemoteCart(request: Request): Promise { const sessionCartId = session.get("sessionCartId"); const userId = session.get("userId"); - let cart: Cart | null = null; - - if (userId || sessionCartId) { - cart = await cartRepository.getCart(userId, sessionCartId); - } + const cart = await getCart(userId, sessionCartId); if (!cart) throw new Error("Cart not found"); - await cartRepository.clearCart(cart.id); + + // Eliminar todos los items del carrito primero + await prisma.cartItem.deleteMany({ + where: { cartId: cart.id }, + }); + + // Luego eliminar el carrito + await prisma.cart.delete({ + where: { id: cart.id }, + }); } export async function linkCartToUser( userId: User["id"], sessionCartId: string -): Promise { +): Promise { if (!sessionCartId) throw new Error("Session cart ID not found"); if (!userId) throw new Error("User ID not found"); - const updatedCart = await cartRepository.updateCartBySessionId( - sessionCartId, - userId - ); + const updatedCart = await prisma.cart.update({ + where: { sessionCartId }, + data: { userId }, + include: { + items: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + price: true, + isOnSale: true, + }, + }, + }, + }, + }, + }); if (!updatedCart) throw new Error("Cart not found after linking"); @@ -148,13 +261,71 @@ export async function linkCartToUser( export async function mergeGuestCartWithUserCart( userId: User["id"], sessionCartId: string -): Promise { - const mergedCart = await cartRepository.mergeGuestCartWithUserCart( - userId, - sessionCartId +): Promise { + // Obtener el carrito de usuario y el carrito de invitado + const userCart = await getCart(userId); + const guestCart = await getCart(undefined, sessionCartId); + + if (!guestCart) { + return userCart; + } + + if (!userCart) { + // Si el usuario no tiene carrito, actualizamos el carrito de invitado con el ID del usuario + const updatedCart = await prisma.cart.update({ + where: { sessionCartId }, + data: { userId }, + include: { + items: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + price: true, + isOnSale: true, + }, + }, + }, + }, + }, + }); + return updatedCart; + } + + // Obtener productos duplicados para eliminarlos del carrito del usuario + const guestProductIds = guestCart.items.map(item => item.productId); + + // Eliminar productos del carrito usuario que también existan en el carrito invitado + await prisma.cartItem.deleteMany({ + where: { + cartId: userCart.id, + productId: { + in: guestProductIds, + }, + }, + }); + + // Mover los items del carrito de invitado al carrito de usuario + await Promise.all( + guestCart.items.map(async (item) => { + return prisma.cartItem.create({ + data: { + cartId: userCart.id, + productId: item.productId, + quantity: item.quantity, + }, + }); + }) ); - if (!mergedCart) throw new Error("Cart not found after merging"); + // Eliminar el carrito de invitado + await prisma.cart.delete({ + where: { id: guestCart.id }, + }); - return mergedCart; -} + // Devolver el carrito actualizado del usuario + return await getCart(userId); +} \ No newline at end of file From b3495cc4501a8418a61ed605ac5d7230145fec83 Mon Sep 17 00:00:00 2001 From: Diego Torres Date: Fri, 27 Jun 2025 21:02:54 -0500 Subject: [PATCH 20/21] refactor: update cart and product models to use Prisma types and streamline cart service functions --- src/models/cart.model.ts | 41 +++++--- src/models/product.model.ts | 17 ---- src/routes/root/index.tsx | 4 +- src/services/cart.service.ts | 186 +++++++++++++++++++---------------- 4 files changed, 127 insertions(+), 121 deletions(-) diff --git a/src/models/cart.model.ts b/src/models/cart.model.ts index 569c2f0..90b62f9 100644 --- a/src/models/cart.model.ts +++ b/src/models/cart.model.ts @@ -1,25 +1,18 @@ import { type Product } from "./product.model"; -import { type User } from "./user.model"; -export interface CartItem { - id: number; +import type { + Cart as PrismaCart, + CartItem as PrismaCartItem, +} from "generated/prisma/client"; + +export type CartItem = PrismaCartItem & { product: Pick< Product, "id" | "title" | "imgSrc" | "alt" | "price" | "isOnSale" >; - quantity: number; - createdAt: string; - updatedAt: string; -} +}; -export interface Cart { - id: number; - userId: User["id"] | null; - sessionCartId: string; - items: CartItem[]; - createdAt: string; - updatedAt: string; -} +export type Cart = PrismaCart; export interface CartItemInput { productId: Product["id"]; @@ -28,3 +21,21 @@ export interface CartItemInput { price: Product["price"]; imgSrc: Product["imgSrc"]; } + +// Tipo para representar un producto simplificado en el carrito + +export type CartProductInfo = Pick< + Product, + "id" | "title" | "imgSrc" | "alt" | "price" | "isOnSale" +>; + +// Tipo para representar un item de carrito con su producto +export type CartItemWithProduct = { + product: CartProductInfo; + quantity: number; +}; + +// Tipo para el carrito con items y productos incluidos +export type CartWithItems = Cart & { + items: CartItem[]; +}; diff --git a/src/models/product.model.ts b/src/models/product.model.ts index 2f56d20..6f46237 100644 --- a/src/models/product.model.ts +++ b/src/models/product.model.ts @@ -1,22 +1,5 @@ -// import { type Category } from "./category.model"; - import type { Product as PrismaProduct } from "generated/prisma/client"; -// export interface Product { -// id: number; -// title: string; -// imgSrc: string; -// alt: string | null; -// price: number; -// description: string | null; -// categoryId: number; -// // categorySlug: Category["slug"]; -// isOnSale: boolean; -// features: string[]; -// createdAt: string; -// updatedAt: string; -// } - export type Product = Omit & { price: number; }; diff --git a/src/routes/root/index.tsx b/src/routes/root/index.tsx index 99be149..99b4610 100644 --- a/src/routes/root/index.tsx +++ b/src/routes/root/index.tsx @@ -17,7 +17,7 @@ import { Separator, } from "@/components/ui"; import { getCart } from "@/lib/cart"; -import type { Cart } from "@/models/cart.model"; +import type { CartWithItems } from "@/models/cart.model"; import { getCurrentUser } from "@/services/auth.service"; import { createRemoteItems } from "@/services/cart.service"; import { commitSession, getSession } from "@/session.server"; @@ -45,7 +45,7 @@ export async function action({ request }: Route.ActionArgs) { export async function loader({ request }: Route.LoaderArgs) { const session = await getSession(request.headers.get("Cookie")); const sessionCartId = session.get("sessionCartId"); - let cart: Cart | null = null; + let cart: CartWithItems | null = null; // Obtenemos el usuario actual (autenticado o no) const user = await getCurrentUser(request); diff --git a/src/services/cart.service.ts b/src/services/cart.service.ts index ddda1bc..f742706 100644 --- a/src/services/cart.service.ts +++ b/src/services/cart.service.ts @@ -1,50 +1,25 @@ -import { type Cart, type CartItem, type User } from "generated/prisma/client"; - import { prisma } from "@/db/prisma"; +import type { CartItemWithProduct, CartWithItems } from "@/models/cart.model"; +import type { User } from "@/models/user.model"; import { getSession } from "@/session.server"; -import type { Decimal } from "@prisma/client/runtime/library"; - -// Tipo para representar un producto simplificado en el carrito -type CartProductInfo = { - id: number; - title: string; - imgSrc: string; - alt: string | null; - price: Decimal; - isOnSale: boolean; -}; - -// Tipo para representar un item de carrito con su producto -type CartItemWithProduct = { - product: CartProductInfo; - quantity: number; -}; - -// Tipo para el carrito con items y productos incluidos -type CartWithItems = Cart & { - items: Array -}; - // Función para obtener un carrito con sus ítems async function getCart( userId?: number, sessionCartId?: string, id?: number ): Promise { - const whereCondition = userId - ? { userId } - : sessionCartId - ? { sessionCartId } - : id - ? { id } - : undefined; + const whereCondition = userId + ? { userId } + : sessionCartId + ? { sessionCartId } + : id + ? { id } + : undefined; if (!whereCondition) return null; - return await prisma.cart.findFirst({ + const data = await prisma.cart.findFirst({ where: whereCondition, include: { items: { @@ -66,9 +41,24 @@ async function getCart( }, }, }); + + if (!data) return null; + + return { + ...data, + items: data.items.map((item) => ({ + ...item, + product: { + ...item.product, + price: item.product.price.toNumber(), + }, + })), + }; } -export async function getRemoteCart(userId: User["id"]): Promise { +export async function getRemoteCart( + userId: User["id"] +): Promise { return await getCart(userId); } @@ -76,37 +66,49 @@ export async function getOrCreateCart( userId: User["id"] | undefined, sessionCartId: string | undefined ): Promise { - let cart = await getCart(userId, sessionCartId); + const cart = await getCart(userId, sessionCartId); + + if (cart) { + return cart; + } // Si no se encontró un carrito creamos uno nuevo - if (!cart) { - // Creamos un carrito con userId si se proporciona - cart = await prisma.cart.create({ - data: { - userId: userId || null, - }, - include: { - items: { - include: { - product: { - select: { - id: true, - title: true, - imgSrc: true, - alt: true, - price: true, - isOnSale: true, - }, + + // Creamos un carrito con userId si se proporciona + const newCart = await prisma.cart.create({ + data: { + userId: userId || null, + }, + include: { + items: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + price: true, + isOnSale: true, }, }, }, }, - }); - } + }, + }); - if (!cart) throw new Error("Failed to create cart"); + if (!newCart) throw new Error("Failed to create cart"); - return cart; + return { + ...newCart, + items: newCart.items.map((item) => ({ + ...item, + product: { + ...item.product, + price: item.product.price.toNumber(), + }, + })), + }; } export async function createRemoteItems( @@ -126,10 +128,10 @@ export async function createRemoteItems( // Si hay elementos para agregar, agregarlos if (items.length > 0) { await prisma.cartItem.createMany({ - data: items.map(item => ({ + data: items.map((item) => ({ cartId: cart.id, productId: item.product.id, - quantity: item.quantity + quantity: item.quantity, })), }); } @@ -174,11 +176,7 @@ export async function alterQuantityCartItem( }); } - const updatedCart = await getCart( - userId, - cart.sessionCartId, - cart.id - ); + const updatedCart = await getCart(userId, cart.sessionCartId, cart.id); if (!updatedCart) throw new Error("Cart not found after update"); @@ -213,12 +211,12 @@ export async function deleteRemoteCart(request: Request): Promise { const cart = await getCart(userId, sessionCartId); if (!cart) throw new Error("Cart not found"); - + // Eliminar todos los items del carrito primero - await prisma.cartItem.deleteMany({ - where: { cartId: cart.id }, - }); - + // await prisma.cartItem.deleteMany({ + // where: { cartId: cart.id }, + // }); + // Luego eliminar el carrito await prisma.cart.delete({ where: { id: cart.id }, @@ -255,7 +253,16 @@ export async function linkCartToUser( if (!updatedCart) throw new Error("Cart not found after linking"); - return updatedCart; + return { + ...updatedCart, + items: updatedCart.items.map((item) => ({ + ...item, + product: { + ...item.product, + price: item.product.price.toNumber(), + }, + })), + }; } export async function mergeGuestCartWithUserCart( @@ -292,12 +299,21 @@ export async function mergeGuestCartWithUserCart( }, }, }); - return updatedCart; + return { + ...updatedCart, + items: updatedCart.items.map((item) => ({ + ...item, + product: { + ...item.product, + price: item.product.price.toNumber(), + }, + })), + }; } // Obtener productos duplicados para eliminarlos del carrito del usuario - const guestProductIds = guestCart.items.map(item => item.productId); - + const guestProductIds = guestCart.items.map((item) => item.productId); + // Eliminar productos del carrito usuario que también existan en el carrito invitado await prisma.cartItem.deleteMany({ where: { @@ -309,17 +325,13 @@ export async function mergeGuestCartWithUserCart( }); // Mover los items del carrito de invitado al carrito de usuario - await Promise.all( - guestCart.items.map(async (item) => { - return prisma.cartItem.create({ - data: { - cartId: userCart.id, - productId: item.productId, - quantity: item.quantity, - }, - }); - }) - ); + await prisma.cartItem.createMany({ + data: guestCart.items.map((item) => ({ + cartId: userCart.id, + productId: item.productId, + quantity: item.quantity, + })), + }); // Eliminar el carrito de invitado await prisma.cart.delete({ @@ -328,4 +340,4 @@ export async function mergeGuestCartWithUserCart( // Devolver el carrito actualizado del usuario return await getCart(userId); -} \ No newline at end of file +} From 2055d96d5f14f69e43f3ab9ad95f43de131a5e26 Mon Sep 17 00:00:00 2001 From: Diego Torres Date: Fri, 27 Jun 2025 21:04:53 -0500 Subject: [PATCH 21/21] refactor: remove unused cart repository and update related test files --- src/repositories/cart.repository.ts | 224 ---------------------------- src/services/order.service.test.ts | 1 - src/services/product.service.ts | 1 - src/services/user.service.test.ts | 6 +- 4 files changed, 3 insertions(+), 229 deletions(-) delete mode 100644 src/repositories/cart.repository.ts diff --git a/src/repositories/cart.repository.ts b/src/repositories/cart.repository.ts deleted file mode 100644 index d8c76de..0000000 --- a/src/repositories/cart.repository.ts +++ /dev/null @@ -1,224 +0,0 @@ -import * as db from "@/db"; -import { type Cart, type CartItem } from "@/models/cart.model"; - -export async function getCart( - userId: number | undefined, - sessionCartId: string | undefined, - id?: number -): Promise { - let whereClause: string; - let paramValue: number | string; - - if (userId) { - whereClause = "WHERE c.user_id = $1"; - paramValue = userId; - } else if (sessionCartId) { - whereClause = "WHERE c.session_cart_id = $1"; - paramValue = sessionCartId; - } else if (id) { - whereClause = "WHERE c.id = $1"; - paramValue = id; - } else { - // Si no se proporciona ningún identificador, devolvemos null - return null; - } - - const query = ` - SELECT - c.*, - COALESCE( - ( - SELECT json_agg( - json_build_object( - 'id', ci.id, - 'quantity', ci.quantity, - 'product', ( - SELECT json_build_object( - 'id', p.id, - 'title', p.title, - 'imgSrc', p.img_src, - 'alt', p.alt, - 'price', p.price, - 'isOnSale', p.is_on_sale - ) - FROM products p - WHERE p.id = ci.product_id - ), - 'createdAt', ci.created_at, - 'updatedAt', ci.updated_at - ) - ORDER BY ci.id ASC - ) - FROM cart_items ci - LEFT JOIN products pr on pr.id = ci.product_id - WHERE ci.cart_id = c.id - )::json, - '[]'::json - ) as items - FROM carts c - ${whereClause} - `; - return await db.queryOne(query, [paramValue]); -} - -export async function createCart(): Promise { - const query = "INSERT INTO carts DEFAULT VALUES RETURNING *"; - const cart = await db.queryOne(query); - return getCart(undefined, undefined, cart?.id); -} - -// export async function createGuestCart(sessionCartId: string): Promise { // new function -// const query = "INSERT INTO carts (session_cart_id) VALUES ($1) RETURNING *"; -// return db.queryOne(query, [sessionCartId]); -// } - -export async function addCartItem( - cartId: number, - productId: number, - quantity: number -): Promise { - const query = ` - INSERT INTO cart_items (cart_id, product_id, quantity) - VALUES ($1, $2, $3) - RETURNING * - `; - - return await db.queryOne(query, [cartId, productId, quantity]); -} - -export async function addCartItems( - cartId: number, - items: { productId: number; quantity: number }[] | [] -): Promise { - // Si no hay elementos para agregar, retornar un array vacío - if (items.length === 0) { - return []; - } - - const valuesClause = items - .map((_, i) => `($1, $${i * 2 + 2}, $${i * 2 + 3})`) - .join(","); - - const query = ` - INSERT INTO cart_items (cart_id, product_id, quantity) - VALUES ${valuesClause} - RETURNING * - `; - - const values = items.reduce( - (acc, item) => { - acc.push(item.productId, item.quantity); - return acc; - }, - [cartId] as (string | number)[] - ); - - return await db.query(query, values); -} - -export async function updateCartItem( - cartId: number, - itemId: number, - quantity: number -): Promise { - const query = - "UPDATE cart_items SET quantity = $1 WHERE id = $2 AND cart_id = $3 RETURNING *"; - - return await db.queryOne(query, [quantity, itemId, cartId]); -} - -export async function removeCartItem( - cartId: number, - itemId: number -): Promise { - const query = "DELETE FROM cart_items WHERE id = $1 AND cart_id = $2"; - await db.query(query, [itemId, cartId]); -} - -export async function clearCart(cartId: number): Promise { - const query = "DELETE FROM carts WHERE id = $1"; - await db.query(query, [cartId]); -} - -export async function updateCartWithUserId( - cartId: number, - userId: number -): Promise { - const query = ` - UPDATE carts - SET user_id = $2 - WHERE id = $1 - RETURNING * - `; - - return await db.queryOne(query, [cartId, userId]); -} - -export async function updateCartBySessionId( - sessionCartId: string, - userId: number -): Promise { - const query = ` - UPDATE carts - SET user_id = $2 - WHERE session_cart_id = $1 - RETURNING * - `; - - return await db.queryOne(query, [sessionCartId, userId]); -} - -export async function mergeGuestCartWithUserCart( - userId: number | undefined, - sessionCartId: string -): Promise { - // Primero, obtenemos el carrito del usuario y el carrito de invitado - const userCart = await getCart(userId, undefined); - const guestCart = await getCart(undefined, sessionCartId); - - if (!guestCart) { - return userCart; - } - - if (!userCart) { - // Si el usuario no tiene carrito, actualizamos el carrito de invitado con el ID del usuario - const query = ` - UPDATE carts - SET user_id = $1 - WHERE session_cart_id = $2 - RETURNING * - `; - return await db.queryOne(query, [userId, sessionCartId]); - } - - // Eliminamos productos del carrito usuario que también existan en el carrito invitado - await db.query( - ` - DELETE FROM cart_items - WHERE cart_id = $1 - AND product_id IN ( - SELECT product_id FROM cart_items WHERE cart_id = $2 - ) - `, - [userCart.id, guestCart.id] - ); - - // Insertamos los artículos del carrito invitado al carrito usuario - const query = ` - INSERT INTO cart_items (cart_id, product_id, quantity) - SELECT $1, product_id, quantity - FROM cart_items - WHERE cart_id = $2 - RETURNING * - `; - - await db.query(query, [userCart.id, guestCart.id]); - - // Eliminamos el carrito de invitado - await db.query(`DELETE FROM carts WHERE session_cart_id = $1`, [ - sessionCartId, - ]); - - // Devolvemos el carrito actualizado del usuario - return await getCart(userId, undefined); -} diff --git a/src/services/order.service.test.ts b/src/services/order.service.test.ts index 35e9ffe..f6bcf52 100644 --- a/src/services/order.service.test.ts +++ b/src/services/order.service.test.ts @@ -29,7 +29,6 @@ vi.mock("@/db/prisma", () => ({ vi.mock("./user.service"); vi.mock("@/lib/cart"); -vi.mock("@/repositories/order.repository"); vi.mock("@/session.server"); describe("Order Service", () => { diff --git a/src/services/product.service.ts b/src/services/product.service.ts index 43ce73f..3406570 100644 --- a/src/services/product.service.ts +++ b/src/services/product.service.ts @@ -31,7 +31,6 @@ export async function getProductById(id: number): Promise { } export async function getAllProducts(): Promise { - // No la utilizamos en repository. return (await prisma.product.findMany()).map((p) => ({ ...p, price: p.price.toNumber(), diff --git a/src/services/user.service.test.ts b/src/services/user.service.test.ts index 1362c66..dc69ff3 100644 --- a/src/services/user.service.test.ts +++ b/src/services/user.service.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { prisma } from "@/db/prisma"; import { hashPassword } from "@/lib/security"; import { createMockSession, @@ -7,7 +8,7 @@ import { createTestUser, } from "@/lib/utils.tests"; import { getSession } from "@/session.server"; -import { prisma } from "@/db/prisma"; + import * as userService from "./user.service"; // Mocking dependencies for unit tests @@ -98,7 +99,6 @@ describe("user service", () => { id: 10, }); - // Mock repository function to return existing user vi.mocked(prisma.user.findUnique).mockResolvedValue(existingUser); // Call service function @@ -118,7 +118,7 @@ describe("user service", () => { id: 20, isGuest: true, }); - // Mock repository functions + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); vi.mocked(prisma.user.create).mockResolvedValue(newUser); // Call service function