Files
ecommerce-platform/test/README.md
T
davide db6b727902 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).
2026-05-19 14:08:07 +02:00

15 KiB

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.

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