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/.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/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/prisma/initial_data.ts b/prisma/initial_data.ts new file mode 100644 index 0000000..ecf0a7c --- /dev/null +++ b/prisma/initial_data.ts @@ -0,0 +1,371 @@ +import type { CategorySlug } from "generated/prisma/client.js"; + +export const categories = [ + { + title: "Polos", + slug: "polos" as CategorySlug, + 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" as CategorySlug, + 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" as CategorySlug, + 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.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.", + ], + }, + { + title: "Polo JavaScript", + imgSrc: "/images/polos/polo-js.png", + 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.", + ], + }, + { + title: "Polo Node.js", + imgSrc: "/images/polos/polo-node.png", + 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.", + ], + }, + { + title: "Polo TypeScript", + imgSrc: "/images/polos/polo-ts.png", + 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.", + ], + }, + { + title: "Polo Backend Developer", + imgSrc: "/images/polos/polo-backend.png", + 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.", + ], + }, + { + title: "Polo Frontend Developer", + imgSrc: "/images/polos/polo-frontend.png", + 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.", + ], + }, + { + title: "Polo Full-Stack Developer", + imgSrc: "/images/polos/polo-fullstack.png", + 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.", + ], + }, + { + title: "Polo It's A Feature", + imgSrc: "/images/polos/polo-feature.png", + 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.", + ], + }, + { + title: "Polo It Works On My Machine", + imgSrc: "/images/polos/polo-works.png", + 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.", + ], + }, + { + 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", + ], + }, +]; 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/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/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/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..4314c02 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,139 @@ +// 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" + 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) + + carts Cart[] + orders Order[] + + @@map("users") +} + +enum CategorySlug { + polos + tazas + stickers +} + +model Category { + id Int @id @default(autoincrement()) + title String + 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[] + + @@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") +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..655d905 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,25 @@ +import { categories, products } from "./initial_data"; +import { PrismaClient } from "../generated/prisma/client.js"; + +const prisma = new PrismaClient(); + +async function seedDb() { + 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() + .catch((e) => { + console.error("Seeding error:", e); + }) + .finally(async () => { + console.log("--- Database seeded successfully. ---"); + await prisma.$disconnect(); + }); diff --git a/src/db/prisma.ts b/src/db/prisma.ts new file mode 100644 index 0000000..7b8438f --- /dev/null +++ b/src/db/prisma.ts @@ -0,0 +1,28 @@ +import { PrismaClient } from "generated/prisma/client.js"; + +// 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/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 new file mode 100644 index 0000000..223637a --- /dev/null +++ b/src/e2e/guest-create-order.spec.ts @@ -0,0 +1,37 @@ +// 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/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/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, +}); diff --git a/src/lib/utils.tests.ts b/src/lib/utils.tests.ts index 827733f..be9f7bb 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"; @@ -18,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, }); @@ -55,8 +56,8 @@ export const createTestProduct = (overrides?: Partial): Product => ({ 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 +70,54 @@ 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, }); + +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, + } satisfies OrderItem); + +export const createTestOrder = (overrides: Partial = {}): Order => { + const details = overrides.details ?? createTestOrderDetails(); + return { + id: 1, + userId: 1, + totalAmount: 100, + items: [createTestOrderItem()], + details, + createdAt: new Date(), + updatedAt: new Date(), + ...details, // Expande todos los campos de contacto sin undefined + ...overrides, + } satisfies Order; +}; 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/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/order.model.ts b/src/models/order.model.ts index 99349c4..d2d5c40 100644 --- a/src/models/order.model.ts +++ b/src/models/order.model.ts @@ -1,39 +1,31 @@ -import { type User } from "./user.model"; +import type { + Order as PrismaOrder, + OrderItem as PrismaOrderItem, +} from "generated/prisma/client"; -export interface OrderDetails { - email: string; - firstName: string; - lastName: string; - company: string | null; - address: string; - city: string; - country: string; - region: string; - zip: string; - phone: string; -} +export type OrderDetails = Pick< + PrismaOrder, + | "email" + | "firstName" + | "lastName" + | "company" + | "address" + | "city" + | "country" + | "region" + | "zip" + | "phone" +>; -export interface OrderItem { - id: number; - orderId: number; - productId: number; - quantity: number; - title: string; +export type OrderItem = Omit & { price: number; - imgSrc: string; - createdAt: string; - updatedAt: string; -} +}; -export interface Order { - id: number; - userId: User["id"]; +export type Order = Omit & { items: OrderItem[]; totalAmount: number; details: OrderDetails; - createdAt: string; - updatedAt: string; -} +}; export interface OrderItemInput { productId: number; diff --git a/src/models/product.model.ts b/src/models/product.model.ts index c42f14d..6f46237 100644 --- a/src/models/product.model.ts +++ b/src/models/product.model.ts @@ -1,16 +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; +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/models/user.model.ts b/src/models/user.model.ts index 9bb9f1b..f88c5b6 100644 --- a/src/models/user.model.ts +++ b/src/models/user.model.ts @@ -1,17 +1,13 @@ -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; token: string; } -// For creating new users (no id, timestamps) -export type CreateUserDTO = Omit; +export type CreateUserDTO = Pick< + User, + "email" | "password" | "isGuest" | "name" +>; 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/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] - ); -} 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/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/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/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}
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/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/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, + }); + }); +}); 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..becd17c --- /dev/null +++ b/src/routes/order-confirmation/order-confirmation.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import OrderConfirmation from "."; +import type { Route } from "./+types"; + +// 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.queryByText(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.queryByText("Código de seguimiento"); + expect(trackingCodeLabel).toBeInTheDocument(); + + const trackingCode = screen.queryByText(testOrderId); + expect(trackingCode).toBeInTheDocument(); + }); + }); +}); 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/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/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; diff --git a/src/services/cart.service.ts b/src/services/cart.service.ts index c0c8b86..f742706 100644 --- a/src/services/cart.service.ts +++ b/src/services/cart.service.ts @@ -1,62 +1,142 @@ -import { type Cart, type CartItem } from "@/models/cart.model"; +import { prisma } from "@/db/prisma"; +import type { CartItemWithProduct, CartWithItems } from "@/models/cart.model"; import type { User } from "@/models/user.model"; -import * as cartRepository from "@/repositories/cart.repository"; import { getSession } from "@/session.server"; -export async function getRemoteCart(userId: User["id"]): Promise { - const cart = await cartRepository.getCart(userId, undefined); - return cart; +// 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; + + const data = 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", + }, + }, + }, + }); + + 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 { + return await getCart(userId); } export async function getOrCreateCart( userId: User["id"] | undefined, sessionCartId: string | undefined -) { - let cart: Cart | null = null; +): Promise { + const cart = await getCart(userId, sessionCartId); - cart = await cartRepository.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); - } + if (cart) { + return cart; } - if (!cart) throw new Error("Failed to create cart"); + // Si no se encontró un carrito creamos uno nuevo - return cart; + // 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 (!newCart) throw new Error("Failed to create cart"); + + return { + ...newCart, + items: newCart.items.map((item) => ({ + ...item, + product: { + ...item.product, + price: item.product.price.toNumber(), + }, + })), + }; } 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 +148,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,16 +158,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( - userId, - cart.sessionCartId, - cart.id - ); + const updatedCart = await getCart(userId, cart.sessionCartId, cart.id); if (!updatedCart) throw new Error("Cart not found after update"); @@ -97,17 +186,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,43 +208,136 @@ 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"); - return updatedCart; + return { + ...updatedCart, + items: updatedCart.items.map((item) => ({ + ...item, + product: { + ...item.product, + price: item.product.price.toNumber(), + }, + })), + }; } 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 (!mergedCart) throw new Error("Cart not found after merging"); + 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, + items: updatedCart.items.map((item) => ({ + ...item, + product: { + ...item.product, + price: item.product.price.toNumber(), + }, + })), + }; + } - return mergedCart; + // 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 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({ + where: { id: guestCart.id }, + }); + + // Devolver el carrito actualizado del usuario + return await getCart(userId); } 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..87d1c96 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/client.js"; + +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`); diff --git a/src/services/order.service.test.ts b/src/services/order.service.test.ts index 28e99ff..f6bcf52 100644 --- a/src/services/order.service.test.ts +++ b/src/services/order.service.test.ts @@ -3,20 +3,32 @@ 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"); vi.mock("@/session.server"); describe("Order Service", () => { @@ -37,85 +49,125 @@ 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 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 +178,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..99f7623 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"; @@ -11,31 +11,99 @@ export async function createOrder( formData: OrderDetails ): Promise { const shippingDetails = formData; - 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, + ...shippingDetails, + 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; + 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, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + })), + createdAt: order.createdAt, + updatedAt: order.updatedAt, + 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 orderRepository.getOrdersByUserId(userId); - - return orders; + const orders = await prisma.order.findMany({ + where: { userId }, + include: { + items: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + return orders.map((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), + createdAt: item.createdAt, + updatedAt: item.updatedAt, + })), + createdAt: order.createdAt, + updatedAt: order.updatedAt, + details, + ...details, + }; + }); } diff --git a/src/services/product.service.test.ts b/src/services/product.service.test.ts index ea57513..721b428 100644 --- a/src/services/product.service.test.ts +++ b/src/services/product.service.test.ts @@ -3,13 +3,23 @@ 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 +40,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 +70,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 +79,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 +96,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 }, + }); }); }); }); diff --git a/src/services/product.service.ts b/src/services/product.service.ts index 0593f26..3406570 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 { prisma } from "@/db/prisma"; +import type { Category } from "@/models/category.model"; +import type { Product } from "@/models/product.model"; import { getCategoryBySlug } from "./category.service"; @@ -8,19 +8,31 @@ 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; + return products.map((product) => ({ + ...product, + price: product.price.toNumber(), + })); } 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"); } - return product; + return { ...product, price: product.price.toNumber() }; +} + +export async function getAllProducts(): Promise { + 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 ffe8965..dc69ff3 100644 --- a/src/services/user.service.test.ts +++ b/src/services/user.service.test.ts @@ -1,20 +1,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { prisma } from "@/db/prisma"; import { hashPassword } from "@/lib/security"; import { createMockSession, createTestRequest, createTestUser, } from "@/lib/utils.tests"; -import * as userRepository from "@/repositories/user.repository"; import { getSession } from "@/session.server"; 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 +37,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 +59,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); @@ -87,16 +99,15 @@ describe("user service", () => { id: 10, }); - // 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 +118,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..b20e923 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -1,6 +1,6 @@ import { hashPassword } from "@/lib/security"; -import type { User, AuthResponse, CreateUserDTO } from "@/models/user.model"; -import * as userRepository from "@/repositories/user.repository"; +import type { User, AuthResponse } from "@/models/user.model"; +import { prisma } from "@/db/prisma"; import { getSession } from "@/session.server"; export async function updateUser( @@ -14,30 +14,35 @@ export async function updateUser( throw new Error("User not authenticated"); } + const data = { ...updatedUser } as any; + 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;