Compare commits
7
Commits
ea5fca6561
...
b9ebd250e7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9ebd250e7 | ||
|
|
db6b727902 | ||
|
|
6a5d5a6119 | ||
|
|
5428eeccc1 | ||
|
|
b93f5d5bdf | ||
|
|
ed7faa3be5 | ||
|
|
93cfe1ad5e |
@@ -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/
|
||||
|
||||
|
||||
@@ -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.
|
||||
Generated
+2453
-1
File diff suppressed because it is too large
Load Diff
+11
-2
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Vendored
+53
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
Vendored
+27
@@ -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,
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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(),
|
||||
}))
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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' } })
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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() }) })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user