Files
ecommerce-platform/test

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/:

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.

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 NextRequestNextResponse. Si importano e si chiamano direttamente, senza un server HTTP.

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]):

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):

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:

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:

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:

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:

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:

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