From db6b72790248eeb26f677eef9254193930aea4de Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 19 May 2026 14:08:03 +0200 Subject: [PATCH] test: add component tests (Button, ProductCard) and test suite README 20 component tests covering Button (variants, disabled state, event handlers) and ProductCard (rendering, price formatting, sale badge, image fallback). README documents the full suite: 151 tests across 10 files, how to run, mock patterns, and what's missing by priority (checkout flow, admin routes, more components). --- test/README.md | 376 ++++++++++++++++++ .../storefront/ProductCard.test.tsx | 76 ++++ test/components/ui/Button.test.tsx | 60 +++ 3 files changed, 512 insertions(+) create mode 100644 test/README.md create mode 100644 test/components/storefront/ProductCard.test.tsx create mode 100644 test/components/ui/Button.test.tsx 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') + }) +})