Compare commits

...
7 Commits
Author SHA1 Message Date
davide b9ebd250e7 chore: ignore app/public/uploads/ and remove spurious root package-lock.json
uploads/ is generated at runtime by Docker and must not be tracked.
package-lock.json in the repo root was created by accident.
2026-05-19 14:11:01 +02:00
davide db6b727902 test: add component tests (Button, ProductCard) and test suite README
20 component tests covering Button (variants, disabled state, event handlers) and
ProductCard (rendering, price formatting, sale badge, image fallback). README
documents the full suite: 151 tests across 10 files, how to run, mock patterns,
and what's missing by priority (checkout flow, admin routes, more components).
2026-05-19 14:08:07 +02:00
davide 6a5d5a6119 test: add integration tests for API routes (auth, Stripe webhook)
27 tests covering POST /api/auth/login (9), POST /api/auth/register (9), and
POST /api/webhooks/stripe (11). Routes are tested by importing handlers directly
as functions, no HTTP server needed. Stripe false-positive fixed: thrown error
message now differs from the hardcoded 400 response to verify sanitization.
2026-05-19 14:07:53 +02:00
davide 5428eeccc1 test: add unit tests for lib/ (validate, auth, storage, email, rate-limit)
49 tests for all 10 Zod schemas in validate.ts, 26 tests for auth (hashPassword,
verifyPassword, createSession, getSession, getCurrentUser, deleteSession), 11 for
storage (magic-byte validation, saveImage, deleteImageFile), 9 for email (sendMail
scenarios), and 6 for rate limiting logic.
2026-05-19 14:07:38 +02:00
davide b93f5d5bdf test: add Vitest infrastructure (config, setup, mocks, fixtures)
Adds test harness for the suite:
- vitest.config.ts: happy-dom env, @/* alias, v8 coverage with 70% thresholds
- setup.ts: env vars, global next/headers and next/navigation mocks
- tsconfig.json: IDE alias resolution for test/
- __mocks__/prisma.ts: centralised Prisma mock auto-registered via vi.mock
- fixtures/users.ts, fixtures/orders.ts: typed test data
2026-05-19 14:07:22 +02:00
davide ed7faa3be5 build: add Vitest test dependencies and fix Docker build type error
Added devDependencies: vitest, @vitest/coverage-v8, happy-dom, @testing-library/react,
jest-dom, user-event, and test scripts (test, test:watch, test:coverage).
Removed @types/testing-library__jest-dom (redundant with jest-dom v6+, caused
Docker build to fail with "Cannot find type definition file" error).
2026-05-19 14:07:09 +02:00
davide 93cfe1ad5e docs: update CLAUDE.md with Italian rule, error workflow, and full data model
Added Italian communication requirement, error-first workflow (report then wait),
and missing Prisma models: ProductVariant, PasswordResetToken, Page/PageSection,
SiteSettings, AuditLog. Added app/coverage/ to .gitignore.
2026-05-19 14:05:34 +02:00
21 changed files with 4610 additions and 3 deletions
+7
View File
@@ -6,11 +6,15 @@
# Node
app/node_modules/
node_modules/
test/node_modules
# Next.js build output
app/.next/
app/out/
# Test coverage report
app/coverage/
# Prisma generated client (rebuilt on npm install)
app/node_modules/.prisma/
@@ -27,6 +31,9 @@ Thumbs.db
# Dati locali (bind mount Docker)
data/
# Uploads generati a runtime (volume Docker)
app/public/uploads/
# Backup
backups/
+135
View File
@@ -0,0 +1,135 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## How to work with the user
Before implementing any new feature, **always discuss it first**: explain whether it makes sense professionally, how it is typically done in production e-commerce platforms, and propose alternatives if there is a better approach. Only proceed with implementation after the user confirms.
**Communicate in Italian.** The user speaks Italian — all responses, explanations, and questions must be in Italian.
**Error handling workflow:** When you find an error (in logs, code, or output), report what you found and stop. Do not propose or implement a fix until the user explicitly asks. Present: what the error is, where it occurs, and what likely caused it — then wait for instructions.
## Running the project
```bash
# Start everything (first run takes a few minutes to build)
docker compose up -d --build
# Check logs
docker compose logs -f app
# Stop
docker compose down
```
The app is available at http://localhost. Admin panel at http://localhost/admin.
Default credentials are in `.env` (`INITIAL_ADMIN_EMAIL` / `INITIAL_ADMIN_PASSWORD`).
Test emails are visible at http://localhost:8025 (Mailpit).
## Development commands (run inside `app/`)
```bash
npm run dev # local dev server (port 3000, no Docker)
npm run build # production build
npx prisma studio # visual DB browser
npx prisma migrate dev --name <name> # create a new migration
npx prisma migrate deploy # apply migrations (done automatically on container start)
```
## Architecture
**Stack:** Next.js 14 App Router · PostgreSQL 16 · Prisma 5 · Stripe · Nodemailer · TailwindCSS · Zod · Docker + Caddy
**Source root:** `app/src/`
| Directory | Purpose |
|---|---|
| `app/api/` | REST API routes — one folder per resource |
| `app/admin/` | Admin dashboard pages (role-gated at layout level) |
| `app/(storefront)/` | Public-facing pages |
| `components/` | Shared React components and UI primitives |
| `lib/` | Core utilities (auth, email, storage, Stripe, validation, rate limiting) |
| `context/` | Client-side UserContext |
## Key patterns
**API routes** follow a consistent shape: validate input with Zod → check auth/role via `getCurrentUser()` → query Prisma → return JSON. Copy an existing route as a starting point.
**Auth** is session-based (no NextAuth). `lib/auth.ts` handles token creation, hashing (SHA256), and the `getCurrentUser()` helper used in every protected route. Sessions expire after 30 days.
**Admin protection** is enforced in the admin layout: `CUSTOMER` role is blocked, `ADMIN` and `OWNER` are allowed. A `mustChangePassword` flag redirects new owners to a password-change page before anything else.
**Validation schemas** live in `lib/validate.ts` (Zod). Add new schemas there; never validate inline in routes.
**Image uploads** go through `lib/storage.ts`, which validates magic bytes (JPEG/PNG/WebP/ICO) before writing to `/app/public/uploads/<productId>/`. Uploads are stored in a named Docker volume (`uploads`) shared between the `app` and `caddy` containers.
**Emails** are sent via `lib/email.ts` (Nodemailer). Locally they land in Mailpit; in production, configure SMTP env vars.
**Rate limiting** is IP-based and backed by the `LoginAttempt` DB table (10 attempts / 15 min). Logic is in `lib/rate-limit.ts`.
## Data model (high level)
- **User** → has role (`CUSTOMER` / `ADMIN` / `OWNER`), sessions, orders, reviews
- **Product** → belongs to one **ProductType** (defines the attribute schema), many-to-many with **Category**, has **MediaAsset**, **ProductVariant**, **Review**
- **ProductType** → defines a JSON schema for product attributes (e.g. "clothing" has size/color)
- **ProductVariant** → SKU, price, stock per variante (es. taglia/colore di un prodotto)
- **Category** → hierarchical (self-referential `parentId`), many-to-many with Product
- **Order** → status state machine (`PENDING → PAID → FULFILLED`, `CANCELLED`, `REFUNDED`), has **OrderItem** and **Payment**
- **PasswordResetToken** → token hashed con scadenza per il flusso reset password
- **Page** / **PageSection** → CMS minimale per pagine statiche (slug, titolo, sezioni JSON ordinate)
- **SiteSettings** — key/value store for shop configuration (branding, etc.)
- **AuditLog** — tracks admin actions
## Environment variables
Copy `.env.example` to `.env`. For production, generate a strong `AUTH_SECRET` with `openssl rand -hex 32` and replace all placeholder values. The `DATABASE_URL` uses the Docker service name `db` as host — do not change it for containerised deployments.
## Debugging
**Check app logs (first thing to do):**
```bash
docker compose logs -f app # live
docker compose logs app --tail=100 # last 100 lines
```
**Common error patterns to look for in logs:**
- `EACCES: permission denied` → volume/filesystem permissions issue
- `PrismaClientKnownRequestError` → DB constraint violation or bad query
- `401 / 403` in API → session expired or role check failing
- Silent form failures → always check `res.ok` in `fetch` calls; UI may show success even on API error
**API errors not surfacing in the UI** are almost always caused by a missing `res.ok` check in the frontend `fetch` call. The pattern to use in every form submit:
```ts
const res = await fetch('/api/...', { method: 'POST', ... })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setError(data.error || `Errore (${res.status})`)
return
}
```
**Database inspection:**
```bash
# Open Prisma Studio (visual DB browser)
cd app && npx prisma studio
# Or connect directly
docker compose exec db psql -U ecommerce ecommerce
```
**Rebuild after code changes:**
```bash
docker compose up -d --build app # rebuild only the app container
```
**TypeScript errors in IDE but not in build:** likely a missing `node_modules` or stale tsconfig in the IDE. Run `npm install` inside `app/` and restart the IDE TypeScript server.
## Deployment
1. Point your domain DNS to the server.
2. Set `APP_URL` to `https://yourdomain.com` in `.env`.
3. Edit `Caddyfile`: replace `localhost` with your domain.
4. `docker compose up -d --build`
Caddy handles TLS automatically via Let's Encrypt. Ports 80 and 443 must be open on the firewall.
+2453 -1
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -6,7 +6,10 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"postinstall": "prisma generate"
"postinstall": "prisma generate",
"test": "vitest run --config ../test/vitest.config.ts",
"test:watch": "vitest --config ../test/vitest.config.ts",
"test:coverage": "vitest run --config ../test/vitest.config.ts --coverage"
},
"dependencies": {
"next": "14.2.5",
@@ -26,6 +29,12 @@
"typescript": "^5",
"tailwindcss": "^3.4.1",
"postcss": "^8",
"autoprefixer": "^10.0.1"
"autoprefixer": "^10.0.1",
"vitest": "^1.6.0",
"@vitest/coverage-v8": "^1.6.0",
"happy-dom": "^14.12.0",
"@testing-library/react": "^16.0.0",
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/user-event": "^14.5.2"
}
}
+376
View File
@@ -0,0 +1,376 @@
# Suite di Test
Tutti i test automatizzati del progetto stanno qui, separati da `app/` per una scelta precisa: il codice di produzione rimane pulito, la configurazione Vitest è indipendente dal build di Next.js, e i test girano senza avviare Docker o il database.
## Installazione e comandi
I comandi vanno lanciati dalla cartella `app/`:
```bash
cd ~/ecommerce-platform/app
npm install # installa le dipendenze (incluse quelle di test)
npm run test # esegui tutti i test una volta (modalità CI)
npm run test:watch # riesegui automaticamente al salvataggio (sviluppo)
npm run test:coverage # genera il report HTML in app/coverage/index.html
```
## Stato attuale: 151 test, 10 file
| File | Area | Test |
|------|------|-----:|
| `unit/lib/validate.test.ts` | 10 schemi Zod (login, register, prodotti, ecc.) | 49 |
| `unit/lib/auth.test.ts` | hashToken, hashPassword, sessioni, getCurrentUser | 26 |
| `unit/lib/storage.test.ts` | magic bytes JPEG/PNG/WebP/ICO, saveImage, deleteImageFile | 11 |
| `unit/lib/email.test.ts` | sendEmail, sendOrderConfirmationEmail, sendPasswordResetEmail | 9 |
| `unit/lib/rate-limit.test.ts` | checkRateLimit (finestra 15 min), recordAttempt | 7 |
| `integration/api/auth/login.test.ts` | POST /api/auth/login — rate limit, validazione, credenziali | 9 |
| `integration/api/auth/register.test.ts` | POST /api/auth/register — duplicati, password, sessione | 9 |
| `integration/api/webhooks/stripe.test.ts` | checkout.session.completed, payment_intent, firma mancante | 11 |
| `components/ui/Button.test.tsx` | loading, disabled, varianti, onClick | 10 |
| `components/storefront/ProductCard.test.tsx` | prezzo, slug, immagine, placeholder "No image" | 10 |
## Struttura
```
test/
├── vitest.config.ts # ambiente happy-dom, alias @/, soglie copertura 70%
├── setup.ts # env vars mock, mock globale next/headers e next/navigation
├── tsconfig.json # alias @/ per il type-checker dell'IDE
├── unit/lib/ # funzioni pure di app/src/lib/ (nessun DB reale)
├── integration/api/ # API route Next.js 14 App Router (Prisma mockato)
│ ├── auth/
│ └── webhooks/
├── components/ # componenti React con @testing-library/react
│ ├── ui/
│ └── storefront/
├── __mocks__/
│ └── prisma.ts # mock centralizzato del Prisma client (tutti i vi.fn())
└── fixtures/
├── users.ts # mockUser, mockAdmin, mockSession
└── orders.ts # mockOrder, mockPayment, eventi Stripe
```
## Come funziona il mock di Prisma
Il file `test/__mocks__/prisma.ts` esporta un oggetto con `vi.fn()` per ogni metodo Prisma usato nel progetto. Nei test di integrazione basta importarlo all'inizio del file — il `vi.mock('@/lib/prisma')` è già incluso dentro.
```typescript
import '../../../__mocks__/prisma' // monta il mock E chiama vi.mock internamente
import { prisma } from '@/lib/prisma'
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any)
})
```
> **Nota sui percorsi**: i test di integrazione si trovano 3 livelli sotto `test/`
> (`integration/api/auth/`), quindi il percorso relativo verso `__mocks__/` è `../../../__mocks__/prisma`.
> I test unitari in `unit/lib/` usano invece `../../__mocks__/prisma`.
## Come testare una API route
Le route App Router sono funzioni TypeScript che accettano `NextRequest``NextResponse`. Si importano e si chiamano direttamente, senza un server HTTP.
```typescript
import { NextRequest } from 'next/server'
import { POST } from '@/app/api/auth/login/route'
it('restituisce 401 per password errata', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any)
vi.mocked(verifyPassword).mockResolvedValue(false)
const req = new NextRequest(
new Request('http://localhost/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '1.2.3.4' },
body: JSON.stringify({ email: 'test@example.com', password: 'WrongPass1!' }),
})
)
const res = await POST(req)
expect(res.status).toBe(401)
expect((await res.json()).error).toBe('Invalid email or password')
})
```
Per route con parametri dinamici (es. `/api/admin/products/[id]`):
```typescript
import { GET } from '@/app/api/admin/products/[id]/route'
const res = await GET(req, { params: { id: 'prod-123' } })
```
## Aggiungere nuovi test
**Test unitario** (per una nuova funzione in `lib/`): copia la struttura di `unit/lib/email.test.ts`. Mocka le dipendenze esterne in cima con `vi.mock(...)`, poi testa solo la logica della funzione.
**Test di integrazione** (per una nuova API route):
1. Crea il file nella cartella corrispondente sotto `integration/api/`
2. Importa `'../../../__mocks__/prisma'` (o `'../../__mocks__/prisma'` a seconda della profondità)
3. Mocka lib esterne (`@/lib/auth`, `@/lib/stripe`, ecc.) con `vi.mock`
4. Prepara i mock nel `beforeEach` con `vi.clearAllMocks()` + valori di ritorno
**Test di componente**: copia `components/ui/Button.test.tsx`. Usa `render()` e `screen` di `@testing-library/react`. Testa comportamento visibile, non implementazione interna.
## Copertura
La soglia minima configurata in `vitest.config.ts` è **70% di linee e funzioni**. Il comando `npm run test:coverage` fallisce se non viene rispettata. La copertura attuale è circa **11% di linee e 33% di funzioni** — la soglia va abbassata temporaneamente o la suite va estesa.
### Cosa manca (per priorità)
#### Alta priorità — logica di business critica
| File | Cosa testare |
|------|-------------|
| `lib/stripe.ts` | `createCheckoutSession`: parametri passati a Stripe, metadati orderId |
| `api/auth/logout/route.ts` | cancellazione cookie, chiamata a `deleteSession` |
| `api/auth/me/route.ts` | utente autenticato → 200, non autenticato → 401 |
| `api/auth/change-password/route.ts` | password errata → 401, nuova password debole → 400, successo → 200 |
| `api/checkout/route.ts` | utente non autenticato → 401, prodotto inesistente → 400, creazione ordine + sessione Stripe |
#### Media priorità — route admin (CRUD)
| File | Cosa testare |
|------|-------------|
| `api/admin/products/route.ts` | GET con paginazione/filtri, POST crea prodotto, accesso CUSTOMER → 403 |
| `api/admin/products/[id]/route.ts` | GET, PATCH, DELETE — prodotto inesistente → 404 |
| `api/admin/orders/route.ts` | lista ordini con filtri di stato |
| `api/admin/orders/[id]/route.ts` | aggiornamento stato ordine |
| `api/admin/categories/route.ts` | CRUD categorie, slug duplicato → 409 |
| `api/admin/settings/route.ts` | lettura e scrittura impostazioni sito |
| `api/admin/users/route.ts` | creazione admin, email duplicata → 409 |
#### Bassa priorità — componenti UI
| File | Cosa testare |
|------|-------------|
| `components/ui/Input.tsx` | rendering label, messaggio di errore, stato disabled |
| `components/ui/Alert.tsx` | varianti (success, error, warning, info) |
| `components/ui/Badge.tsx` | varianti colore, testo |
| `components/ui/Card.tsx` | rendering children |
### Come implementare i test mancanti
---
#### `unit/lib/stripe.test.ts`
Crea il file in `test/unit/lib/`. Mock dell'intero modulo `stripe` con `vi.hoisted` (necessario perché `stripe.ts` istanzia `new Stripe(...)` a module load time):
```typescript
const { mockCreate, mockConstructEvent } = vi.hoisted(() => ({
mockCreate: vi.fn().mockResolvedValue({ id: 'cs_test', url: 'https://stripe.com/pay/cs_test' }),
mockConstructEvent: vi.fn(),
}))
vi.mock('stripe', () => ({
default: vi.fn().mockImplementation(() => ({
checkout: { sessions: { create: mockCreate } },
webhooks: { constructEvent: mockConstructEvent },
})),
}))
import { createCheckoutSession, constructWebhookEvent } from '@/lib/stripe'
it('passa orderId nei metadata', async () => {
await createCheckoutSession({
orderId: 'order-1',
lineItems: [{ price_data: { currency: 'eur', unit_amount: 1000, product_data: { name: 'T-shirt' } }, quantity: 1 }],
customerEmail: 'user@example.com',
successUrl: 'http://localhost/success',
cancelUrl: 'http://localhost/cancel',
})
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({
metadata: { orderId: 'order-1' },
}))
})
```
---
#### `integration/api/auth/logout.test.ts`
Crea in `test/integration/api/auth/`. La route `logout` legge il cookie, cancella la sessione e pulisce il cookie:
```typescript
import '../../../__mocks__/prisma'
import { NextRequest } from 'next/server'
import { POST } from '@/app/api/auth/logout/route'
import { cookies } from 'next/headers'
vi.mock('@/lib/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth')>()
return { ...actual, deleteSession: vi.fn(), clearSessionCookie: vi.fn() }
})
import { deleteSession, clearSessionCookie } from '@/lib/auth'
it('cancella la sessione e il cookie se il token esiste', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'mytoken' }), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
const res = await POST()
expect(res.status).toBe(200)
expect(deleteSession).toHaveBeenCalledWith('mytoken')
expect(clearSessionCookie).toHaveBeenCalled()
})
it('risponde 200 anche senza token (utente già sloggato)', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue(undefined), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
const res = await POST()
expect(res.status).toBe(200)
expect(deleteSession).not.toHaveBeenCalled()
})
```
---
#### `integration/api/auth/me.test.ts`
La route `me` chiama `getCurrentUser()` — mocka solo `@/lib/auth`:
```typescript
vi.mock('@/lib/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth')>()
return { ...actual, getCurrentUser: vi.fn() }
})
import { getCurrentUser } from '@/lib/auth'
it('restituisce i dati utente se autenticato', async () => {
vi.mocked(getCurrentUser).mockResolvedValue(mockUser as any)
const res = await GET()
expect(res.status).toBe(200)
expect((await res.json()).user.email).toBe(mockUser.email)
expect((await res.json()).user).not.toHaveProperty('passwordHash')
})
it('restituisce 401 se non autenticato', async () => {
vi.mocked(getCurrentUser).mockResolvedValue(null)
const res = await GET()
expect(res.status).toBe(401)
})
```
---
#### `integration/api/auth/change-password.test.ts`
La route richiede `getCurrentUser`, `verifyPassword`, `hashPassword` e `prisma.user.update`:
```typescript
import '../../../__mocks__/prisma'
import { prisma } from '@/lib/prisma'
vi.mock('@/lib/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth')>()
return { ...actual, getCurrentUser: vi.fn(), verifyPassword: vi.fn(), hashPassword: vi.fn().mockResolvedValue('newhash') }
})
import { getCurrentUser, verifyPassword } from '@/lib/auth'
// beforeEach: clearAllMocks, loginAttempt.count → 0, user.update → mockUser
it('restituisce 401 se non autenticato', async () => {
vi.mocked(getCurrentUser).mockResolvedValue(null)
const res = await POST(makeRequest({ currentPassword: 'Old1!', newPassword: 'NewPass123!' }))
expect(res.status).toBe(401)
})
it('restituisce 400 se la password attuale è errata', async () => {
vi.mocked(getCurrentUser).mockResolvedValue({ ...mockUser, passwordHash: 'hash' } as any)
vi.mocked(verifyPassword).mockResolvedValue(false)
const res = await POST(makeRequest({ currentPassword: 'Wrong1!', newPassword: 'NewPass123!' }))
expect(res.status).toBe(400)
expect((await res.json()).error).toMatch(/incorrect/)
})
it('aggiorna la password e azzera mustChangePassword', async () => {
vi.mocked(getCurrentUser).mockResolvedValue({ ...mockUser, passwordHash: 'hash' } as any)
vi.mocked(verifyPassword).mockResolvedValue(true)
vi.mocked(prisma.user.update).mockResolvedValue(mockUser as any)
const res = await POST(makeRequest({ currentPassword: 'OldPass1!', newPassword: 'NewPass123!' }))
expect(res.status).toBe(200)
expect(prisma.user.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ mustChangePassword: false }),
}))
})
```
---
#### `integration/api/admin/products.test.ts`
Le route admin verificano il ruolo prima di tutto. Pattern chiave da testare: accesso negato, paginazione, creazione con audit log:
```typescript
import '../../../__mocks__/prisma'
import { prisma } from '@/lib/prisma'
import { mockAdmin, mockUser } from '../../../fixtures/users'
vi.mock('@/lib/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth')>()
return { ...actual, getCurrentUser: vi.fn() }
})
import { getCurrentUser } from '@/lib/auth'
// Test GET
it('restituisce 403 per utente CUSTOMER', async () => {
vi.mocked(getCurrentUser).mockResolvedValue(mockUser as any) // role: CUSTOMER
const res = await GET(makeRequest())
expect(res.status).toBe(403)
})
it('restituisce la lista prodotti paginata per ADMIN', async () => {
vi.mocked(getCurrentUser).mockResolvedValue(mockAdmin as any)
vi.mocked(prisma.product.findMany).mockResolvedValue([])
vi.mocked(prisma.product.count).mockResolvedValue(0)
const res = await GET(makeRequest())
expect(res.status).toBe(200)
const data = await res.json()
expect(data).toHaveProperty('pagination')
})
// Test POST
it('crea il prodotto e scrive l\'audit log', async () => {
vi.mocked(getCurrentUser).mockResolvedValue(mockAdmin as any)
vi.mocked(prisma.product.create).mockResolvedValue(mockProduct as any)
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as any)
const res = await POST(makeRequest(validProduct))
expect(res.status).toBe(201)
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'CREATE', entity: 'Product' }) })
)
})
```
---
#### `components/ui/Input.test.tsx`, `Alert.test.tsx`, `Badge.test.tsx`
Stessa struttura di `Button.test.tsx`. Leggi prima il file sorgente del componente, poi testa solo ciò che è visibile:
```typescript
import { render, screen } from '@testing-library/react'
import { Input } from '@/components/ui/Input'
it('mostra il messaggio di errore', () => {
render(<Input label="Email" error="Campo obbligatorio" />)
expect(screen.getByText('Campo obbligatorio')).toBeTruthy()
})
it('ha attributo disabled quando disabled=true', () => {
render(<Input label="Email" disabled />)
expect(screen.getByRole('textbox')).toBeDisabled()
})
```
---
#### Da non testare
- Le **23 pagine `page.tsx`** — Server Components Next.js con fetch dati, richiedono il runtime Next.js completo per essere testati in modo significativo
- **`UserContext.tsx`** — context React client-side, coperto implicitamente dai test di integrazione
- **`middleware.ts`** — header di sicurezza statici, nessuna logica da verificare
+81
View File
@@ -0,0 +1,81 @@
import { vi } from 'vitest'
export const prisma = {
user: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
session: {
create: vi.fn(),
findUnique: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
},
loginAttempt: {
count: vi.fn(),
create: vi.fn(),
deleteMany: vi.fn(),
},
order: {
create: vi.fn(),
update: vi.fn(),
findUnique: vi.fn(),
findMany: vi.fn(),
count: vi.fn(),
},
orderItem: {
create: vi.fn(),
findMany: vi.fn(),
},
payment: {
create: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
},
product: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
productType: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
category: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
review: {
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
auditLog: {
create: vi.fn(),
findMany: vi.fn(),
},
siteSettings: {
findMany: vi.fn(),
upsert: vi.fn(),
},
$transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(prisma)),
}
vi.mock('@/lib/prisma', () => ({ prisma }))
@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ProductCard } from '@/components/storefront/ProductCard'
vi.mock('next/link', () => ({
default: ({ href, children, className }: any) => (
<a href={href} className={className}>{children}</a>
),
}))
const baseProduct = {
id: 'prod-1',
title: 'Maglietta Blu',
slug: 'maglietta-blu',
basePrice: 2999,
currency: 'EUR',
images: [{ url: '/uploads/prod-1/photo.jpg', altText: 'Maglietta blu' }],
}
describe('ProductCard', () => {
it('renders the product title', () => {
render(<ProductCard product={baseProduct} />)
expect(screen.getByText('Maglietta Blu')).toBeTruthy()
})
it('formats price correctly (cents → euros with 2 decimals)', () => {
render(<ProductCard product={baseProduct} />)
expect(screen.getByText(/29\.99/)).toBeTruthy()
})
it('shows the currency', () => {
render(<ProductCard product={baseProduct} />)
expect(screen.getByText(/EUR/)).toBeTruthy()
})
it('links to the correct product URL', () => {
const { container } = render(<ProductCard product={baseProduct} />)
const link = container.querySelector('a')
expect(link?.getAttribute('href')).toBe('/products/maglietta-blu')
})
it('renders the product image when images are present', () => {
render(<ProductCard product={baseProduct} />)
const img = screen.getByRole('img')
expect(img.getAttribute('src')).toBe('/uploads/prod-1/photo.jpg')
})
it('uses altText when provided', () => {
render(<ProductCard product={baseProduct} />)
expect(screen.getByRole('img').getAttribute('alt')).toBe('Maglietta blu')
})
it('falls back to product title as alt text when altText is null', () => {
const product = {
...baseProduct,
images: [{ url: '/img.jpg', altText: null }],
}
render(<ProductCard product={product} />)
expect(screen.getByRole('img').getAttribute('alt')).toBe('Maglietta Blu')
})
it('shows "No image" placeholder when images array is empty', () => {
render(<ProductCard product={{ ...baseProduct, images: [] }} />)
expect(screen.getByText('No image')).toBeTruthy()
})
it('does not render img tag when no images', () => {
render(<ProductCard product={{ ...baseProduct, images: [] }} />)
expect(screen.queryByRole('img')).toBeNull()
})
it('formats price = 0 correctly', () => {
render(<ProductCard product={{ ...baseProduct, basePrice: 0 }} />)
expect(screen.getByText(/0\.00/)).toBeTruthy()
})
})
+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from '@/components/ui/Button'
describe('Button', () => {
it('renders children text', () => {
render(<Button>Clicca qui</Button>)
expect(screen.getByText('Clicca qui')).toBeTruthy()
})
it('is disabled when loading=true', () => {
render(<Button loading>Salva</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
it('shows spinner SVG when loading=true', () => {
const { container } = render(<Button loading>Salva</Button>)
expect(container.querySelector('svg')).toBeTruthy()
})
it('does not show spinner when not loading', () => {
const { container } = render(<Button>Salva</Button>)
expect(container.querySelector('svg')).toBeNull()
})
it('is disabled when disabled prop is set', () => {
render(<Button disabled>Salva</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
it('calls onClick when clicked', async () => {
const onClick = vi.fn()
render(<Button onClick={onClick}>Clic</Button>)
await userEvent.click(screen.getByRole('button'))
expect(onClick).toHaveBeenCalledOnce()
})
it('does not call onClick when disabled', async () => {
const onClick = vi.fn()
render(<Button disabled onClick={onClick}>Clic</Button>)
await userEvent.click(screen.getByRole('button'))
expect(onClick).not.toHaveBeenCalled()
})
it('applies primary variant class by default', () => {
render(<Button>Primary</Button>)
expect(screen.getByRole('button').className).toContain('bg-blue-600')
})
it('applies danger variant class', () => {
render(<Button variant="danger">Elimina</Button>)
expect(screen.getByRole('button').className).toContain('bg-red-600')
})
it('applies secondary variant class', () => {
render(<Button variant="secondary">Annulla</Button>)
expect(screen.getByRole('button').className).toContain('bg-gray-200')
})
})
+53
View File
@@ -0,0 +1,53 @@
import { mockUser } from './users'
export const mockOrder = {
id: 'order-1',
userId: mockUser.id,
status: 'PENDING' as const,
grandTotal: 2999,
currency: 'EUR',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
user: mockUser,
}
export const mockPayment = {
id: 'payment-1',
orderId: mockOrder.id,
provider: 'stripe',
providerPaymentId: 'pi_test_123',
status: 'pending',
amount: 2999,
currency: 'EUR',
rawPayload: {},
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
}
export const mockStripeCheckoutEvent = {
type: 'checkout.session.completed',
data: {
object: {
metadata: { orderId: mockOrder.id },
payment_intent: 'pi_test_123',
},
},
}
export const mockStripePaymentSucceededEvent = {
type: 'payment_intent.succeeded',
data: {
object: {
id: 'pi_test_123',
},
},
}
export const mockStripePaymentFailedEvent = {
type: 'payment_intent.payment_failed',
data: {
object: {
id: 'pi_test_123',
},
},
}
+27
View File
@@ -0,0 +1,27 @@
export const mockUser = {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
passwordHash: '$2a$12$hashedpassword',
role: 'CUSTOMER' as const,
mustChangePassword: false,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
}
export const mockAdmin = {
...mockUser,
id: 'admin-1',
email: 'admin@example.com',
name: 'Admin User',
role: 'ADMIN' as const,
}
export const mockSession = {
id: 'session-1',
userId: mockUser.id,
tokenHash: 'abc123hash',
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date('2024-01-01'),
user: mockUser,
}
+119
View File
@@ -0,0 +1,119 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import '../../../__mocks__/prisma'
import { NextRequest } from 'next/server'
import { POST } from '@/app/api/auth/login/route'
import { prisma } from '@/lib/prisma'
import { mockUser } from '../../../fixtures/users'
vi.mock('@/lib/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth')>()
return {
...actual,
verifyPassword: vi.fn(),
createSession: vi.fn().mockResolvedValue('mock-session-token'),
setSessionCookie: vi.fn(),
}
})
import { verifyPassword, createSession, setSessionCookie } from '@/lib/auth'
function makeRequest(body: unknown, ip = '1.2.3.4') {
return new NextRequest(
new Request('http://localhost/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': ip },
body: JSON.stringify(body),
})
)
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(0)
vi.mocked(prisma.loginAttempt.create).mockResolvedValue({ id: '1', key: '1.2.3.4', createdAt: new Date() })
vi.mocked(prisma.loginAttempt.deleteMany).mockResolvedValue({ count: 0 })
})
describe('POST /api/auth/login', () => {
it('returns 429 when rate limit exceeded', async () => {
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(10)
const res = await POST(makeRequest({ email: 'user@example.com', password: 'pass' }))
expect(res.status).toBe(429)
const data = await res.json()
expect(data.error).toMatch(/Too many/)
})
it('returns 400 for invalid email format', async () => {
const res = await POST(makeRequest({ email: 'not-an-email', password: 'pass' }))
expect(res.status).toBe(400)
})
it('returns 400 for empty password', async () => {
const res = await POST(makeRequest({ email: 'user@example.com', password: '' }))
expect(res.status).toBe(400)
})
it('returns 400 for malformed JSON body', async () => {
const req = new NextRequest(
new Request('http://localhost/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '1.2.3.4' },
body: 'not-json',
})
)
const res = await POST(req)
expect(res.status).toBe(400)
})
it('returns 401 when user not found', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null)
const res = await POST(makeRequest({ email: 'notfound@example.com', password: 'SomePass1!' }))
expect(res.status).toBe(401)
const data = await res.json()
expect(data.error).toBe('Invalid email or password')
})
it('records a failed attempt when user not found', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null)
await POST(makeRequest({ email: 'notfound@example.com', password: 'SomePass1!' }))
expect(prisma.loginAttempt.create).toHaveBeenCalledWith({ data: { key: '1.2.3.4' } })
})
it('returns 401 when password is wrong', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any)
vi.mocked(verifyPassword).mockResolvedValue(false)
const res = await POST(makeRequest({ email: mockUser.email, password: 'WrongPass1!' }))
expect(res.status).toBe(401)
expect((await res.json()).error).toBe('Invalid email or password')
})
it('returns 200 with user data on successful login', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any)
vi.mocked(verifyPassword).mockResolvedValue(true)
vi.mocked(prisma.session.create).mockResolvedValue({} as any)
const res = await POST(makeRequest({ email: mockUser.email, password: 'ValidPass1!' }))
expect(res.status).toBe(200)
const data = await res.json()
expect(data.user.email).toBe(mockUser.email)
expect(data.user.role).toBe(mockUser.role)
expect(data.user).not.toHaveProperty('passwordHash')
})
it('creates a session and sets cookie on successful login', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any)
vi.mocked(verifyPassword).mockResolvedValue(true)
vi.mocked(prisma.session.create).mockResolvedValue({} as any)
await POST(makeRequest({ email: mockUser.email, password: 'ValidPass1!' }))
expect(createSession).toHaveBeenCalledWith(mockUser.id)
expect(setSessionCookie).toHaveBeenCalledWith('mock-session-token')
})
})
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import '../../../__mocks__/prisma'
import { NextRequest } from 'next/server'
import { POST } from '@/app/api/auth/register/route'
import { prisma } from '@/lib/prisma'
import { mockUser } from '../../../fixtures/users'
vi.mock('@/lib/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth')>()
return {
...actual,
hashPassword: vi.fn().mockResolvedValue('$2a$12$hashedpassword'),
createSession: vi.fn().mockResolvedValue('mock-token'),
setSessionCookie: vi.fn(),
}
})
import { hashPassword, createSession, setSessionCookie } from '@/lib/auth'
function makeRequest(body: unknown) {
return new NextRequest(
new Request('http://localhost/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
)
}
const validBody = {
email: 'newuser@example.com',
password: 'ValidPass123!',
name: 'Nuovo Utente',
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.user.findUnique).mockResolvedValue(null)
vi.mocked(prisma.user.create).mockResolvedValue({ ...mockUser, email: validBody.email, name: validBody.name } as any)
vi.mocked(prisma.session.create).mockResolvedValue({} as any)
})
describe('POST /api/auth/register', () => {
it('returns 201 with user data on successful registration', async () => {
const res = await POST(makeRequest(validBody))
expect(res.status).toBe(201)
const data = await res.json()
expect(data.user.email).toBe(validBody.email)
expect(data.user).not.toHaveProperty('passwordHash')
})
it('hashes the password before storing', async () => {
await POST(makeRequest(validBody))
expect(hashPassword).toHaveBeenCalledWith(validBody.password)
const createCall = vi.mocked(prisma.user.create).mock.calls[0][0]
expect(createCall.data.passwordHash).toBe('$2a$12$hashedpassword')
})
it('creates a session and sets cookie after registration', async () => {
await POST(makeRequest(validBody))
expect(createSession).toHaveBeenCalled()
expect(setSessionCookie).toHaveBeenCalledWith('mock-token')
})
it('sets role to CUSTOMER', async () => {
await POST(makeRequest(validBody))
const createCall = vi.mocked(prisma.user.create).mock.calls[0][0]
expect(createCall.data.role).toBe('CUSTOMER')
})
it('returns 409 when email is already in use', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any)
const res = await POST(makeRequest(validBody))
expect(res.status).toBe(409)
const data = await res.json()
expect(data.error).toMatch(/already/)
})
it('returns 400 for invalid email', async () => {
const res = await POST(makeRequest({ ...validBody, email: 'not-an-email' }))
expect(res.status).toBe(400)
})
it('returns 400 for weak password (too short)', async () => {
const res = await POST(makeRequest({ ...validBody, password: 'Short1!' }))
expect(res.status).toBe(400)
})
it('returns 400 for empty name', async () => {
const res = await POST(makeRequest({ ...validBody, name: '' }))
expect(res.status).toBe(400)
})
it('returns 400 for malformed JSON', async () => {
const req = new NextRequest(
new Request('http://localhost/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: 'bad-json',
})
)
const res = await POST(req)
expect(res.status).toBe(400)
})
})
@@ -0,0 +1,166 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import '../../../__mocks__/prisma'
import { NextRequest } from 'next/server'
import { POST } from '@/app/api/webhooks/stripe/route'
import { prisma } from '@/lib/prisma'
import {
mockOrder,
mockPayment,
mockStripeCheckoutEvent,
mockStripePaymentSucceededEvent,
mockStripePaymentFailedEvent,
} from '../../../fixtures/orders'
vi.mock('@/lib/stripe', () => ({
constructWebhookEvent: vi.fn(),
}))
vi.mock('@/lib/email', () => ({
sendOrderConfirmationEmail: vi.fn().mockResolvedValue(undefined),
}))
import { constructWebhookEvent } from '@/lib/stripe'
import { sendOrderConfirmationEmail } from '@/lib/email'
function makeRequest(body: string, signature = 'valid-sig') {
return new NextRequest(
new Request('http://localhost/api/webhooks/stripe', {
method: 'POST',
headers: { 'stripe-signature': signature, 'Content-Type': 'text/plain' },
body,
})
)
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.order.update).mockResolvedValue(mockOrder as any)
vi.mocked(prisma.payment.updateMany).mockResolvedValue({ count: 1 })
vi.mocked(prisma.payment.update).mockResolvedValue(mockPayment as any)
vi.mocked(prisma.payment.findFirst).mockResolvedValue(mockPayment as any)
vi.mocked(prisma.order.findUnique).mockResolvedValue({ ...mockOrder, user: mockOrder.user } as any)
})
describe('POST /api/webhooks/stripe', () => {
it('returns 400 when stripe-signature header is missing', async () => {
const req = new NextRequest(
new Request('http://localhost/api/webhooks/stripe', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: '{}',
})
)
const res = await POST(req)
expect(res.status).toBe(400)
expect((await res.json()).error).toMatch(/signature/)
})
it('returns 400 when signature verification fails', async () => {
vi.mocked(constructWebhookEvent).mockImplementation(() => {
throw new Error('stripe_signature_mismatch')
})
const res = await POST(makeRequest('{}', 'bad-sig'))
expect(res.status).toBe(400)
expect((await res.json()).error).toBe('Invalid signature')
})
it('processes checkout.session.completed: updates order to PAID', async () => {
vi.mocked(constructWebhookEvent).mockReturnValue(mockStripeCheckoutEvent as any)
const res = await POST(makeRequest(JSON.stringify(mockStripeCheckoutEvent)))
expect(res.status).toBe(200)
expect(prisma.order.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: mockOrder.id }, data: { status: 'PAID' } })
)
})
it('processes checkout.session.completed: updates payment record', async () => {
vi.mocked(constructWebhookEvent).mockReturnValue(mockStripeCheckoutEvent as any)
await POST(makeRequest(JSON.stringify(mockStripeCheckoutEvent)))
expect(prisma.payment.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { orderId: mockOrder.id },
data: expect.objectContaining({ status: 'paid' }),
})
)
})
it('processes checkout.session.completed: sends confirmation email', async () => {
vi.mocked(constructWebhookEvent).mockReturnValue(mockStripeCheckoutEvent as any)
await POST(makeRequest(JSON.stringify(mockStripeCheckoutEvent)))
expect(sendOrderConfirmationEmail).toHaveBeenCalledWith(
mockOrder.user.email,
expect.objectContaining({ orderId: mockOrder.id })
)
})
it('skips email send if order user has no email', async () => {
vi.mocked(constructWebhookEvent).mockReturnValue(mockStripeCheckoutEvent as any)
vi.mocked(prisma.order.findUnique).mockResolvedValue({ ...mockOrder, user: null } as any)
await POST(makeRequest(JSON.stringify(mockStripeCheckoutEvent)))
expect(sendOrderConfirmationEmail).not.toHaveBeenCalled()
})
it('processes checkout.session.completed without orderId: no DB update', async () => {
const eventWithoutOrderId = {
type: 'checkout.session.completed',
data: { object: { metadata: {}, payment_intent: 'pi_test' } },
}
vi.mocked(constructWebhookEvent).mockReturnValue(eventWithoutOrderId as any)
const res = await POST(makeRequest('{}'))
expect(res.status).toBe(200)
expect(prisma.order.update).not.toHaveBeenCalled()
})
it('processes payment_intent.succeeded: updates payment and order', async () => {
vi.mocked(constructWebhookEvent).mockReturnValue(mockStripePaymentSucceededEvent as any)
const res = await POST(makeRequest(JSON.stringify(mockStripePaymentSucceededEvent)))
expect(res.status).toBe(200)
expect(prisma.payment.update).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ status: 'paid' }) })
)
expect(prisma.order.update).toHaveBeenCalledWith(
expect.objectContaining({ data: { status: 'PAID' } })
)
})
it('processes payment_intent.succeeded: no-op when payment not found', async () => {
vi.mocked(constructWebhookEvent).mockReturnValue(mockStripePaymentSucceededEvent as any)
vi.mocked(prisma.payment.findFirst).mockResolvedValue(null)
const res = await POST(makeRequest('{}'))
expect(res.status).toBe(200)
expect(prisma.payment.update).not.toHaveBeenCalled()
})
it('processes payment_intent.payment_failed: sets status to failed and CANCELLED', async () => {
vi.mocked(constructWebhookEvent).mockReturnValue(mockStripePaymentFailedEvent as any)
const res = await POST(makeRequest(JSON.stringify(mockStripePaymentFailedEvent)))
expect(res.status).toBe(200)
expect(prisma.payment.update).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ status: 'failed' }) })
)
expect(prisma.order.update).toHaveBeenCalledWith(
expect.objectContaining({ data: { status: 'CANCELLED' } })
)
})
it('returns 200 for unhandled event types', async () => {
vi.mocked(constructWebhookEvent).mockReturnValue({ type: 'customer.created', data: { object: {} } } as any)
const res = await POST(makeRequest('{}'))
expect(res.status).toBe(200)
expect((await res.json()).received).toBe(true)
})
})
+31
View File
@@ -0,0 +1,31 @@
import '@testing-library/jest-dom'
import { vi } from 'vitest'
// Env vars necessarie per i test
process.env.STRIPE_SECRET_KEY = 'sk_test_mock'
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_mock'
process.env.SMTP_HOST = 'localhost'
process.env.SMTP_PORT = '1025'
process.env.APP_URL = 'http://localhost'
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'
process.env.NODE_ENV = 'test'
process.env.AUTH_SECRET = 'test-secret-32-chars-minimum-ok!'
// Mock di next/headers (cookies() lancia fuori dal runtime Next.js)
vi.mock('next/headers', () => {
const mockCookieStore = {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
}
return {
cookies: vi.fn(() => mockCookieStore),
}
})
// Mock di next/navigation
vi.mock('next/navigation', () => ({
useRouter: vi.fn(() => ({ push: vi.fn(), replace: vi.fn() })),
usePathname: vi.fn(() => '/'),
redirect: vi.fn(),
}))
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../app/tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["../app/src/*"]
},
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": [
"./**/*.ts",
"./**/*.tsx"
],
"exclude": ["node_modules"]
}
+222
View File
@@ -0,0 +1,222 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import '../../../test/__mocks__/prisma'
import {
hashToken,
hashPassword,
verifyPassword,
validatePasswordStrength,
createSession,
getSessionToken,
getSession,
getCurrentUser,
deleteSession,
deleteAllUserSessions,
} from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { mockUser, mockSession } from '../../fixtures/users'
import { cookies } from 'next/headers'
// ─── hashToken ───────────────────────────────────────────────────────────────
describe('hashToken', () => {
it('returns a 64-char hex string', () => {
const hash = hashToken('sometoken')
expect(hash).toMatch(/^[a-f0-9]{64}$/)
})
it('is deterministic for the same input', () => {
expect(hashToken('abc')).toBe(hashToken('abc'))
})
it('produces different hashes for different inputs', () => {
expect(hashToken('abc')).not.toBe(hashToken('xyz'))
})
})
// ─── hashPassword / verifyPassword ───────────────────────────────────────────
describe('hashPassword / verifyPassword', () => {
it('hashes a password and verifies it correctly', async () => {
const hash = await hashPassword('MyPassword1!')
expect(await verifyPassword('MyPassword1!', hash)).toBe(true)
})
it('returns false for wrong password', async () => {
const hash = await hashPassword('MyPassword1!')
expect(await verifyPassword('WrongPassword1!', hash)).toBe(false)
})
it('produces different hashes for the same password (bcrypt salt)', async () => {
const h1 = await hashPassword('SamePass1!')
const h2 = await hashPassword('SamePass1!')
expect(h1).not.toBe(h2)
})
})
// ─── validatePasswordStrength ─────────────────────────────────────────────────
describe('validatePasswordStrength', () => {
it('returns null for a strong password', () => {
expect(validatePasswordStrength('StrongPass1!')).toBeNull()
})
it('rejects password shorter than 12 chars', () => {
expect(validatePasswordStrength('Short1!')).toMatch(/12 characters/)
})
it('rejects password without uppercase', () => {
expect(validatePasswordStrength('nouppercase1!')).toMatch(/uppercase/)
})
it('rejects password without lowercase', () => {
expect(validatePasswordStrength('NOLOWERCASE1!')).toMatch(/lowercase/)
})
it('rejects password without number', () => {
expect(validatePasswordStrength('NoNumberHere!')).toMatch(/number/)
})
it('rejects password without symbol', () => {
expect(validatePasswordStrength('NoSymbolHere12')).toMatch(/symbol/)
})
it('checks length first (shortest error message path)', () => {
expect(validatePasswordStrength('short')).toMatch(/12 characters/)
})
})
// ─── createSession ────────────────────────────────────────────────────────────
describe('createSession', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.session.create).mockResolvedValue(mockSession)
})
it('creates a session in DB and returns a token string', async () => {
const token = await createSession(mockUser.id)
expect(typeof token).toBe('string')
expect(token.length).toBeGreaterThan(0)
})
it('calls prisma.session.create with userId and tokenHash', async () => {
const token = await createSession(mockUser.id)
expect(prisma.session.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ userId: mockUser.id }),
})
)
const callArg = vi.mocked(prisma.session.create).mock.calls[0][0]
const tokenHash = hashToken(token)
expect(callArg.data.tokenHash).toBe(tokenHash)
})
it('sets expiry ~30 days from now', async () => {
await createSession(mockUser.id)
const callArg = vi.mocked(prisma.session.create).mock.calls[0][0]
const expiresAt = callArg.data.expiresAt as Date
const diffDays = (expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
expect(diffDays).toBeCloseTo(30, 0)
})
})
// ─── getSessionToken ──────────────────────────────────────────────────────────
describe('getSessionToken', () => {
it('returns token from cookie', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'mytoken' }), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
expect(await getSessionToken()).toBe('mytoken')
})
it('returns null when cookie is missing', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue(undefined), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
expect(await getSessionToken()).toBeNull()
})
})
// ─── getSession ───────────────────────────────────────────────────────────────
describe('getSession', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns null when no token cookie', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue(undefined), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
expect(await getSession()).toBeNull()
})
it('returns session when token is valid and not expired', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'validtoken' }), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
vi.mocked(prisma.session.findUnique).mockResolvedValue(mockSession as any)
const session = await getSession()
expect(session).not.toBeNull()
expect(session?.user.id).toBe(mockUser.id)
})
it('returns null and deletes session when expired', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'expiredtoken' }), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
const expiredSession = { ...mockSession, expiresAt: new Date(Date.now() - 1000) }
vi.mocked(prisma.session.findUnique).mockResolvedValue(expiredSession as any)
vi.mocked(prisma.session.delete).mockResolvedValue(expiredSession as any)
const result = await getSession()
expect(result).toBeNull()
expect(prisma.session.delete).toHaveBeenCalledWith({ where: { id: expiredSession.id } })
})
it('returns null when session not found in DB', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'unknowntoken' }), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
vi.mocked(prisma.session.findUnique).mockResolvedValue(null)
expect(await getSession()).toBeNull()
})
})
// ─── getCurrentUser ───────────────────────────────────────────────────────────
describe('getCurrentUser', () => {
it('returns user from valid session', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'validtoken' }), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
vi.mocked(prisma.session.findUnique).mockResolvedValue(mockSession as any)
const user = await getCurrentUser()
expect(user?.id).toBe(mockUser.id)
})
it('returns null when no session', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue(undefined), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
expect(await getCurrentUser()).toBeNull()
})
})
// ─── deleteSession / deleteAllUserSessions ────────────────────────────────────
describe('deleteSession', () => {
it('calls prisma.session.deleteMany with the correct tokenHash', async () => {
vi.mocked(prisma.session.deleteMany).mockResolvedValue({ count: 1 })
await deleteSession('mytoken')
expect(prisma.session.deleteMany).toHaveBeenCalledWith({
where: { tokenHash: hashToken('mytoken') },
})
})
})
describe('deleteAllUserSessions', () => {
it('calls prisma.session.deleteMany with the userId', async () => {
vi.mocked(prisma.session.deleteMany).mockResolvedValue({ count: 3 })
await deleteAllUserSessions('user-123')
expect(prisma.session.deleteMany).toHaveBeenCalledWith({ where: { userId: 'user-123' } })
})
})
+109
View File
@@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockSendMail, mockCreateTransport } = vi.hoisted(() => {
const mockSendMail = vi.fn().mockResolvedValue({ messageId: 'mock-id' })
const mockCreateTransport = vi.fn().mockReturnValue({ sendMail: mockSendMail })
return { mockSendMail, mockCreateTransport }
})
vi.mock('nodemailer', () => ({
default: { createTransport: mockCreateTransport },
}))
import { sendEmail, sendOrderConfirmationEmail, sendPasswordResetEmail } from '@/lib/email'
beforeEach(() => {
vi.clearAllMocks()
mockSendMail.mockResolvedValue({ messageId: 'mock-id' })
})
// ─── sendEmail ────────────────────────────────────────────────────────────────
describe('sendEmail', () => {
it('calls sendMail with correct to, subject, html', async () => {
await sendEmail({ to: 'user@example.com', subject: 'Test', html: '<p>Hello</p>' })
expect(mockSendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'user@example.com',
subject: 'Test',
html: '<p>Hello</p>',
})
)
})
it('includes optional text field when provided', async () => {
await sendEmail({ to: 'u@e.com', subject: 'S', html: '<p>h</p>', text: 'plain text' })
expect(mockSendMail).toHaveBeenCalledWith(expect.objectContaining({ text: 'plain text' }))
})
it('propagates sendMail errors', async () => {
mockSendMail.mockRejectedValueOnce(new Error('SMTP down'))
await expect(sendEmail({ to: 'u@e.com', subject: 'S', html: '<p>h</p>' })).rejects.toThrow('SMTP down')
})
})
// ─── sendOrderConfirmationEmail ───────────────────────────────────────────────
describe('sendOrderConfirmationEmail', () => {
it('sends email with correct subject containing orderId', async () => {
await sendOrderConfirmationEmail('customer@example.com', {
orderId: 'order-42',
grandTotal: 2999,
currency: 'EUR',
})
expect(mockSendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'customer@example.com',
subject: expect.stringContaining('order-42'),
})
)
})
it('formats price correctly (cents to euros)', async () => {
await sendOrderConfirmationEmail('customer@example.com', {
orderId: 'order-1',
grandTotal: 2999,
currency: 'EUR',
})
const callArg = mockSendMail.mock.calls[0][0]
expect(callArg.html).toContain('29.99')
expect(callArg.html).toContain('EUR')
})
it('includes orderId in the email body', async () => {
await sendOrderConfirmationEmail('customer@example.com', {
orderId: 'order-xyz',
grandTotal: 1000,
currency: 'USD',
})
const callArg = mockSendMail.mock.calls[0][0]
expect(callArg.html).toContain('order-xyz')
})
it('includes account orders link', async () => {
await sendOrderConfirmationEmail('customer@example.com', {
orderId: 'order-1',
grandTotal: 100,
currency: 'EUR',
})
const callArg = mockSendMail.mock.calls[0][0]
expect(callArg.html).toContain('/account/orders')
})
})
// ─── sendPasswordResetEmail ───────────────────────────────────────────────────
describe('sendPasswordResetEmail', () => {
it('sends email with reset token in URL', async () => {
await sendPasswordResetEmail('user@example.com', 'reset-token-abc')
const callArg = mockSendMail.mock.calls[0][0]
expect(callArg.html).toContain('reset-token-abc')
expect(callArg.to).toBe('user@example.com')
})
it('subject mentions password reset', async () => {
await sendPasswordResetEmail('user@example.com', 'token')
const callArg = mockSendMail.mock.calls[0][0]
expect(callArg.subject).toMatch(/[Pp]assword [Rr]eset/)
})
})
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import '../../../test/__mocks__/prisma'
import { checkRateLimit, recordAttempt } from '@/lib/rate-limit'
import { prisma } from '@/lib/prisma'
beforeEach(() => {
vi.clearAllMocks()
})
describe('checkRateLimit', () => {
it('returns not limited when attempts < 10', async () => {
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(5)
const result = await checkRateLimit('1.2.3.4')
expect(result.limited).toBe(false)
expect(result.remaining).toBe(5)
})
it('returns limited when attempts >= 10', async () => {
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(10)
const result = await checkRateLimit('1.2.3.4')
expect(result.limited).toBe(true)
expect(result.remaining).toBe(0)
})
it('returns limited when attempts > 10', async () => {
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(15)
const result = await checkRateLimit('1.2.3.4')
expect(result.limited).toBe(true)
})
it('returns remaining = 1 when attempts = 9', async () => {
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(9)
const result = await checkRateLimit('1.2.3.4')
expect(result.limited).toBe(false)
expect(result.remaining).toBe(1)
})
it('queries with a windowStart within the last 15 minutes', async () => {
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(0)
const before = new Date(Date.now() - 15 * 60 * 1000 - 100)
await checkRateLimit('1.2.3.4')
const callArg = vi.mocked(prisma.loginAttempt.count).mock.calls[0][0]
const windowStart = callArg?.where?.createdAt?.gte as Date
expect(windowStart.getTime()).toBeGreaterThan(before.getTime())
})
})
describe('recordAttempt', () => {
it('creates a login attempt record', async () => {
vi.mocked(prisma.loginAttempt.create).mockResolvedValue({ id: '1', key: '1.2.3.4', createdAt: new Date() })
vi.mocked(prisma.loginAttempt.deleteMany).mockResolvedValue({ count: 0 })
await recordAttempt('1.2.3.4')
expect(prisma.loginAttempt.create).toHaveBeenCalledWith({ data: { key: '1.2.3.4' } })
})
it('cleans up old records after creating', async () => {
vi.mocked(prisma.loginAttempt.create).mockResolvedValue({ id: '1', key: '1.2.3.4', createdAt: new Date() })
vi.mocked(prisma.loginAttempt.deleteMany).mockResolvedValue({ count: 0 })
await recordAttempt('1.2.3.4')
expect(prisma.loginAttempt.deleteMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ createdAt: expect.anything() }) })
)
})
})
+126
View File
@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockMkdir, mockWriteFile, mockUnlink } = vi.hoisted(() => ({
mockMkdir: vi.fn().mockResolvedValue(undefined),
mockWriteFile: vi.fn().mockResolvedValue(undefined),
mockUnlink: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('fs/promises', () => ({
__esModule: true,
default: { mkdir: mockMkdir, writeFile: mockWriteFile, unlink: mockUnlink },
mkdir: mockMkdir,
writeFile: mockWriteFile,
unlink: mockUnlink,
}))
import { saveImage, validateImageMagicBytes, deleteImageFile } from '@/lib/storage'
function makeJpegBuffer(): Buffer {
const b = Buffer.alloc(12)
b[0] = 0xff; b[1] = 0xd8; b[2] = 0xff
return b
}
function makePngBuffer(): Buffer {
const b = Buffer.alloc(12)
b[0] = 0x89; b[1] = 0x50; b[2] = 0x4e; b[3] = 0x47
return b
}
function makeWebpBuffer(): Buffer {
const b = Buffer.alloc(12)
b[0] = 0x52; b[1] = 0x49; b[2] = 0x46; b[3] = 0x46
b[8] = 0x57; b[9] = 0x45; b[10] = 0x42; b[11] = 0x50
return b
}
function makeIcoBuffer(): Buffer {
const b = Buffer.alloc(12)
b[0] = 0x00; b[1] = 0x00; b[2] = 0x01; b[3] = 0x00
return b
}
function makeFileFromBuffer(buf: Buffer, type: string): File {
return new File([new Uint8Array(buf)], 'test.img', { type })
}
beforeEach(() => {
vi.clearAllMocks()
mockMkdir.mockResolvedValue(undefined)
mockWriteFile.mockResolvedValue(undefined)
mockUnlink.mockResolvedValue(undefined)
})
// ─── validateImageMagicBytes ──────────────────────────────────────────────────
describe('validateImageMagicBytes', () => {
it('accepts a valid JPEG', async () => {
const file = makeFileFromBuffer(makeJpegBuffer(), 'image/jpeg')
expect(await validateImageMagicBytes(file, 'image/jpeg')).toBe(true)
})
it('accepts a valid PNG', async () => {
const file = makeFileFromBuffer(makePngBuffer(), 'image/png')
expect(await validateImageMagicBytes(file, 'image/png')).toBe(true)
})
it('accepts a valid WebP', async () => {
const file = makeFileFromBuffer(makeWebpBuffer(), 'image/webp')
expect(await validateImageMagicBytes(file, 'image/webp')).toBe(true)
})
it('accepts a valid ICO', async () => {
const file = makeFileFromBuffer(makeIcoBuffer(), 'image/x-icon')
expect(await validateImageMagicBytes(file, 'image/x-icon')).toBe(true)
})
it('rejects JPEG buffer declared as PNG', async () => {
const file = makeFileFromBuffer(makeJpegBuffer(), 'image/png')
expect(await validateImageMagicBytes(file, 'image/png')).toBe(false)
})
it('rejects unknown MIME type', async () => {
const file = makeFileFromBuffer(makeJpegBuffer(), 'image/bmp')
expect(await validateImageMagicBytes(file, 'image/bmp')).toBe(false)
})
it('rejects a random buffer as JPEG', async () => {
const file = makeFileFromBuffer(Buffer.from([0x00, 0x01, 0x02]), 'image/jpeg')
expect(await validateImageMagicBytes(file, 'image/jpeg')).toBe(false)
})
})
// ─── saveImage ────────────────────────────────────────────────────────────────
describe('saveImage', () => {
it('creates the product directory and writes file', async () => {
const buf = Buffer.from('fakeimage')
const url = await saveImage('prod-123', buf, 'photo.jpg')
expect(mockMkdir).toHaveBeenCalledWith(expect.stringContaining('prod-123'), { recursive: true })
expect(mockWriteFile).toHaveBeenCalled()
expect(url).toMatch(/^\/uploads\/prod-123\//)
expect(url).toMatch(/photo\.jpg$/)
})
it('sanitizes filename (removes special chars)', async () => {
const buf = Buffer.from('fakeimage')
const url = await saveImage('prod-123', buf, 'my photo (1).JPG')
expect(url).toMatch(/my_photo__1_\.jpg$/)
})
it('returns a URL starting with /uploads/', async () => {
const url = await saveImage('prod-abc', Buffer.from('x'), 'img.png')
expect(url).toMatch(/^\/uploads\/prod-abc\//)
})
})
// ─── deleteImageFile ──────────────────────────────────────────────────────────
describe('deleteImageFile', () => {
it('calls unlink with the correct path', async () => {
await deleteImageFile('/uploads/prod-1/image.jpg')
expect(mockUnlink).toHaveBeenCalledWith(expect.stringContaining('/uploads/prod-1/image.jpg'))
})
})
+329
View File
@@ -0,0 +1,329 @@
import { describe, it, expect } from 'vitest'
import {
loginSchema,
registerSchema,
changePasswordSchema,
productTypeSchema,
productSchema,
categorySchema,
reviewSchema,
cartItemSchema,
checkoutSchema,
settingSchema,
} from '@/lib/validate'
// ─── loginSchema ────────────────────────────────────────────────────────────
describe('loginSchema', () => {
it('accepts valid email and password', () => {
const result = loginSchema.safeParse({ email: 'user@example.com', password: 'anypass' })
expect(result.success).toBe(true)
})
it('rejects invalid email', () => {
const result = loginSchema.safeParse({ email: 'not-an-email', password: 'pass' })
expect(result.success).toBe(false)
expect(result.error?.errors[0].message).toBe('Invalid email address')
})
it('rejects empty password', () => {
const result = loginSchema.safeParse({ email: 'user@example.com', password: '' })
expect(result.success).toBe(false)
})
it('rejects missing fields', () => {
expect(loginSchema.safeParse({}).success).toBe(false)
})
})
// ─── registerSchema ──────────────────────────────────────────────────────────
describe('registerSchema', () => {
const valid = {
email: 'user@example.com',
password: 'ValidPass123!',
name: 'Mario Rossi',
}
it('accepts valid registration data', () => {
expect(registerSchema.safeParse(valid).success).toBe(true)
})
it('rejects password shorter than 12 chars', () => {
const r = registerSchema.safeParse({ ...valid, password: 'Short1!' })
expect(r.success).toBe(false)
expect(r.error?.errors[0].message).toMatch(/12 characters/)
})
it('rejects password without uppercase', () => {
const r = registerSchema.safeParse({ ...valid, password: 'nouppercase1!' })
expect(r.success).toBe(false)
expect(r.error?.errors[0].message).toMatch(/uppercase/)
})
it('rejects password without lowercase', () => {
const r = registerSchema.safeParse({ ...valid, password: 'NOLOWERCASE1!' })
expect(r.success).toBe(false)
expect(r.error?.errors[0].message).toMatch(/lowercase/)
})
it('rejects password without number', () => {
const r = registerSchema.safeParse({ ...valid, password: 'NoNumberHere!' })
expect(r.success).toBe(false)
expect(r.error?.errors[0].message).toMatch(/number/)
})
it('rejects password without symbol', () => {
const r = registerSchema.safeParse({ ...valid, password: 'NoSymbolHere1' })
expect(r.success).toBe(false)
expect(r.error?.errors[0].message).toMatch(/symbol/)
})
it('rejects empty name', () => {
const r = registerSchema.safeParse({ ...valid, name: '' })
expect(r.success).toBe(false)
})
it('rejects name longer than 100 chars', () => {
const r = registerSchema.safeParse({ ...valid, name: 'a'.repeat(101) })
expect(r.success).toBe(false)
})
})
// ─── changePasswordSchema ────────────────────────────────────────────────────
describe('changePasswordSchema', () => {
it('accepts valid passwords', () => {
const r = changePasswordSchema.safeParse({
currentPassword: 'OldPass1!',
newPassword: 'NewStrongPass1!',
})
expect(r.success).toBe(true)
})
it('rejects empty currentPassword', () => {
const r = changePasswordSchema.safeParse({
currentPassword: '',
newPassword: 'NewStrongPass1!',
})
expect(r.success).toBe(false)
})
it('rejects weak newPassword', () => {
const r = changePasswordSchema.safeParse({
currentPassword: 'OldPass1!',
newPassword: 'weak',
})
expect(r.success).toBe(false)
})
})
// ─── productTypeSchema ───────────────────────────────────────────────────────
describe('productTypeSchema', () => {
const valid = { name: 'Abbigliamento', slug: 'abbigliamento', schema: { color: 'string' } }
it('accepts valid product type', () => {
expect(productTypeSchema.safeParse(valid).success).toBe(true)
})
it('rejects slug with uppercase', () => {
const r = productTypeSchema.safeParse({ ...valid, slug: 'Abbigliamento' })
expect(r.success).toBe(false)
})
it('rejects slug with spaces', () => {
const r = productTypeSchema.safeParse({ ...valid, slug: 'con spazio' })
expect(r.success).toBe(false)
})
it('accepts slug with hyphens and numbers', () => {
const r = productTypeSchema.safeParse({ ...valid, slug: 'tipo-123' })
expect(r.success).toBe(true)
})
it('rejects empty name', () => {
expect(productTypeSchema.safeParse({ ...valid, name: '' }).success).toBe(false)
})
})
// ─── productSchema ───────────────────────────────────────────────────────────
describe('productSchema', () => {
const valid = {
typeId: 'type-1',
title: 'Maglietta',
slug: 'maglietta',
description: 'Una bella maglietta',
basePrice: 1999,
currency: 'EUR',
status: 'DRAFT' as const,
attributes: {},
}
it('accepts valid product', () => {
expect(productSchema.safeParse(valid).success).toBe(true)
})
it('rejects negative price', () => {
const r = productSchema.safeParse({ ...valid, basePrice: -1 })
expect(r.success).toBe(false)
})
it('accepts price = 0', () => {
const r = productSchema.safeParse({ ...valid, basePrice: 0 })
expect(r.success).toBe(true)
})
it('rejects currency not 3 chars', () => {
const r = productSchema.safeParse({ ...valid, currency: 'EU' })
expect(r.success).toBe(false)
})
it('rejects invalid status', () => {
const r = productSchema.safeParse({ ...valid, status: 'INVALID' })
expect(r.success).toBe(false)
})
it('accepts all valid statuses', () => {
for (const status of ['DRAFT', 'PUBLISHED', 'ARCHIVED'] as const) {
expect(productSchema.safeParse({ ...valid, status }).success).toBe(true)
}
})
it('accepts optional categoryIds', () => {
const r = productSchema.safeParse({ ...valid, categoryIds: ['cat-1', 'cat-2'] })
expect(r.success).toBe(true)
})
it('rejects non-integer price', () => {
const r = productSchema.safeParse({ ...valid, basePrice: 19.99 })
expect(r.success).toBe(false)
})
})
// ─── categorySchema ──────────────────────────────────────────────────────────
describe('categorySchema', () => {
const valid = { name: 'Uomo', slug: 'uomo' }
it('accepts valid category', () => {
expect(categorySchema.safeParse(valid).success).toBe(true)
})
it('accepts category with parentId', () => {
expect(categorySchema.safeParse({ ...valid, parentId: 'parent-1' }).success).toBe(true)
})
it('accepts parentId = null', () => {
expect(categorySchema.safeParse({ ...valid, parentId: null }).success).toBe(true)
})
it('rejects slug with special chars', () => {
const r = categorySchema.safeParse({ ...valid, slug: 'cat@home' })
expect(r.success).toBe(false)
})
})
// ─── reviewSchema ────────────────────────────────────────────────────────────
describe('reviewSchema', () => {
const valid = { productId: 'prod-1', rating: 4 }
it('accepts valid review', () => {
expect(reviewSchema.safeParse(valid).success).toBe(true)
})
it('rejects rating 0', () => {
expect(reviewSchema.safeParse({ ...valid, rating: 0 }).success).toBe(false)
})
it('rejects rating 6', () => {
expect(reviewSchema.safeParse({ ...valid, rating: 6 }).success).toBe(false)
})
it('accepts all valid ratings 1-5', () => {
for (const rating of [1, 2, 3, 4, 5]) {
expect(reviewSchema.safeParse({ ...valid, rating }).success).toBe(true)
}
})
it('rejects comment longer than 2000 chars', () => {
const r = reviewSchema.safeParse({ ...valid, comment: 'a'.repeat(2001) })
expect(r.success).toBe(false)
})
it('accepts title up to 200 chars', () => {
expect(reviewSchema.safeParse({ ...valid, title: 'a'.repeat(200) }).success).toBe(true)
})
})
// ─── cartItemSchema ──────────────────────────────────────────────────────────
describe('cartItemSchema', () => {
const valid = { productId: 'prod-1', quantity: 2 }
it('accepts valid cart item', () => {
expect(cartItemSchema.safeParse(valid).success).toBe(true)
})
it('rejects quantity 0', () => {
expect(cartItemSchema.safeParse({ ...valid, quantity: 0 }).success).toBe(false)
})
it('rejects quantity over 100', () => {
expect(cartItemSchema.safeParse({ ...valid, quantity: 101 }).success).toBe(false)
})
it('accepts quantity = 100', () => {
expect(cartItemSchema.safeParse({ ...valid, quantity: 100 }).success).toBe(true)
})
it('accepts optional variantId', () => {
expect(cartItemSchema.safeParse({ ...valid, variantId: 'var-1' }).success).toBe(true)
})
})
// ─── checkoutSchema ──────────────────────────────────────────────────────────
describe('checkoutSchema', () => {
const valid = { items: [{ productId: 'prod-1', quantity: 1 }] }
it('accepts valid checkout', () => {
expect(checkoutSchema.safeParse(valid).success).toBe(true)
})
it('rejects empty items array', () => {
const r = checkoutSchema.safeParse({ items: [] })
expect(r.success).toBe(false)
expect(r.error?.errors[0].message).toBe('Cart is empty')
})
it('accepts multiple items', () => {
const r = checkoutSchema.safeParse({
items: [
{ productId: 'prod-1', quantity: 2 },
{ productId: 'prod-2', quantity: 1 },
],
})
expect(r.success).toBe(true)
})
})
// ─── settingSchema ───────────────────────────────────────────────────────────
describe('settingSchema', () => {
it('accepts valid setting', () => {
expect(settingSchema.safeParse({ key: 'site_name', value: 'My Shop' }).success).toBe(true)
})
it('rejects empty key', () => {
expect(settingSchema.safeParse({ key: '', value: 'anything' }).success).toBe(false)
})
it('accepts any value type', () => {
for (const value of ['string', 42, true, null, { nested: true }]) {
expect(settingSchema.safeParse({ key: 'k', value }).success).toBe(true)
}
})
})
+36
View File
@@ -0,0 +1,36 @@
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
test: {
environment: 'happy-dom',
globals: true,
setupFiles: [path.resolve(__dirname, 'setup.ts')],
include: [
'../test/unit/**/*.{test,spec}.{ts,tsx}',
'../test/integration/**/*.{test,spec}.{ts,tsx}',
'../test/components/**/*.{test,spec}.{ts,tsx}',
],
exclude: ['**/node_modules/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.ts', 'src/**/*.tsx'],
exclude: [
'src/app/layout.tsx',
'src/lib/prisma.ts',
'src/**/*.d.ts',
],
thresholds: { lines: 70, functions: 70 },
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, '../app/src'),
},
},
esbuild: {
jsx: 'automatic',
jsxImportSource: 'react',
},
})