# 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() 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() 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() 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() 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() expect(screen.getByText('Campo obbligatorio')).toBeTruthy() }) it('ha attributo disabled quando disabled=true', () => { render() 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