diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..1c85a5f --- /dev/null +++ b/test/README.md @@ -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() + 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 diff --git a/test/components/storefront/ProductCard.test.tsx b/test/components/storefront/ProductCard.test.tsx new file mode 100644 index 0000000..4a94b91 --- /dev/null +++ b/test/components/storefront/ProductCard.test.tsx @@ -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) => ( + {children} + ), +})) + +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() + expect(screen.getByText('Maglietta Blu')).toBeTruthy() + }) + + it('formats price correctly (cents → euros with 2 decimals)', () => { + render() + expect(screen.getByText(/29\.99/)).toBeTruthy() + }) + + it('shows the currency', () => { + render() + expect(screen.getByText(/EUR/)).toBeTruthy() + }) + + it('links to the correct product URL', () => { + const { container } = render() + const link = container.querySelector('a') + expect(link?.getAttribute('href')).toBe('/products/maglietta-blu') + }) + + it('renders the product image when images are present', () => { + render() + const img = screen.getByRole('img') + expect(img.getAttribute('src')).toBe('/uploads/prod-1/photo.jpg') + }) + + it('uses altText when provided', () => { + render() + 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() + expect(screen.getByRole('img').getAttribute('alt')).toBe('Maglietta Blu') + }) + + it('shows "No image" placeholder when images array is empty', () => { + render() + expect(screen.getByText('No image')).toBeTruthy() + }) + + it('does not render img tag when no images', () => { + render() + expect(screen.queryByRole('img')).toBeNull() + }) + + it('formats price = 0 correctly', () => { + render() + expect(screen.getByText(/0\.00/)).toBeTruthy() + }) +}) diff --git a/test/components/ui/Button.test.tsx b/test/components/ui/Button.test.tsx new file mode 100644 index 0000000..f3505db --- /dev/null +++ b/test/components/ui/Button.test.tsx @@ -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() + expect(screen.getByText('Clicca qui')).toBeTruthy() + }) + + it('is disabled when loading=true', () => { + render() + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('shows spinner SVG when loading=true', () => { + const { container } = render() + expect(container.querySelector('svg')).toBeTruthy() + }) + + it('does not show spinner when not loading', () => { + const { container } = render() + expect(container.querySelector('svg')).toBeNull() + }) + + it('is disabled when disabled prop is set', () => { + render() + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('calls onClick when clicked', async () => { + const onClick = vi.fn() + render() + await userEvent.click(screen.getByRole('button')) + expect(onClick).toHaveBeenCalledOnce() + }) + + it('does not call onClick when disabled', async () => { + const onClick = vi.fn() + render() + await userEvent.click(screen.getByRole('button')) + expect(onClick).not.toHaveBeenCalled() + }) + + it('applies primary variant class by default', () => { + render() + expect(screen.getByRole('button').className).toContain('bg-blue-600') + }) + + it('applies danger variant class', () => { + render() + expect(screen.getByRole('button').className).toContain('bg-red-600') + }) + + it('applies secondary variant class', () => { + render() + expect(screen.getByRole('button').className).toContain('bg-gray-200') + }) +})