c7d2713c23
Explains that the Stripe webhook stderr log, the unhandled event stdout, and the Vite CJS deprecation warning are all normal and expected.
398 lines
16 KiB
Markdown
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
|