Files
ecommerce-platform/test/README.md
T
davide c7d2713c23 docs(test): document expected stderr/stdout output during test runs
Explains that the Stripe webhook stderr log, the unhandled event stdout,
and the Vite CJS deprecation warning are all normal and expected.
2026-05-19 14:18:26 +02:00

398 lines
16 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.
### Output atteso durante l'esecuzione
Durante `npm test` o `npm run test:coverage` possono comparire righe che sembrano errori ma sono normali:
```
stderr | ...stripe.test.ts > returns 400 when signature verification fails
Webhook signature verification failed: stripe_signature_mismatch
```
La route `/api/webhooks/stripe` logga intenzionalmente il messaggio quando la firma è invalida. Il test verifica proprio questo scenario — il `stderr` è corretto.
```
stdout | ...stripe.test.ts > returns 200 for unhandled event types
Unhandled event type: customer.created
```
Anche questo è il log della route per eventi Stripe non gestiti. Comportamento atteso.
```
The CJS build of Vite's Node API is deprecated.
```
Warning di Vitest 1.x, innocuo. Sparirà aggiornando a Vitest 2+.
### 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