377 lines
15 KiB
Markdown
377 lines
15 KiB
Markdown
|
|
# 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
|