Compare commits

..

1 Commits

Author SHA1 Message Date
davide a8b716fc5a temp
vorrei una favicon e un footer, poi personalizzabili in maniera semplice come potrei fare?

ci sono errori
2026-05-18 18:57:11 +02:00
69 changed files with 208 additions and 5658 deletions
-11
View File
@@ -1,11 +0,0 @@
{
"enabledPlugins": {
"stripe@claude-plugins-official": true,
"superpowers@claude-plugins-official": true,
"security-guidance@claude-plugins-official": true,
"ui-ux-pro-max@ui-ux-pro-max-skill": true,
"claude-md-management@claude-plugins-official": true,
"figma@claude-plugins-official": true,
"frontend-design@claude-plugins-official":true
}
}
+2 -3
View File
@@ -1,8 +1,8 @@
APP_URL=http://localhost
DATABASE_URL=postgresql://ecommerce:ecommerce_password@db:5432/ecommerce
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
AUTH_SECRET=dev-secret-change-in-production-32chars
INITIAL_ADMIN_EMAIL=admin@example.com
INITIAL_ADMIN_PASSWORD=<change-this-use-openssl-rand-base64-32>
INITIAL_ADMIN_PASSWORD=Admin1234!test
STRIPE_SECRET_KEY=sk_test_placeholder
STRIPE_WEBHOOK_SECRET=whsec_placeholder
SMTP_HOST=mailpit
@@ -10,4 +10,3 @@ SMTP_PORT=1025
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=noreply@localhost
POSTGRES_PASSWORD=ecommerce_password
+7 -12
View File
@@ -6,15 +6,11 @@
# Node
app/node_modules/
node_modules/
test/node_modules
# Next.js build output
app/.next/
app/out/
# Test coverage report
app/coverage/
# Prisma generated client (rebuilt on npm install)
app/node_modules/.prisma/
@@ -28,15 +24,14 @@ Thumbs.db
*.swp
*.swo
# Dati locali (bind mount Docker) — ignora contenuto, traccia solo struttura
data/db/
data/caddy/
data/uploads/*
!data/uploads/.gitkeep
# Backup
backups/
# Docker volumes (dati locali)
pgdata/
caddy_data/
caddy_config/
# Logs
*.log
npm-debug.log*
# Uploads / media locali
app/public/uploads/
+30 -197
View File
@@ -169,14 +169,13 @@ Il form è diviso in 4 sezioni.
#### Sezione 2 — Pricing & Inventory
**Base Price** *(obbligatorio)*
- Campo: `number` (valore in euro/dollari, con decimali)
- Inserisci il prezzo nella valuta selezionata, con il punto come separatore decimale
- Campo: `number` (valore in centesimi)
- **Attenzione:** il prezzo va inserito in centesimi, non in euro/dollari
- Esempi:
- `19.99` → €19,99
- `49.90` → €49,90
- `100` → €100,00
- `1999` → €19,99
- `4990` → €49,90
- `10000` → €100,00
- Valore minimo: `0`
- Il sistema converte automaticamente in centesimi internamente (compatibilità con Stripe)
---
@@ -345,35 +344,12 @@ Un tipo di prodotto è essenzialmente uno schema che dice: "i prodotti di questa
1. Vai su http://localhost/admin/product-types
2. Clicca **"New Product Type"**
3. Compila i campi (vedi sotto)
4. Clicca **"Save"**
3. Compila:
- **Name:** nome del tipo (es. `Abbigliamento`)
- **Schema (JSON):** definizione degli attributi in formato JSON Schema
### Campi — Product Type
Esempio di schema:
**Name** *(obbligatorio)*
- Campo: `text`
- Il nome del tipo di prodotto, visibile solo nell'admin
- Esempio: `Abbigliamento`, `Elettronica`, `Libri`
- Usa nomi chiari che descrivano la categoria merceologica
---
**Slug** *(obbligatorio)*
- Campo: `text`
- Identificatore univoco generato automaticamente dal nome
- Formato: solo lettere minuscole, numeri e trattini (es. `abbigliamento`, `elettronica`)
- Non può contenere spazi o caratteri speciali
- Viene usato internamente dal sistema per identificare il tipo — non è visibile ai clienti
- **Non modificarlo dopo aver creato prodotti** con questo tipo
---
**Schema (JSON)** *(opzionale)*
- Campo: `textarea` con formato JSON
- Definisce quali attributi personalizzati avranno i prodotti di questo tipo
- Usa il formato JSON Schema standard
Esempio per abbigliamento:
```json
{
"type": "object",
@@ -385,19 +361,7 @@ Esempio per abbigliamento:
}
```
Esempio per elettronica:
```json
{
"type": "object",
"properties": {
"marca": { "type": "string" },
"ram_gb": { "type": "number" },
"storage_gb": { "type": "number" }
}
}
```
> Lo schema è una guida per te — il sistema non blocca l'inserimento di attributi non definiti nello schema.
4. Clicca **"Save"**
### Modifica e gestione
@@ -431,35 +395,12 @@ Abbigliamento (padre)
1. Vai su http://localhost/admin/categories
2. Clicca **"New Category"**
3. Compila i campi (vedi sotto)
3. Compila:
- **Name** *(obbligatorio):* nome della categoria (es. `Sneaker`)
- **Slug** *(obbligatorio):* generato automaticamente, usato nell'URL
- **Parent Category** *(opzionale):* seleziona la categoria padre per creare una sottocategoria
4. Clicca **"Save"**
### Campi — Categoria
**Name** *(obbligatorio)*
- Campo: `text`
- Il nome della categoria visibile ai clienti nel negozio
- Esempio: `Sneaker`, `T-shirt Uomo`, `Smartphone`
- Usa nomi brevi e chiari
---
**Slug** *(obbligatorio)*
- Campo: `text`
- Generato automaticamente dal nome
- Formato: solo lettere minuscole, numeri e trattini (es. `sneaker`, `t-shirt-uomo`)
- Viene usato nell'URL della categoria nel negozio: `/category/sneaker`
- **Non modificarlo** dopo aver pubblicato la categoria per evitare link rotti
---
**Parent Category** *(opzionale)*
- Campo: `select` (menu a tendina)
- Permette di creare una gerarchia padre/figlio
- Lascia vuoto per una categoria di primo livello (es. `Abbigliamento`)
- Seleziona una categoria esistente per creare una sottocategoria (es. `Sneaker` → padre: `Scarpe`)
- Puoi annidare più livelli (es. `Running` → padre: `Sneaker` → nonno: `Scarpe`)
### Modificare una categoria
Clicca **"Edit"** accanto alla categoria, modifica i campi, salva.
@@ -605,48 +546,14 @@ Gestisci gli account che possono accedere al pannello admin.
1. Vai su http://localhost/admin/admin-users
2. Clicca **"New Admin User"**
3. Compila i campi (vedi sotto)
3. Compila:
- **Name:** nome del nuovo admin
- **Email:** email di accesso (deve essere unica)
- **Password:** password iniziale (il sistema richiederà il cambio al primo login)
- **Role:** seleziona `ADMIN` o `OWNER`
4. Clicca **"Create"**
Il nuovo admin dovrà cambiare la password al primo accesso.
### Campi — Admin User
**Name** *(obbligatorio)*
- Campo: `text`
- Nome e cognome dell'amministratore
- Visibile nella lista admin e nei log di audit
- Esempio: `Mario Rossi`
---
**Email** *(obbligatorio)*
- Campo: `email`
- Indirizzo email usato per accedere al pannello admin
- Deve essere unica — non è possibile avere due admin con la stessa email
- Esempio: `mario.rossi@negozio.it`
---
**Role** *(obbligatorio)*
- Campo: `select`
- Seleziona il livello di accesso:
| Ruolo | Cosa può fare |
|-------|--------------|
| `ADMIN` | Gestire prodotti, ordini, clienti, recensioni, categorie, tipi prodotto |
| `OWNER` | Tutto ciò che può fare ADMIN, più: gestire altri admin, modificare le impostazioni di sistema |
> Assegna `OWNER` solo a persone di fiducia — può modificare impostazioni critiche e creare/eliminare altri admin.
---
**Password** *(obbligatorio)*
- Campo: `password`
- Password temporanea assegnata al nuovo admin
- Deve rispettare i requisiti di sicurezza: minimo 12 caratteri, almeno una maiuscola, una minuscola, un numero e un simbolo
- Il sistema chiederà di cambiarla al primo accesso
- Esempio sicuro: `Temp#2026Admin!`
Il nuovo admin riceverà le credenziali e dovrà cambiare la password al primo accesso.
### Eliminare un admin
@@ -662,6 +569,16 @@ Clicca **"Delete"** accanto all'admin da rimuovere.
Configura le impostazioni globali del negozio.
### Campi configurabili
| Campo | Descrizione | Esempio |
|-------|-------------|---------|
| **Site Name** | Nome del negozio | `Il Mio Negozio` |
| **Site Description** | Descrizione breve | `Il miglior ecommerce italiano` |
| **Support Email** | Email di contatto per i clienti | `support@mionegozio.it` |
| **Currency** | Valuta di default | `EUR` |
| **Tax Rate** | Aliquota IVA in percentuale | `22` (per il 22%) |
### Come modificare
1. Vai su http://localhost/admin/settings
@@ -670,90 +587,6 @@ Configura le impostazioni globali del negozio.
Le modifiche hanno effetto immediato su tutto il negozio.
### Campi — Impostazioni generali
**Site Name**
- Campo: `text`
- Il nome del negozio, mostrato nel titolo del browser, nelle email ai clienti e nel footer
- Esempio: `Il Mio Negozio`, `ShopX Italia`
- Tienilo breve e riconoscibile
---
**Site Description**
- Campo: `text`
- Breve descrizione del negozio, usata nei meta tag per i motori di ricerca (SEO)
- Esempio: `Il miglior ecommerce di abbigliamento sportivo italiano`
- Consigliato: massimo 160 caratteri
---
**Support Email**
- Campo: `email`
- Indirizzo email mostrato ai clienti per il supporto (es. nella pagina contatti, nelle email di conferma ordine)
- Esempio: `supporto@mionegozio.it`
- Assicurati che sia una casella monitorata
---
**Default Currency**
- Campo: `text`
- Valuta usata di default per i nuovi prodotti e per il negozio
- Valori accettati: `EUR`, `USD`, `GBP` (codice ISO 4217 a 3 lettere)
- Esempio: `EUR`
- **Attenzione:** cambiare la valuta dopo aver creato prodotti non converte automaticamente i prezzi esistenti
---
**Tax Rate (%)**
- Campo: `number`
- Aliquota IVA applicata agli ordini, espressa in percentuale
- Esempi:
- `22` → IVA italiana al 22%
- `10` → IVA ridotta al 10%
- `0` → nessuna tassa applicata
- Il valore viene mostrato nel riepilogo dell'ordine al checkout
---
### Campi — Footer
**Testo copyright**
- Campo: `text`
- Testo mostrato nel footer del negozio, solitamente l'indicazione del copyright
- Esempio: `© 2026 Il Mio Negozio. Tutti i diritti riservati.`
- Lascia vuoto per non mostrare nulla
---
**Link footer** (Footer Links)
- Campo: `textarea` con formato JSON
- Lista di link mostrati nel footer (es. Privacy Policy, Termini, Contatti)
- Formato: array JSON di oggetti con `label` (testo del link) e `url` (destinazione)
- Esempio:
```json
[
{"label": "Privacy Policy", "url": "/privacy"},
{"label": "Termini e Condizioni", "url": "/termini"},
{"label": "Contatti", "url": "/contatti"}
]
```
- Lascia `[]` per non mostrare link nel footer
- Gli URL possono essere relativi (`/privacy`) o assoluti (`https://...`)
---
### Campi — Branding
**Favicon**
- Campo: `file upload`
- Icona del negozio mostrata nel tab del browser e nei preferiti
- Formati accettati: PNG, ICO, SVG, JPEG, WebP
- Dimensione massima: 1 MB
- Dimensione consigliata: **32×32 px** o **64×64 px** (quadrata)
- Come caricare: clicca **"Scegli file"**, seleziona l'immagine, clicca **"Upload Favicon"**
- La favicon viene aggiornata immediatamente su tutte le pagine
---
## 11. Flusso Consigliato per Iniziare
-135
View File
@@ -1,135 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## How to work with the user
Before implementing any new feature, **always discuss it first**: explain whether it makes sense professionally, how it is typically done in production e-commerce platforms, and propose alternatives if there is a better approach. Only proceed with implementation after the user confirms.
**Communicate in Italian.** The user speaks Italian — all responses, explanations, and questions must be in Italian.
**Error handling workflow:** When you find an error (in logs, code, or output), report what you found and stop. Do not propose or implement a fix until the user explicitly asks. Present: what the error is, where it occurs, and what likely caused it — then wait for instructions.
## Running the project
```bash
# Start everything (first run takes a few minutes to build)
docker compose up -d --build
# Check logs
docker compose logs -f app
# Stop
docker compose down
```
The app is available at http://localhost. Admin panel at http://localhost/admin.
Default credentials are in `.env` (`INITIAL_ADMIN_EMAIL` / `INITIAL_ADMIN_PASSWORD`).
Test emails are visible at http://localhost:8025 (Mailpit).
## Development commands (run inside `app/`)
```bash
npm run dev # local dev server (port 3000, no Docker)
npm run build # production build
npx prisma studio # visual DB browser
npx prisma migrate dev --name <name> # create a new migration
npx prisma migrate deploy # apply migrations (done automatically on container start)
```
## Architecture
**Stack:** Next.js 14 App Router · PostgreSQL 16 · Prisma 5 · Stripe · Nodemailer · TailwindCSS · Zod · Docker + Caddy
**Source root:** `app/src/`
| Directory | Purpose |
|---|---|
| `app/api/` | REST API routes — one folder per resource |
| `app/admin/` | Admin dashboard pages (role-gated at layout level) |
| `app/(storefront)/` | Public-facing pages |
| `components/` | Shared React components and UI primitives |
| `lib/` | Core utilities (auth, email, storage, Stripe, validation, rate limiting) |
| `context/` | Client-side UserContext |
## Key patterns
**API routes** follow a consistent shape: validate input with Zod → check auth/role via `getCurrentUser()` → query Prisma → return JSON. Copy an existing route as a starting point.
**Auth** is session-based (no NextAuth). `lib/auth.ts` handles token creation, hashing (SHA256), and the `getCurrentUser()` helper used in every protected route. Sessions expire after 30 days.
**Admin protection** is enforced in the admin layout: `CUSTOMER` role is blocked, `ADMIN` and `OWNER` are allowed. A `mustChangePassword` flag redirects new owners to a password-change page before anything else.
**Validation schemas** live in `lib/validate.ts` (Zod). Add new schemas there; never validate inline in routes.
**Image uploads** go through `lib/storage.ts`, which validates magic bytes (JPEG/PNG/WebP/ICO) before writing to `/app/public/uploads/<productId>/`. Uploads are stored in a named Docker volume (`uploads`) shared between the `app` and `caddy` containers.
**Emails** are sent via `lib/email.ts` (Nodemailer). Locally they land in Mailpit; in production, configure SMTP env vars.
**Rate limiting** is IP-based and backed by the `LoginAttempt` DB table (10 attempts / 15 min). Logic is in `lib/rate-limit.ts`.
## Data model (high level)
- **User** → has role (`CUSTOMER` / `ADMIN` / `OWNER`), sessions, orders, reviews
- **Product** → belongs to one **ProductType** (defines the attribute schema), many-to-many with **Category**, has **MediaAsset**, **ProductVariant**, **Review**
- **ProductType** → defines a JSON schema for product attributes (e.g. "clothing" has size/color)
- **ProductVariant** → SKU, price, stock per variante (es. taglia/colore di un prodotto)
- **Category** → hierarchical (self-referential `parentId`), many-to-many with Product
- **Order** → status state machine (`PENDING → PAID → FULFILLED`, `CANCELLED`, `REFUNDED`), has **OrderItem** and **Payment**
- **PasswordResetToken** → token hashed con scadenza per il flusso reset password
- **Page** / **PageSection** → CMS minimale per pagine statiche (slug, titolo, sezioni JSON ordinate)
- **SiteSettings** — key/value store for shop configuration (branding, etc.)
- **AuditLog** — tracks admin actions
## Environment variables
Copy `.env.example` to `.env`. For production, generate a strong `AUTH_SECRET` with `openssl rand -hex 32` and replace all placeholder values. The `DATABASE_URL` uses the Docker service name `db` as host — do not change it for containerised deployments.
## Debugging
**Check app logs (first thing to do):**
```bash
docker compose logs -f app # live
docker compose logs app --tail=100 # last 100 lines
```
**Common error patterns to look for in logs:**
- `EACCES: permission denied` → volume/filesystem permissions issue
- `PrismaClientKnownRequestError` → DB constraint violation or bad query
- `401 / 403` in API → session expired or role check failing
- Silent form failures → always check `res.ok` in `fetch` calls; UI may show success even on API error
**API errors not surfacing in the UI** are almost always caused by a missing `res.ok` check in the frontend `fetch` call. The pattern to use in every form submit:
```ts
const res = await fetch('/api/...', { method: 'POST', ... })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setError(data.error || `Errore (${res.status})`)
return
}
```
**Database inspection:**
```bash
# Open Prisma Studio (visual DB browser)
cd app && npx prisma studio
# Or connect directly
docker compose exec db psql -U ecommerce ecommerce
```
**Rebuild after code changes:**
```bash
docker compose up -d --build app # rebuild only the app container
```
**TypeScript errors in IDE but not in build:** likely a missing `node_modules` or stale tsconfig in the IDE. Run `npm install` inside `app/` and restart the IDE TypeScript server.
## Deployment
1. Point your domain DNS to the server.
2. Set `APP_URL` to `https://yourdomain.com` in `.env`.
3. Edit `Caddyfile`: replace `localhost` with your domain.
4. `docker compose up -d --build`
Caddy handles TLS automatically via Let's Encrypt. Ports 80 and 443 must be open on the firewall.
-5
View File
@@ -1,9 +1,4 @@
localhost {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
}
handle /uploads/* {
root * /srv
file_server
+25 -113
View File
@@ -12,7 +12,6 @@ Piattaforma e-commerce containerizzata, avviabile con un singolo comando.
- [Docker Desktop](https://docs.docker.com/get-docker/) installato e avviato
- Porta **80** libera (nessun altro web server in esecuzione)
- Account [Stripe](https://stripe.com) gratuito (necessario per i pagamenti)
### 1. Clona il repository
@@ -27,28 +26,14 @@ cd ecommerce-platform
cp .env.example .env
```
Apri `.env` e inserisci la tua chiave Stripe test (gratuita, dalla [Stripe Dashboard → API keys](https://dashboard.stripe.com/test/apikeys)):
```env
STRIPE_SECRET_KEY=sk_test_la_tua_chiave
```
Il resto dei valori è già preconfigurato per localhost e non va modificato.
Il file `.env` di default è già configurato per localhost. **Non serve modificare nulla** per i primi test.
### 3. Avvia la piattaforma
**Senza pagamenti** (admin, catalogo, email):
```bash
docker compose up -d
```
**Con pagamenti e webhook Stripe attivi** (consigliato):
```bash
docker compose --profile dev up -d
```
Il primo avvio richiede **510 minuti**: Docker scarica le immagini, installa le dipendenze npm, compila Next.js, esegue le migrazioni e crea l'utente admin.
Segui il progresso con:
@@ -91,57 +76,33 @@ Al primo accesso il sistema ti obbliga a cambiare la password.
3. **Products** → crea un prodotto, assegna tipo e categoria, impostalo su **Published**
4. Apri http://localhost → il prodotto appare in homepage
### 7. Setup webhook Stripe (una tantum per macchina)
### 7. Cosa funziona in locale senza configurazione extra
Il profilo `dev` include un container `stripe-cli` che riceve i webhook da Stripe e li inoltra all'app. Senza di esso, dopo il pagamento l'ordine resta in `PENDING` e nessuna email viene inviata.
| Funzionalità | Stato |
|---|---|
| Admin dashboard completa | ✅ |
| Gestione prodotti, categorie, ordini | ✅ |
| Registrazione e login clienti | ✅ |
| Email (reset password, ecc.) | ✅ visibili su http://localhost:8025 |
| Pagamenti Stripe | ⚠️ richiede chiavi reali (vedi sotto) |
**Al primo avvio con `--profile dev`, recupera il webhook secret dai log:**
```bash
docker compose logs stripe-cli | grep "webhook signing secret"
```
Copia il valore `whsec_...` nel `.env`:
**Per testare i pagamenti Stripe** in locale, inserisci una chiave `sk_test_...` reale nel `.env` (gratuita, modalità test su [dashboard.stripe.com](https://dashboard.stripe.com/test/apikeys)):
```env
STRIPE_WEBHOOK_SECRET=whsec_abc123...
STRIPE_SECRET_KEY=sk_test_la_tua_chiave
```
Poi ricrea il container app per applicare la variabile (`restart` non ricarica il `.env`):
Poi riavvia l'app:
```bash
docker compose up -d --force-recreate app
docker compose restart app
```
Da questo momento `docker compose --profile dev up -d` avvia tutto inclusi i webhook — non serve altro.
### 8. Testa i pagamenti
**Carte di test Stripe:**
| Carta | Risultato |
|-------|-----------|
| `4242 4242 4242 4242` | Pagamento riuscito |
| `4000 0000 0000 0002` | Carta rifiutata |
| `4000 0025 0000 3155` | Richiede autenticazione 3D Secure |
Scadenza: qualsiasi data futura · CVC: qualsiasi 3 cifre
**Verifica che tutto funzioni dopo un pagamento:**
### 8. Ferma la piattaforma
```bash
docker compose logs stripe-cli --tail=20
# deve mostrare [200] per checkout.session.completed
```
1. L'ordine nel pannello admin passa da `PENDING` a `PAID`
2. L'email di conferma appare su http://localhost:8025
### 9. Ferma la piattaforma
```bash
docker compose --profile dev down # ferma tutto (con stripe-cli), i dati restano
docker compose down -v # ferma e cancella anche il database
docker compose down # ferma i container, i dati restano
docker compose down -v # ferma e cancella anche il database
```
---
@@ -172,13 +133,13 @@ Cinque variabili da aggiornare nel `.env`:
|-----------|-----------|------------|
| `APP_URL` | `http://localhost` | `https://tuodominio.com` |
| `AUTH_SECRET` | qualsiasi stringa | `openssl rand -hex 32` |
| `STRIPE_SECRET_KEY` | `sk_test_...` | `sk_live_...` |
| `STRIPE_WEBHOOK_SECRET` | dal container stripe-cli | dal dashboard Stripe |
| `STRIPE_SECRET_KEY` | `sk_test_...` (gratuita) | `sk_live_...` |
| `STRIPE_WEBHOOK_SECRET` | opzionale | obbligatorio |
| `SMTP_*` | Mailpit locale (porta 8025) | provider reale (Resend, Mailgun, SendGrid…) |
### 3 — Server
Serve un VPS Linux con Docker installato (DigitalOcean, Hetzner, OVH, ecc.) e il record DNS del dominio puntato all'IP del server. Il comando di avvio è identico: `docker compose up -d` (senza `--profile dev`).
Serve un VPS Linux con Docker installato (DigitalOcean, Hetzner, OVH, ecc.) e il record DNS del dominio puntato all'IP del server. Il comando di avvio è identico: `docker compose up -d`.
### 4 — Backup
@@ -260,6 +221,8 @@ tuodominio.com {
}
```
Caddy ottiene e rinnova automaticamente il certificato HTTPS tramite Let's Encrypt. Non serve nessuna configurazione SSL manuale.
### 5. Avvia
```bash
@@ -288,10 +251,10 @@ Eventi da ascoltare:
- payment_intent.payment_failed
```
Copia il **Signing secret** (`whsec_...`) nel `.env` come `STRIPE_WEBHOOK_SECRET`, poi ricrea il container per applicarlo:
Copia il **Signing secret** (`whsec_...`) nel `.env` come `STRIPE_WEBHOOK_SECRET`, poi:
```bash
docker compose up -d --force-recreate app
docker compose restart app
```
### 7. Primo accesso e configurazione negozio
@@ -308,43 +271,6 @@ docker compose up -d --force-recreate app
---
## Porte di rete
### Porte da aprire nel firewall del server (produzione)
| Porta | Protocollo | Servizio | Perché è necessaria |
|-------|-----------|---------|---------------------|
| **80** | TCP | Caddy (HTTP) | Redirect automatico HTTP → HTTPS e rinnovo certificati Let's Encrypt (ACME challenge) |
| **443** | TCP | Caddy (HTTPS) | Traffico web principale — sito pubblico e pannello admin |
> Solo queste due porte devono essere aperte verso l'esterno. Tutto il resto è interno a Docker.
### Porte interne (rete Docker — non esporre al pubblico)
| Porta | Servizio | Note |
|-------|---------|------|
| **3000** | Next.js (app) | Accessibile solo da Caddy tramite `reverse_proxy app:3000` |
| **5432** | PostgreSQL (db) | Accessibile solo dall'app tramite `DATABASE_URL` |
| **1025** | Mailpit SMTP | Accessibile solo dall'app per l'invio email |
### Porta solo per sviluppo locale (non aprire in produzione)
| Porta | Servizio | Note |
|-------|---------|------|
| **8025** | Mailpit UI | Interfaccia web per ispezionare le email di test — non deve mai essere esposta in produzione |
### Riepilogo comandi firewall (UFW — Ubuntu/Debian)
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw deny 8025/tcp
sudo ufw enable
sudo ufw status
```
---
## Aggiornamenti
```bash
@@ -393,20 +319,6 @@ docker compose logs -f app
**Errore "port 80 already in use"**
Un altro servizio usa la porta 80 (Apache, Nginx, ecc.). Fermalo o cambia la porta nel `docker-compose.yml`.
**L'ordine resta in PENDING dopo il pagamento**
Il container `stripe-cli` non è in esecuzione o `STRIPE_WEBHOOK_SECRET` non è configurato. Verifica:
```bash
docker compose --profile dev ps # stripe-cli deve essere "running"
docker compose logs stripe-cli --tail=10
```
Se il `whsec_...` manca nel `.env`, segui lo step 7 del setup locale.
**Le variabili del `.env` non vengono applicate dopo la modifica**
`docker compose restart` non ricarica il `.env`. Usa sempre:
```bash
docker compose up -d --force-recreate app
```
**Dimentico la password admin**
Resetta direttamente nel database:
```bash
@@ -426,7 +338,7 @@ docker compose exec db psql -U ecommerce ecommerce -c \
```
ecommerce-platform/
├── docker-compose.yml Orchestrazione: db, app, caddy, mailpit, stripe-cli (profilo dev)
├── docker-compose.yml Orchestrazione: db, app, caddy, mailpit
├── Caddyfile Reverse proxy — modifica qui il dominio
├── .env Variabili d'ambiente (non committare)
├── .env.example Template da copiare
@@ -458,7 +370,7 @@ ecommerce-platform/
| `INITIAL_ADMIN_EMAIL` | Email primo admin | qualsiasi | la tua email |
| `INITIAL_ADMIN_PASSWORD` | Password primo admin | qualsiasi | sicura |
| `STRIPE_SECRET_KEY` | Chiave Stripe | `sk_test_...` | `sk_live_...` |
| `STRIPE_WEBHOOK_SECRET` | Segreto webhook Stripe | dal container stripe-cli | dal dashboard Stripe |
| `STRIPE_WEBHOOK_SECRET` | Segreto webhook Stripe | opzionale | obbligatorio |
| `SMTP_HOST` | Server SMTP | `mailpit` | provider reale |
| `SMTP_PORT` | Porta SMTP | `1025` | `587` |
| `SMTP_USER` | Utente SMTP | vuoto | obbligatorio |
+1 -2453
View File
File diff suppressed because it is too large Load Diff
+2 -11
View File
@@ -6,10 +6,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"postinstall": "prisma generate",
"test": "vitest run --config ../test/vitest.config.ts",
"test:watch": "vitest --config ../test/vitest.config.ts",
"test:coverage": "vitest run --config ../test/vitest.config.ts --coverage"
"postinstall": "prisma generate"
},
"dependencies": {
"next": "14.2.5",
@@ -29,12 +26,6 @@
"typescript": "^5",
"tailwindcss": "^3.4.1",
"postcss": "^8",
"autoprefixer": "^10.0.1",
"vitest": "^1.6.0",
"@vitest/coverage-v8": "^1.6.0",
"happy-dom": "^14.12.0",
"@testing-library/react": "^16.0.0",
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/user-event": "^14.5.2"
"autoprefixer": "^10.0.1"
}
}
@@ -1,7 +0,0 @@
CREATE TABLE "LoginAttempt" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LoginAttempt_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "LoginAttempt_key_createdAt_idx" ON "LoginAttempt"("key", "createdAt");
-8
View File
@@ -255,11 +255,3 @@ model AuditLog {
metadata Json?
createdAt DateTime @default(now())
}
model LoginAttempt {
id String @id @default(cuid())
key String // IP address or identifier
createdAt DateTime @default(now())
@@index([key, createdAt])
}
View File
+2 -4
View File
@@ -6,7 +6,6 @@ import Link from 'next/link'
import { Navbar } from '@/components/storefront/Navbar'
import { Badge } from '@/components/ui/Badge'
import { Alert } from '@/components/ui/Alert'
import { useUser } from '@/context/UserContext'
interface Order {
id: string
@@ -36,14 +35,13 @@ export default function OrdersPage() {
function OrdersContent() {
const [orders, setOrders] = useState<Order[]>([])
const [loading, setLoading] = useState(true)
const { user, isLoading: userLoading } = useUser()
const router = useRouter()
const searchParams = useSearchParams()
const success = searchParams.get('success')
useEffect(() => {
if (userLoading) return
if (!user) {
const stored = localStorage.getItem('user')
if (!stored) {
router.push('/login?redirect=/account/orders')
return
}
+19 -6
View File
@@ -1,22 +1,35 @@
'use client'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Navbar } from '@/components/storefront/Navbar'
import { useUser } from '@/context/UserContext'
interface User {
id: string
email: string
name?: string
role: string
}
export default function AccountPage() {
const { user, isLoading } = useUser()
const [user, setUser] = useState<User | null>(null)
const router = useRouter()
useEffect(() => {
if (!isLoading && !user) {
const stored = localStorage.getItem('user')
if (!stored) {
router.push('/login?redirect=/account')
return
}
}, [isLoading, user, router])
try {
setUser(JSON.parse(stored))
} catch {
router.push('/login')
}
}, [router])
if (isLoading || !user) return null
if (!user) return null
return (
<div>
+6 -5
View File
@@ -5,7 +5,6 @@ import { useRouter } from 'next/navigation'
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
import { useUser } from '@/context/UserContext'
export default function ChangePasswordPage() {
const [currentPassword, setCurrentPassword] = useState('')
@@ -15,7 +14,6 @@ export default function ChangePasswordPage() {
const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false)
const router = useRouter()
const { user, setUser } = useUser()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -43,9 +41,12 @@ export default function ChangePasswordPage() {
}
setSuccess(true)
// Update context to remove mustChangePassword flag
if (user) {
setUser({ ...user, mustChangePassword: false })
// Update localStorage to remove mustChangePassword flag
const userStr = localStorage.getItem('user')
if (userStr) {
const user = JSON.parse(userStr)
user.mustChangePassword = false
localStorage.setItem('user', JSON.stringify(user))
}
setTimeout(() => router.push('/admin'), 1500)
+17 -9
View File
@@ -112,15 +112,23 @@ export default function AdminProductTypesPage() {
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
<h2 className="font-semibold mb-4">{editId ? 'Edit Type' : 'New Product Type'}</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Name"
value={name}
onChange={(e) => {
setName(e.target.value)
if (!editId) setSlug(generateSlug(e.target.value))
}}
required
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="Name"
value={name}
onChange={(e) => {
setName(e.target.value)
if (!editId) setSlug(generateSlug(e.target.value))
}}
required
/>
<Input
label="Slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Schema (JSON define attribute fields)
+3 -4
View File
@@ -82,7 +82,7 @@ export default function AdminProductEditPage() {
title: p.title,
slug: p.slug,
description: p.description,
basePrice: (p.basePrice / 100).toFixed(2),
basePrice: String(p.basePrice),
currency: p.currency,
status: p.status,
attributes: JSON.stringify(p.attributes, null, 2),
@@ -171,7 +171,7 @@ export default function AdminProductEditPage() {
title: form.title,
slug: form.slug,
description: form.description,
basePrice: Math.round(parseFloat(form.basePrice) * 100),
basePrice: parseInt(form.basePrice),
currency: form.currency,
status: form.status,
attributes,
@@ -262,12 +262,11 @@ export default function AdminProductEditPage() {
<div className="grid grid-cols-2 gap-4">
<Input
label={`Base Price (${form.currency})`}
label="Base Price (cents)"
type="number"
value={form.basePrice}
onChange={(e) => setForm((f) => ({ ...f, basePrice: e.target.value }))}
min="0"
step="0.01"
required
/>
<Select
+1 -8
View File
@@ -52,19 +52,12 @@ export default function AdminSettingsPage() {
e.preventDefault()
setSaving(true)
setError('')
setMessage('')
for (const key of ALL_KEYS) {
const res = await fetch('/api/admin/settings', {
await fetch('/api/admin/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value: values[key] }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setSaving(false)
setError(data.error || `Errore nel salvare "${key}" (${res.status})`)
return
}
}
setSaving(false)
setMessage('Impostazioni salvate!')
@@ -72,14 +72,6 @@ export async function DELETE(request: NextRequest) {
const id = searchParams.get('id')
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
const productCount = await prisma.productCategory.count({ where: { categoryId: id } })
if (productCount > 0) {
return NextResponse.json(
{ error: `Cannot delete: ${productCount} product(s) use this category` },
{ status: 409 }
)
}
await prisma.category.delete({ where: { id } })
return NextResponse.json({ success: true })
@@ -2,14 +2,6 @@ import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
const VALID_TRANSITIONS: Record<string, string[]> = {
PENDING: ['PAID', 'CANCELLED'],
PAID: ['FULFILLED', 'REFUNDED', 'CANCELLED'],
FULFILLED: ['REFUNDED'],
CANCELLED: [],
REFUNDED: [],
}
async function requireAdmin() {
const user = await getCurrentUser()
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
@@ -58,17 +50,6 @@ export async function PUT(
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
}
const currentOrder = await prisma.order.findUnique({ where: { id: params.id }, select: { status: true } })
if (!currentOrder) return NextResponse.json({ error: 'Order not found' }, { status: 404 })
const allowed = VALID_TRANSITIONS[currentOrder.status] ?? []
if (!allowed.includes(status)) {
return NextResponse.json(
{ error: `Cannot transition order from ${currentOrder.status} to ${status}` },
{ status: 422 }
)
}
const order = await prisma.order.update({
where: { id: params.id },
data: { status: status as 'PENDING' | 'PAID' | 'CANCELLED' | 'REFUNDED' | 'FULFILLED' },
+2 -2
View File
@@ -13,8 +13,8 @@ export async function GET(request: NextRequest) {
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { searchParams } = new URL(request.url)
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const status = searchParams.get('status')
const skip = (page - 1) * limit
@@ -73,14 +73,6 @@ export async function DELETE(request: NextRequest) {
const id = searchParams.get('id')
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
const productCount = await prisma.product.count({ where: { typeId: id } })
if (productCount > 0) {
return NextResponse.json(
{ error: `Cannot delete: ${productCount} product(s) use this product type` },
{ status: 409 }
)
}
await prisma.productType.delete({ where: { id } })
return NextResponse.json({ success: true })
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { saveImage, deleteImageFile, validateImageMagicBytes } from '@/lib/storage'
import { saveImage, deleteImageFile } from '@/lib/storage'
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
@@ -26,10 +26,6 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json({ error: 'Only JPEG, PNG and WebP images are allowed' }, { status: 400 })
}
const isValidImage = await validateImageMagicBytes(file, file.type)
if (!isValidImage) {
return NextResponse.json({ error: 'File content does not match declared image type' }, { status: 400 })
}
if (file.size > MAX_SIZE) {
return NextResponse.json({ error: 'File too large (max 5MB)' }, { status: 400 })
}
+3 -3
View File
@@ -16,8 +16,8 @@ export async function GET(request: NextRequest) {
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { searchParams } = new URL(request.url)
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const search = searchParams.get('search')
const status = searchParams.get('status')
@@ -65,7 +65,7 @@ export async function POST(request: NextRequest) {
const parsed = productSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid input' },
{ error: parsed.error.errors[0]?.message || 'Invalid input', details: parsed.error.errors },
{ status: 400 }
)
}
+2 -2
View File
@@ -13,8 +13,8 @@ export async function GET(request: NextRequest) {
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { searchParams } = new URL(request.url)
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const status = searchParams.get('status')
const skip = (page - 1) * limit
-15
View File
@@ -2,17 +2,6 @@ import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
const ALLOWED_SETTING_KEYS = [
'site_name',
'site_description',
'support_email',
'currency',
'tax_rate',
'footer_copyright',
'footer_links',
'favicon_url',
] as const
async function requireAdmin() {
const user = await getCurrentUser()
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
@@ -51,10 +40,6 @@ export async function POST(request: NextRequest) {
if (!key) return NextResponse.json({ error: 'Key is required' }, { status: 400 })
if (!ALLOWED_SETTING_KEYS.includes(key as (typeof ALLOWED_SETTING_KEYS)[number])) {
return NextResponse.json({ error: 'Invalid setting key' }, { status: 400 })
}
const setting = await prisma.siteSettings.upsert({
where: { key },
update: { value: value as object },
@@ -3,9 +3,8 @@ import { mkdir, writeFile } from 'fs/promises'
import path from 'path'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { validateImageMagicBytes } from '@/lib/storage'
const ALLOWED_TYPES = ['image/x-icon', 'image/png', 'image/jpeg', 'image/webp']
const ALLOWED_TYPES = ['image/x-icon', 'image/png', 'image/svg+xml', 'image/jpeg', 'image/webp']
const MAX_SIZE = 1 * 1024 * 1024 // 1MB
const FAVICON_URL = '/uploads/branding/favicon.png'
@@ -24,11 +23,7 @@ export async function POST(req: NextRequest) {
if (!file) return NextResponse.json({ error: 'No file provided' }, { status: 400 })
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json({ error: 'Formato non supportato (usa PNG, ICO, JPEG o WebP)' }, { status: 400 })
}
const isValidImage = await validateImageMagicBytes(file, file.type)
if (!isValidImage) {
return NextResponse.json({ error: 'File content does not match declared image type' }, { status: 400 })
return NextResponse.json({ error: 'Formato non supportato (usa PNG, ICO, SVG o WebP)' }, { status: 400 })
}
if (file.size > MAX_SIZE) {
return NextResponse.json({ error: 'File troppo grande (max 1MB)' }, { status: 400 })
@@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser, verifyPassword, hashPassword } from '@/lib/auth'
import { changePasswordSchema } from '@/lib/validate'
import { checkRateLimit, recordAttempt } from '@/lib/rate-limit'
export async function POST(request: NextRequest) {
const user = await getCurrentUser()
@@ -10,19 +9,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const ip =
request.headers.get('x-forwarded-for') ??
request.headers.get('x-real-ip') ??
'unknown'
const { limited } = await checkRateLimit(ip)
if (limited) {
return NextResponse.json(
{ error: 'Too many attempts. Please try again later.' },
{ status: 429 }
)
}
let body: unknown
try {
body = await request.json()
@@ -42,7 +28,6 @@ export async function POST(request: NextRequest) {
const valid = await verifyPassword(currentPassword, user.passwordHash)
if (!valid) {
await recordAttempt(ip)
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 400 })
}
+25 -9
View File
@@ -6,16 +6,33 @@ import {
setSessionCookie,
} from '@/lib/auth'
import { loginSchema } from '@/lib/validate'
import { checkRateLimit, recordAttempt } from '@/lib/rate-limit'
// Simple in-memory rate limiter
const loginAttempts = new Map<string, { count: number; resetAt: number }>()
function checkRateLimit(ip: string): boolean {
const now = Date.now()
const windowMs = 15 * 60 * 1000 // 15 minutes
const maxAttempts = 10
const record = loginAttempts.get(ip)
if (!record || record.resetAt < now) {
loginAttempts.set(ip, { count: 1, resetAt: now + windowMs })
return true
}
if (record.count >= maxAttempts) {
return false
}
record.count++
return true
}
export async function POST(request: NextRequest) {
const ip =
request.headers.get('x-forwarded-for') ??
request.headers.get('x-real-ip') ??
'unknown'
const ip = request.headers.get('x-forwarded-for') || 'unknown'
const { limited } = await checkRateLimit(ip)
if (limited) {
if (!checkRateLimit(ip)) {
return NextResponse.json(
{ error: 'Too many login attempts. Please try again later.' },
{ status: 429 }
@@ -41,13 +58,11 @@ export async function POST(request: NextRequest) {
const user = await prisma.user.findUnique({ where: { email } })
if (!user) {
await recordAttempt(ip)
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
}
const valid = await verifyPassword(password, user.passwordHash)
if (!valid) {
await recordAttempt(ip)
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
}
@@ -60,6 +75,7 @@ export async function POST(request: NextRequest) {
email: user.email,
name: user.name,
role: user.role,
mustChangePassword: user.mustChangePassword,
},
})
}
-16
View File
@@ -1,16 +0,0 @@
import { NextResponse } from 'next/server'
import { getCurrentUser } from '@/lib/auth'
export async function GET() {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ user: null }, { status: 401 })
return NextResponse.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
mustChangePassword: user.mustChangePassword,
},
})
}
+2 -2
View File
@@ -3,8 +3,8 @@ import { prisma } from '@/lib/prisma'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const category = searchParams.get('category')
const search = searchParams.get('search')
-14
View File
@@ -1,14 +0,0 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
const PUBLIC_KEYS = ['site_name', 'site_description', 'footer_copyright', 'footer_links'] as const
export async function GET() {
const rows = await prisma.siteSettings.findMany({
where: { key: { in: [...PUBLIC_KEYS] } },
})
const settings = Object.fromEntries(rows.map((r) => [r.key, r.value]))
return NextResponse.json({ settings })
}
+3 -3
View File
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
try {
event = constructWebhookEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
} catch (err) {
console.error('Webhook signature verification failed:', err instanceof Error ? err.message : String(err))
console.error('Webhook signature verification failed:', err)
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
@@ -55,7 +55,7 @@ export async function POST(request: NextRequest) {
currency: order.currency,
})
} catch (emailErr) {
console.error('Failed to send confirmation email:', emailErr instanceof Error ? emailErr.message : String(emailErr))
console.error('Failed to send confirmation email:', emailErr)
}
}
@@ -116,7 +116,7 @@ export async function POST(request: NextRequest) {
console.log(`Unhandled event type: ${event.type}`)
}
} catch (err) {
console.error('Error processing webhook:', err instanceof Error ? err.message : String(err))
console.error('Error processing webhook:', err)
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 })
}
+3 -4
View File
@@ -5,7 +5,6 @@ import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Navbar } from '@/components/storefront/Navbar'
import { Button } from '@/components/ui/Button'
import { useUser } from '@/context/UserContext'
interface CartItem {
productId: string
@@ -19,7 +18,6 @@ interface CartItem {
export default function CartPage() {
const [cart, setCart] = useState<CartItem[]>([])
const router = useRouter()
const { user } = useUser()
useEffect(() => {
const stored = JSON.parse(localStorage.getItem('cart') || '[]')
@@ -53,9 +51,10 @@ export default function CartPage() {
const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
function handleCheckout() {
async function handleCheckout() {
const user = localStorage.getItem('user')
if (!user) {
router.push('/login?redirect=/checkout')
router.push('/login?redirect=/cart')
return
}
router.push('/checkout')
+6 -10
View File
@@ -5,7 +5,6 @@ import { useRouter } from 'next/navigation'
import { Navbar } from '@/components/storefront/Navbar'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
import { useUser } from '@/context/UserContext'
interface CartItem {
productId: string
@@ -20,23 +19,20 @@ export default function CheckoutPage() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const router = useRouter()
const { user, isLoading: userLoading } = useUser()
useEffect(() => {
if (userLoading) return
if (!user) {
router.push('/login?redirect=/checkout')
return
}
const stored = JSON.parse(localStorage.getItem('cart') || '[]')
if (stored.length === 0) {
router.push('/cart')
return
}
setCart(stored)
}, [router, user, userLoading])
const user = localStorage.getItem('user')
if (!user) {
router.push('/login?redirect=/checkout')
}
}, [router])
const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 658 KiB

+2 -5
View File
@@ -2,7 +2,6 @@ import type { Metadata } from 'next'
import './globals.css'
import { prisma } from '@/lib/prisma'
import { Footer } from '@/components/storefront/Footer'
import { UserProvider } from '@/context/UserContext'
export async function generateMetadata(): Promise<Metadata> {
try {
@@ -13,7 +12,7 @@ export async function generateMetadata(): Promise<Metadata> {
return {
title: (s.site_name as string) || 'ShopX',
description: (s.site_description as string) || 'Your online store',
icons: { icon: (s.favicon_url as string) || '/icon.png' },
icons: s.favicon_url ? { icon: s.favicon_url as string } : undefined,
}
} catch {
return { title: 'ShopX', description: 'Your online store' }
@@ -24,9 +23,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="it">
<body className="bg-gray-50 text-gray-900 min-h-screen flex flex-col">
<UserProvider>
{children}
</UserProvider>
{children}
<Footer />
</body>
</html>
+5 -15
View File
@@ -1,12 +1,11 @@
'use client'
import { Suspense, useState, useEffect } from 'react'
import { Suspense, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
import { useUser } from '@/context/UserContext'
export default function LoginPage() {
return (
@@ -21,18 +20,9 @@ function LoginForm() {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [siteName, setSiteName] = useState('ShopX')
const router = useRouter()
const searchParams = useSearchParams()
const redirect = searchParams.get('redirect') || '/'
const { refreshUser } = useUser()
useEffect(() => {
fetch('/api/settings')
.then((r) => r.json())
.then((data) => { if (data.settings?.site_name) setSiteName(data.settings.site_name as string) })
.catch(() => {})
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -53,11 +43,11 @@ function LoginForm() {
return
}
const refreshedUser = await refreshUser()
localStorage.setItem('user', JSON.stringify(data.user))
if (refreshedUser?.mustChangePassword) {
if (data.user.mustChangePassword) {
router.push('/admin/change-password')
} else if (refreshedUser?.role === 'ADMIN' || refreshedUser?.role === 'OWNER') {
} else if (data.user.role === 'ADMIN' || data.user.role === 'OWNER') {
router.push('/admin')
} else {
router.push(redirect)
@@ -74,7 +64,7 @@ function LoginForm() {
<div className="w-full max-w-md">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
<div className="text-center mb-8">
<Link href="/" className="text-2xl font-bold text-gray-900">{siteName}</Link>
<Link href="/" className="text-2xl font-bold text-gray-900">ShopX</Link>
<h1 className="text-xl font-semibold mt-4">Welcome back</h1>
<p className="text-gray-600 text-sm mt-1">Sign in to your account</p>
</div>
+4 -13
View File
@@ -14,17 +14,8 @@ async function getFeaturedProducts() {
})
}
async function getSiteSettings() {
const rows = await prisma.siteSettings.findMany({
where: { key: { in: ['site_name', 'site_description'] } },
})
return Object.fromEntries(rows.map((r) => [r.key, r.value as string]))
}
export default async function HomePage() {
const [products, settings] = await Promise.all([getFeaturedProducts(), getSiteSettings()])
const siteName = settings.site_name || 'ShopX'
const siteDescription = settings.site_description || 'Discover our curated collection of products'
const products = await getFeaturedProducts()
return (
<div>
@@ -33,9 +24,9 @@ export default async function HomePage() {
{/* Hero */}
<section className="bg-blue-600 text-white py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 className="text-4xl font-bold mb-4">Welcome to {siteName}</h1>
<h1 className="text-4xl font-bold mb-4">Welcome to ShopX</h1>
<p className="text-xl text-blue-100 mb-8">
{siteDescription}
Discover our curated collection of products
</p>
<Link
href="/products"
@@ -99,7 +90,7 @@ export default async function HomePage() {
<footer className="bg-gray-800 text-gray-300 py-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-sm">
<p>&copy; {new Date().getFullYear()} {siteName}. All rights reserved.</p>
<p>&copy; {new Date().getFullYear()} ShopX. All rights reserved.</p>
</div>
</footer>
</div>
+2 -7
View File
@@ -54,7 +54,7 @@ export default function ProductDetailPage() {
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
const existingIndex = cart.findIndex(
(item: { productId: string; variantId?: string }) =>
item.productId === product.id && item.variantId === (selectedVariant || undefined)
item.productId === product.id && item.variantId === selectedVariant
)
if (existingIndex >= 0) {
@@ -249,12 +249,7 @@ export default function ProductDetailPage() {
size="lg"
variant="secondary"
onClick={() => {
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
const alreadyInCart = cart.some(
(item: { productId: string; variantId?: string }) =>
item.productId === product.id && item.variantId === (selectedVariant || undefined)
)
if (!alreadyInCart) addToCart()
addToCart()
router.push('/cart')
}}
>
+4 -14
View File
@@ -1,12 +1,11 @@
'use client'
import { useState, useEffect } from 'react'
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
import { useUser } from '@/context/UserContext'
export default function RegisterPage() {
const [name, setName] = useState('')
@@ -14,16 +13,7 @@ export default function RegisterPage() {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [siteName, setSiteName] = useState('ShopX')
const router = useRouter()
const { refreshUser } = useUser()
useEffect(() => {
fetch('/api/settings')
.then((r) => r.json())
.then((data) => { if (data.settings?.site_name) setSiteName(data.settings.site_name as string) })
.catch(() => {})
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -44,7 +34,7 @@ export default function RegisterPage() {
return
}
await refreshUser()
localStorage.setItem('user', JSON.stringify(data.user))
router.push('/')
} catch {
setError('Something went wrong. Please try again.')
@@ -58,9 +48,9 @@ export default function RegisterPage() {
<div className="w-full max-w-md">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
<div className="text-center mb-8">
<Link href="/" className="text-2xl font-bold text-gray-900">{siteName}</Link>
<Link href="/" className="text-2xl font-bold text-gray-900">ShopX</Link>
<h1 className="text-xl font-semibold mt-4">Create Account</h1>
<p className="text-gray-600 text-sm mt-1">Join {siteName} today</p>
<p className="text-gray-600 text-sm mt-1">Join ShopX today</p>
</div>
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
+12 -14
View File
@@ -3,33 +3,31 @@
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useUser } from '@/context/UserContext'
export function Navbar() {
const [cartCount, setCartCount] = useState(0)
const [siteName, setSiteName] = useState('ShopX')
const { user, refreshUser } = useUser()
const [user, setUser] = useState<{ name?: string; email: string; role: string } | null>(null)
const router = useRouter()
useEffect(() => {
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
const count = cart.reduce((sum: number, item: { quantity: number }) => sum + item.quantity, 0)
setCartCount(count)
}, [])
useEffect(() => {
fetch('/api/settings')
.then((r) => r.json())
.then((data) => {
const name = data.settings?.site_name
if (name) setSiteName(name as string)
})
.catch(() => {})
const userData = localStorage.getItem('user')
if (userData) {
try {
setUser(JSON.parse(userData))
} catch {
// ignore
}
}
}, [])
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' })
await refreshUser()
localStorage.removeItem('user')
setUser(null)
router.push('/')
router.refresh()
}
@@ -39,7 +37,7 @@ export function Navbar() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<Link href="/" className="text-xl font-bold text-gray-900">
{siteName}
ShopX
</Link>
<nav className="flex items-center gap-6 text-sm">
-60
View File
@@ -1,60 +0,0 @@
'use client'
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
interface User {
id: string
email: string
name: string
role: string
mustChangePassword: boolean
}
interface UserContextValue {
user: User | null
isLoading: boolean
setUser: (user: User | null) => void
refreshUser: () => Promise<User | null>
}
const UserContext = createContext<UserContextValue | null>(null)
export function UserProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const refreshUser = useCallback(async (): Promise<User | null> => {
try {
const res = await fetch('/api/auth/me')
if (res.ok) {
const data = await res.json()
setUser(data.user)
return data.user
} else {
setUser(null)
return null
}
} catch {
setUser(null)
return null
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
refreshUser()
}, [refreshUser])
return (
<UserContext.Provider value={{ user, isLoading, setUser, refreshUser }}>
{children}
</UserContext.Provider>
)
}
export function useUser(): UserContextValue {
const ctx = useContext(UserContext)
if (!ctx) throw new Error('useUser must be used within a UserProvider')
return ctx
}
-25
View File
@@ -1,25 +0,0 @@
import { prisma } from '@/lib/prisma'
const MAX_ATTEMPTS = 10
const WINDOW_MS = 15 * 60 * 1000 // 15 minutes
export async function checkRateLimit(key: string): Promise<{ limited: boolean; remaining: number }> {
const windowStart = new Date(Date.now() - WINDOW_MS)
const count = await prisma.loginAttempt.count({
where: { key, createdAt: { gte: windowStart } },
})
if (count >= MAX_ATTEMPTS) {
return { limited: true, remaining: 0 }
}
return { limited: false, remaining: MAX_ATTEMPTS - count }
}
export async function recordAttempt(key: string): Promise<void> {
await prisma.loginAttempt.create({ data: { key } })
// Clean up old records (keep last 24h only)
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000)
await prisma.loginAttempt.deleteMany({ where: { createdAt: { lt: cutoff } } })
}
-17
View File
@@ -16,23 +16,6 @@ export async function saveImage(
return `/uploads/${productId}/${filename}`
}
const IMAGE_MAGIC_BYTES: Record<string, (buf: Buffer) => boolean> = {
'image/jpeg': (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
'image/png': (b) => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47,
'image/webp': (b) =>
b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50,
'image/x-icon': (b) => b[0] === 0x00 && b[1] === 0x00 && b[2] === 0x01 && b[3] === 0x00,
}
export async function validateImageMagicBytes(file: File, declaredType: string): Promise<boolean> {
const checker = IMAGE_MAGIC_BYTES[declaredType]
if (!checker) return false
const arrayBuffer = await file.slice(0, 12).arrayBuffer()
const buf = Buffer.from(arrayBuffer)
return checker(buf)
}
export async function deleteImageFile(url: string): Promise<void> {
const filePath = path.join(process.cwd(), 'public', url)
await unlink(filePath)
-8
View File
@@ -4,14 +4,6 @@ import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const response = NextResponse.next()
response.headers.set('x-pathname', request.nextUrl.pathname)
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://api.stripe.com; frame-src https://js.stripe.com; frame-ancestors 'none'"
)
return response
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "ES2017",
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
+14 -18
View File
@@ -3,11 +3,11 @@ services:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-ecommerce}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ecommerce_password}
POSTGRES_DB: ${POSTGRES_DB:-ecommerce}
POSTGRES_USER: ecommerce
POSTGRES_PASSWORD: ecommerce_password
POSTGRES_DB: ecommerce
volumes:
- ./data/db:/var/lib/postgresql/data
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ecommerce"]
interval: 5s
@@ -24,11 +24,11 @@ services:
condition: service_healthy
env_file: .env
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-ecommerce}:${POSTGRES_PASSWORD:-ecommerce_password}@db:5432/${POSTGRES_DB:-ecommerce}
DATABASE_URL: postgresql://ecommerce:ecommerce_password@db:5432/ecommerce
expose:
- "3000"
volumes:
- ./data/uploads:/app/public/uploads
- uploads:/app/public/uploads
mailpit:
image: axllent/mailpit:latest
@@ -44,18 +44,14 @@ services:
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./data/caddy/data:/data
- ./data/caddy/config:/config
- ./data/uploads:/srv/uploads
- caddy_data:/data
- caddy_config:/config
- uploads:/srv/uploads
depends_on:
- app
stripe-cli:
image: stripe/stripe-cli:latest
command: listen --forward-to http://app:3000/api/webhooks/stripe --api-key ${STRIPE_SECRET_KEY}
depends_on:
- app
restart: unless-stopped
profiles:
- dev
volumes:
pgdata:
caddy_data:
caddy_config:
uploads:
-95
View File
@@ -1,95 +0,0 @@
# Backup e Ripristino
## Cosa viene salvato
| Dato | Posizione | Contenuto |
|------|-----------|-----------|
| Database | `data/db/` | Utenti, prodotti, ordini, impostazioni |
| Upload | `data/uploads/` | Immagini prodotti, favicon |
---
## Backup manuale
### Script automatico (consigliato)
```bash
./scripts/backup.sh
```
Crea una cartella `backups/YYYYMMDD_HHMMSS/` con:
- `db.sql.gz` — dump compresso del database
- `uploads.tar.gz` — archivio delle immagini (se presenti)
I backup più vecchi di 30 giorni vengono eliminati automaticamente.
### Percorso di destinazione personalizzato
```bash
BACKUP_DIR=/mnt/nas/backups ./scripts/backup.sh
```
---
## Backup manuale passo passo
### 1. Database
```bash
# Crea dump SQL compresso
docker compose exec -T db pg_dump -U ecommerce ecommerce | gzip > backup_db.sql.gz
# Verifica che il file non sia vuoto
ls -lh backup_db.sql.gz
```
### 2. Immagini e file caricati
```bash
# La cartella è leggibile direttamente dall'host
tar -czf backup_uploads.tar.gz -C data uploads
```
---
## Ripristino
### 1. Ripristino database
```bash
# I container devono essere in esecuzione
gunzip -c backups/20260519_120000/db.sql.gz | docker compose exec -T db psql -U ecommerce ecommerce
```
> Se il database contiene già dati, svuotalo prima:
> ```bash
> docker compose exec db psql -U ecommerce -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" ecommerce
> ```
### 2. Ripristino immagini
```bash
tar -xzf backups/20260519_120000/uploads.tar.gz -C data
```
---
## Backup automatico (cron)
Per eseguire il backup ogni notte alle 02:00, aggiungi questa riga al crontab dell'host:
```bash
crontab -e
```
```
0 2 * * * /home/davide/ecommerce-platform/scripts/backup.sh >> /var/log/ecommerce-backup.log 2>&1
```
---
## Note importanti
- La cartella `data/db/` **non è leggibile** dall'utente host (appartiene all'utente interno di PostgreSQL). Usare sempre `pg_dump` per i backup del DB, mai copiare i file direttamente.
- La cartella `backups/` è esclusa da git (`.gitignore`). Salva i backup su storage esterno o NAS.
- I container devono essere **in esecuzione** durante il backup del database.
-89
View File
@@ -1,89 +0,0 @@
# Personalizzazione dell'e-commerce
Tutte le personalizzazioni principali si gestiscono dal **pannello admin** senza toccare il codice, oppure modificando file specifici descritti qui sotto.
---
## 1. Tramite pannello admin (senza toccare il codice)
Vai su `/admin/settings` per modificare:
| Impostazione | Campo | Descrizione |
|---|---|---|
| Nome del sito | `site_name` | Appare nel titolo del browser e nel footer |
| Descrizione | `site_description` | Meta description per SEO |
| Copyright footer | `footer_copyright` | Testo in basso a sinistra nel footer |
| Link nel footer | `footer_links` | Array JSON di link `[{"label":"Chi siamo","url":"/about"}]` |
| Favicon (URL) | `favicon_url` | URL di un'immagine esterna da usare come favicon |
Queste impostazioni sono salvate nel database e applicate in tempo reale.
---
## 2. Favicon (icona del browser)
**Modo consigliato — file locale:**
1. Sostituisci il file [app/src/app/icon.png](../app/src/app/icon.png) con la tua icona
2. Formato: PNG con sfondo trasparente, dimensione minima 64×64px (ideale 512×512px)
3. Ricostruisci il container: `docker compose up --build app -d`
> La favicon da pannello admin (`favicon_url`) ha la precedenza sul file locale.
> Se vuoi usare solo il file locale, lascia `favicon_url` vuoto nel database.
**Fallback applicato in** [app/src/app/layout.tsx](../app/src/app/layout.tsx):
```ts
icons: { icon: (s.favicon_url as string) || '/icon.png' },
```
---
## 3. Titolo e descrizione del sito
**Via pannello admin** (consigliato): imposta `site_name` e `site_description` in `/admin/settings`.
**Valori di fallback nel codice** — [app/src/app/layout.tsx](../app/src/app/layout.tsx) righe 13-14:
```ts
title: (s.site_name as string) || 'ShopX', // ← cambia 'ShopX'
description: (s.site_description as string) || 'Your online store',
```
---
## 4. Footer
**Via pannello admin** (consigliato): imposta `footer_copyright` e `footer_links`.
**Struttura del componente** — [app/src/components/storefront/Footer.tsx](../app/src/components/storefront/Footer.tsx):
- Testo copyright: riga con `footer_copyright` o fallback automatico `© anno NomeSito`
- Link di navigazione: array JSON salvato in `footer_links`
Esempio `footer_links`:
```json
[
{ "label": "Chi siamo", "url": "/about" },
{ "label": "Contatti", "url": "/contact" },
{ "label": "Privacy", "url": "/privacy" }
]
```
---
## 5. Riepilogo file da modificare (solo codice)
| Cosa | File |
|---|---|
| Favicon locale | [app/src/app/icon.png](../app/src/app/icon.png) |
| Fallback titolo/favicon | [app/src/app/layout.tsx](../app/src/app/layout.tsx) |
| Struttura footer | [app/src/components/storefront/Footer.tsx](../app/src/components/storefront/Footer.tsx) |
| Stili globali | [app/src/app/globals.css](../app/src/app/globals.css) |
---
## 6. Dopo modifiche al codice
Se hai modificato file nel codice (non solo il pannello admin), ricostruisci il container:
```bash
docker compose up --build app -d
```
-29
View File
@@ -1,29 +0,0 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
BACKUP_DIR="${BACKUP_DIR:-$PROJECT_DIR/backups}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_PATH="$BACKUP_DIR/$TIMESTAMP"
mkdir -p "$BACKUP_PATH"
echo "[1/2] Backup database..."
docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T db \
pg_dump -U ecommerce ecommerce | gzip > "$BACKUP_PATH/db.sql.gz"
echo "$BACKUP_PATH/db.sql.gz"
echo "[2/2] Backup uploads..."
if [ -d "$PROJECT_DIR/data/uploads" ] && [ "$(ls -A "$PROJECT_DIR/data/uploads")" ]; then
tar -czf "$BACKUP_PATH/uploads.tar.gz" -C "$PROJECT_DIR/data" uploads
echo "$BACKUP_PATH/uploads.tar.gz"
else
echo " (nessun file in data/uploads, saltato)"
fi
echo ""
echo "Backup completato: $BACKUP_PATH"
# Rimuovi backup più vecchi di 30 giorni
find "$BACKUP_DIR" -maxdepth 1 -type d -mtime +30 -exec rm -rf {} + 2>/dev/null || true
-397
View File
@@ -1,397 +0,0 @@
# 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<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`:
```typescript
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`:
```typescript
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:
```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<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:
```typescript
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
-81
View File
@@ -1,81 +0,0 @@
import { vi } from 'vitest'
export const prisma = {
user: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
session: {
create: vi.fn(),
findUnique: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
},
loginAttempt: {
count: vi.fn(),
create: vi.fn(),
deleteMany: vi.fn(),
},
order: {
create: vi.fn(),
update: vi.fn(),
findUnique: vi.fn(),
findMany: vi.fn(),
count: vi.fn(),
},
orderItem: {
create: vi.fn(),
findMany: vi.fn(),
},
payment: {
create: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
},
product: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
productType: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
category: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
review: {
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
auditLog: {
create: vi.fn(),
findMany: vi.fn(),
},
siteSettings: {
findMany: vi.fn(),
upsert: vi.fn(),
},
$transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(prisma)),
}
vi.mock('@/lib/prisma', () => ({ prisma }))
@@ -1,76 +0,0 @@
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) => (
<a href={href} className={className}>{children}</a>
),
}))
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(<ProductCard product={baseProduct} />)
expect(screen.getByText('Maglietta Blu')).toBeTruthy()
})
it('formats price correctly (cents → euros with 2 decimals)', () => {
render(<ProductCard product={baseProduct} />)
expect(screen.getByText(/29\.99/)).toBeTruthy()
})
it('shows the currency', () => {
render(<ProductCard product={baseProduct} />)
expect(screen.getByText(/EUR/)).toBeTruthy()
})
it('links to the correct product URL', () => {
const { container } = render(<ProductCard product={baseProduct} />)
const link = container.querySelector('a')
expect(link?.getAttribute('href')).toBe('/products/maglietta-blu')
})
it('renders the product image when images are present', () => {
render(<ProductCard product={baseProduct} />)
const img = screen.getByRole('img')
expect(img.getAttribute('src')).toBe('/uploads/prod-1/photo.jpg')
})
it('uses altText when provided', () => {
render(<ProductCard product={baseProduct} />)
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(<ProductCard product={product} />)
expect(screen.getByRole('img').getAttribute('alt')).toBe('Maglietta Blu')
})
it('shows "No image" placeholder when images array is empty', () => {
render(<ProductCard product={{ ...baseProduct, images: [] }} />)
expect(screen.getByText('No image')).toBeTruthy()
})
it('does not render img tag when no images', () => {
render(<ProductCard product={{ ...baseProduct, images: [] }} />)
expect(screen.queryByRole('img')).toBeNull()
})
it('formats price = 0 correctly', () => {
render(<ProductCard product={{ ...baseProduct, basePrice: 0 }} />)
expect(screen.getByText(/0\.00/)).toBeTruthy()
})
})
@@ -1,90 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import ProductDetailPage from '@/app/products/[slug]/page'
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useParams: () => ({ slug: 'test-product' }),
useRouter: () => ({ push: mockPush }),
}))
vi.mock('@/components/storefront/Navbar', () => ({
Navbar: () => null,
}))
const mockProduct = {
product: {
id: 'prod-1',
title: 'Test Product',
slug: 'test-product',
description: 'A test product',
basePrice: 1000,
currency: 'EUR',
stock: 10,
images: [],
variants: [],
categories: [],
reviews: [],
},
}
describe('ProductDetailPage - cart behavior', () => {
beforeEach(() => {
localStorage.clear()
mockPush.mockClear()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ json: () => Promise.resolve(mockProduct) })
)
})
it('Add to Cart aggiunge 1 articolo al carrello', async () => {
render(<ProductDetailPage />)
await waitFor(() => screen.getByText('Test Product'))
fireEvent.click(screen.getByText('Add to Cart'))
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
expect(cart).toHaveLength(1)
expect(cart[0].quantity).toBe(1)
expect(cart[0].productId).toBe('prod-1')
})
it('Add to Cart cliccato due volte incrementa la quantità a 2', async () => {
render(<ProductDetailPage />)
await waitFor(() => screen.getByText('Test Product'))
fireEvent.click(screen.getByText('Add to Cart'))
fireEvent.click(screen.getByText('Add to Cart'))
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
expect(cart).toHaveLength(1)
expect(cart[0].quantity).toBe(2)
})
it('Buy Now aggiunge 1 articolo e naviga al carrello', async () => {
render(<ProductDetailPage />)
await waitFor(() => screen.getByText('Test Product'))
fireEvent.click(screen.getByText('Buy Now'))
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
expect(cart).toHaveLength(1)
expect(cart[0].quantity).toBe(1)
expect(mockPush).toHaveBeenCalledWith('/cart')
})
it('Add to Cart seguito da Buy Now non duplica: rimane 1 articolo con quantità 1', async () => {
render(<ProductDetailPage />)
await waitFor(() => screen.getByText('Test Product'))
fireEvent.click(screen.getByText('Add to Cart'))
fireEvent.click(screen.getByText('Buy Now'))
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
expect(cart).toHaveLength(1)
expect(cart[0].quantity).toBe(1)
expect(mockPush).toHaveBeenCalledWith('/cart')
})
})
-60
View File
@@ -1,60 +0,0 @@
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(<Button>Clicca qui</Button>)
expect(screen.getByText('Clicca qui')).toBeTruthy()
})
it('is disabled when loading=true', () => {
render(<Button loading>Salva</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
it('shows spinner SVG when loading=true', () => {
const { container } = render(<Button loading>Salva</Button>)
expect(container.querySelector('svg')).toBeTruthy()
})
it('does not show spinner when not loading', () => {
const { container } = render(<Button>Salva</Button>)
expect(container.querySelector('svg')).toBeNull()
})
it('is disabled when disabled prop is set', () => {
render(<Button disabled>Salva</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
it('calls onClick when clicked', async () => {
const onClick = vi.fn()
render(<Button onClick={onClick}>Clic</Button>)
await userEvent.click(screen.getByRole('button'))
expect(onClick).toHaveBeenCalledOnce()
})
it('does not call onClick when disabled', async () => {
const onClick = vi.fn()
render(<Button disabled onClick={onClick}>Clic</Button>)
await userEvent.click(screen.getByRole('button'))
expect(onClick).not.toHaveBeenCalled()
})
it('applies primary variant class by default', () => {
render(<Button>Primary</Button>)
expect(screen.getByRole('button').className).toContain('bg-blue-600')
})
it('applies danger variant class', () => {
render(<Button variant="danger">Elimina</Button>)
expect(screen.getByRole('button').className).toContain('bg-red-600')
})
it('applies secondary variant class', () => {
render(<Button variant="secondary">Annulla</Button>)
expect(screen.getByRole('button').className).toContain('bg-gray-200')
})
})
-53
View File
@@ -1,53 +0,0 @@
import { mockUser } from './users'
export const mockOrder = {
id: 'order-1',
userId: mockUser.id,
status: 'PENDING' as const,
grandTotal: 2999,
currency: 'EUR',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
user: mockUser,
}
export const mockPayment = {
id: 'payment-1',
orderId: mockOrder.id,
provider: 'stripe',
providerPaymentId: 'pi_test_123',
status: 'pending',
amount: 2999,
currency: 'EUR',
rawPayload: {},
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
}
export const mockStripeCheckoutEvent = {
type: 'checkout.session.completed',
data: {
object: {
metadata: { orderId: mockOrder.id },
payment_intent: 'pi_test_123',
},
},
}
export const mockStripePaymentSucceededEvent = {
type: 'payment_intent.succeeded',
data: {
object: {
id: 'pi_test_123',
},
},
}
export const mockStripePaymentFailedEvent = {
type: 'payment_intent.payment_failed',
data: {
object: {
id: 'pi_test_123',
},
},
}
-27
View File
@@ -1,27 +0,0 @@
export const mockUser = {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
passwordHash: '$2a$12$hashedpassword',
role: 'CUSTOMER' as const,
mustChangePassword: false,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
}
export const mockAdmin = {
...mockUser,
id: 'admin-1',
email: 'admin@example.com',
name: 'Admin User',
role: 'ADMIN' as const,
}
export const mockSession = {
id: 'session-1',
userId: mockUser.id,
tokenHash: 'abc123hash',
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date('2024-01-01'),
user: mockUser,
}
-119
View File
@@ -1,119 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import '../../../__mocks__/prisma'
import { NextRequest } from 'next/server'
import { POST } from '@/app/api/auth/login/route'
import { prisma } from '@/lib/prisma'
import { mockUser } from '../../../fixtures/users'
vi.mock('@/lib/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth')>()
return {
...actual,
verifyPassword: vi.fn(),
createSession: vi.fn().mockResolvedValue('mock-session-token'),
setSessionCookie: vi.fn(),
}
})
import { verifyPassword, createSession, setSessionCookie } from '@/lib/auth'
function makeRequest(body: unknown, ip = '1.2.3.4') {
return new NextRequest(
new Request('http://localhost/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': ip },
body: JSON.stringify(body),
})
)
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(0)
vi.mocked(prisma.loginAttempt.create).mockResolvedValue({ id: '1', key: '1.2.3.4', createdAt: new Date() })
vi.mocked(prisma.loginAttempt.deleteMany).mockResolvedValue({ count: 0 })
})
describe('POST /api/auth/login', () => {
it('returns 429 when rate limit exceeded', async () => {
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(10)
const res = await POST(makeRequest({ email: 'user@example.com', password: 'pass' }))
expect(res.status).toBe(429)
const data = await res.json()
expect(data.error).toMatch(/Too many/)
})
it('returns 400 for invalid email format', async () => {
const res = await POST(makeRequest({ email: 'not-an-email', password: 'pass' }))
expect(res.status).toBe(400)
})
it('returns 400 for empty password', async () => {
const res = await POST(makeRequest({ email: 'user@example.com', password: '' }))
expect(res.status).toBe(400)
})
it('returns 400 for malformed JSON body', async () => {
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: 'not-json',
})
)
const res = await POST(req)
expect(res.status).toBe(400)
})
it('returns 401 when user not found', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null)
const res = await POST(makeRequest({ email: 'notfound@example.com', password: 'SomePass1!' }))
expect(res.status).toBe(401)
const data = await res.json()
expect(data.error).toBe('Invalid email or password')
})
it('records a failed attempt when user not found', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null)
await POST(makeRequest({ email: 'notfound@example.com', password: 'SomePass1!' }))
expect(prisma.loginAttempt.create).toHaveBeenCalledWith({ data: { key: '1.2.3.4' } })
})
it('returns 401 when password is wrong', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any)
vi.mocked(verifyPassword).mockResolvedValue(false)
const res = await POST(makeRequest({ email: mockUser.email, password: 'WrongPass1!' }))
expect(res.status).toBe(401)
expect((await res.json()).error).toBe('Invalid email or password')
})
it('returns 200 with user data on successful login', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any)
vi.mocked(verifyPassword).mockResolvedValue(true)
vi.mocked(prisma.session.create).mockResolvedValue({} as any)
const res = await POST(makeRequest({ email: mockUser.email, password: 'ValidPass1!' }))
expect(res.status).toBe(200)
const data = await res.json()
expect(data.user.email).toBe(mockUser.email)
expect(data.user.role).toBe(mockUser.role)
expect(data.user).not.toHaveProperty('passwordHash')
})
it('creates a session and sets cookie on successful login', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any)
vi.mocked(verifyPassword).mockResolvedValue(true)
vi.mocked(prisma.session.create).mockResolvedValue({} as any)
await POST(makeRequest({ email: mockUser.email, password: 'ValidPass1!' }))
expect(createSession).toHaveBeenCalledWith(mockUser.id)
expect(setSessionCookie).toHaveBeenCalledWith('mock-session-token')
})
})
-107
View File
@@ -1,107 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import '../../../__mocks__/prisma'
import { NextRequest } from 'next/server'
import { POST } from '@/app/api/auth/register/route'
import { prisma } from '@/lib/prisma'
import { mockUser } from '../../../fixtures/users'
vi.mock('@/lib/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth')>()
return {
...actual,
hashPassword: vi.fn().mockResolvedValue('$2a$12$hashedpassword'),
createSession: vi.fn().mockResolvedValue('mock-token'),
setSessionCookie: vi.fn(),
}
})
import { hashPassword, createSession, setSessionCookie } from '@/lib/auth'
function makeRequest(body: unknown) {
return new NextRequest(
new Request('http://localhost/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
)
}
const validBody = {
email: 'newuser@example.com',
password: 'ValidPass123!',
name: 'Nuovo Utente',
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.user.findUnique).mockResolvedValue(null)
vi.mocked(prisma.user.create).mockResolvedValue({ ...mockUser, email: validBody.email, name: validBody.name } as any)
vi.mocked(prisma.session.create).mockResolvedValue({} as any)
})
describe('POST /api/auth/register', () => {
it('returns 201 with user data on successful registration', async () => {
const res = await POST(makeRequest(validBody))
expect(res.status).toBe(201)
const data = await res.json()
expect(data.user.email).toBe(validBody.email)
expect(data.user).not.toHaveProperty('passwordHash')
})
it('hashes the password before storing', async () => {
await POST(makeRequest(validBody))
expect(hashPassword).toHaveBeenCalledWith(validBody.password)
const createCall = vi.mocked(prisma.user.create).mock.calls[0][0]
expect(createCall.data.passwordHash).toBe('$2a$12$hashedpassword')
})
it('creates a session and sets cookie after registration', async () => {
await POST(makeRequest(validBody))
expect(createSession).toHaveBeenCalled()
expect(setSessionCookie).toHaveBeenCalledWith('mock-token')
})
it('sets role to CUSTOMER', async () => {
await POST(makeRequest(validBody))
const createCall = vi.mocked(prisma.user.create).mock.calls[0][0]
expect(createCall.data.role).toBe('CUSTOMER')
})
it('returns 409 when email is already in use', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any)
const res = await POST(makeRequest(validBody))
expect(res.status).toBe(409)
const data = await res.json()
expect(data.error).toMatch(/already/)
})
it('returns 400 for invalid email', async () => {
const res = await POST(makeRequest({ ...validBody, email: 'not-an-email' }))
expect(res.status).toBe(400)
})
it('returns 400 for weak password (too short)', async () => {
const res = await POST(makeRequest({ ...validBody, password: 'Short1!' }))
expect(res.status).toBe(400)
})
it('returns 400 for empty name', async () => {
const res = await POST(makeRequest({ ...validBody, name: '' }))
expect(res.status).toBe(400)
})
it('returns 400 for malformed JSON', async () => {
const req = new NextRequest(
new Request('http://localhost/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: 'bad-json',
})
)
const res = await POST(req)
expect(res.status).toBe(400)
})
})
@@ -1,166 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import '../../../__mocks__/prisma'
import { NextRequest } from 'next/server'
import { POST } from '@/app/api/webhooks/stripe/route'
import { prisma } from '@/lib/prisma'
import {
mockOrder,
mockPayment,
mockStripeCheckoutEvent,
mockStripePaymentSucceededEvent,
mockStripePaymentFailedEvent,
} from '../../../fixtures/orders'
vi.mock('@/lib/stripe', () => ({
constructWebhookEvent: vi.fn(),
}))
vi.mock('@/lib/email', () => ({
sendOrderConfirmationEmail: vi.fn().mockResolvedValue(undefined),
}))
import { constructWebhookEvent } from '@/lib/stripe'
import { sendOrderConfirmationEmail } from '@/lib/email'
function makeRequest(body: string, signature = 'valid-sig') {
return new NextRequest(
new Request('http://localhost/api/webhooks/stripe', {
method: 'POST',
headers: { 'stripe-signature': signature, 'Content-Type': 'text/plain' },
body,
})
)
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.order.update).mockResolvedValue(mockOrder as any)
vi.mocked(prisma.payment.updateMany).mockResolvedValue({ count: 1 })
vi.mocked(prisma.payment.update).mockResolvedValue(mockPayment as any)
vi.mocked(prisma.payment.findFirst).mockResolvedValue(mockPayment as any)
vi.mocked(prisma.order.findUnique).mockResolvedValue({ ...mockOrder, user: mockOrder.user } as any)
})
describe('POST /api/webhooks/stripe', () => {
it('returns 400 when stripe-signature header is missing', async () => {
const req = new NextRequest(
new Request('http://localhost/api/webhooks/stripe', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: '{}',
})
)
const res = await POST(req)
expect(res.status).toBe(400)
expect((await res.json()).error).toMatch(/signature/)
})
it('returns 400 when signature verification fails', async () => {
vi.mocked(constructWebhookEvent).mockImplementation(() => {
throw new Error('stripe_signature_mismatch')
})
const res = await POST(makeRequest('{}', 'bad-sig'))
expect(res.status).toBe(400)
expect((await res.json()).error).toBe('Invalid signature')
})
it('processes checkout.session.completed: updates order to PAID', async () => {
vi.mocked(constructWebhookEvent).mockReturnValue(mockStripeCheckoutEvent as any)
const res = await POST(makeRequest(JSON.stringify(mockStripeCheckoutEvent)))
expect(res.status).toBe(200)
expect(prisma.order.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: mockOrder.id }, data: { status: 'PAID' } })
)
})
it('processes checkout.session.completed: updates payment record', async () => {
vi.mocked(constructWebhookEvent).mockReturnValue(mockStripeCheckoutEvent as any)
await POST(makeRequest(JSON.stringify(mockStripeCheckoutEvent)))
expect(prisma.payment.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { orderId: mockOrder.id },
data: expect.objectContaining({ status: 'paid' }),
})
)
})
it('processes checkout.session.completed: sends confirmation email', async () => {
vi.mocked(constructWebhookEvent).mockReturnValue(mockStripeCheckoutEvent as any)
await POST(makeRequest(JSON.stringify(mockStripeCheckoutEvent)))
expect(sendOrderConfirmationEmail).toHaveBeenCalledWith(
mockOrder.user.email,
expect.objectContaining({ orderId: mockOrder.id })
)
})
it('skips email send if order user has no email', async () => {
vi.mocked(constructWebhookEvent).mockReturnValue(mockStripeCheckoutEvent as any)
vi.mocked(prisma.order.findUnique).mockResolvedValue({ ...mockOrder, user: null } as any)
await POST(makeRequest(JSON.stringify(mockStripeCheckoutEvent)))
expect(sendOrderConfirmationEmail).not.toHaveBeenCalled()
})
it('processes checkout.session.completed without orderId: no DB update', async () => {
const eventWithoutOrderId = {
type: 'checkout.session.completed',
data: { object: { metadata: {}, payment_intent: 'pi_test' } },
}
vi.mocked(constructWebhookEvent).mockReturnValue(eventWithoutOrderId as any)
const res = await POST(makeRequest('{}'))
expect(res.status).toBe(200)
expect(prisma.order.update).not.toHaveBeenCalled()
})
it('processes payment_intent.succeeded: updates payment and order', async () => {
vi.mocked(constructWebhookEvent).mockReturnValue(mockStripePaymentSucceededEvent as any)
const res = await POST(makeRequest(JSON.stringify(mockStripePaymentSucceededEvent)))
expect(res.status).toBe(200)
expect(prisma.payment.update).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ status: 'paid' }) })
)
expect(prisma.order.update).toHaveBeenCalledWith(
expect.objectContaining({ data: { status: 'PAID' } })
)
})
it('processes payment_intent.succeeded: no-op when payment not found', async () => {
vi.mocked(constructWebhookEvent).mockReturnValue(mockStripePaymentSucceededEvent as any)
vi.mocked(prisma.payment.findFirst).mockResolvedValue(null)
const res = await POST(makeRequest('{}'))
expect(res.status).toBe(200)
expect(prisma.payment.update).not.toHaveBeenCalled()
})
it('processes payment_intent.payment_failed: sets status to failed and CANCELLED', async () => {
vi.mocked(constructWebhookEvent).mockReturnValue(mockStripePaymentFailedEvent as any)
const res = await POST(makeRequest(JSON.stringify(mockStripePaymentFailedEvent)))
expect(res.status).toBe(200)
expect(prisma.payment.update).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ status: 'failed' }) })
)
expect(prisma.order.update).toHaveBeenCalledWith(
expect.objectContaining({ data: { status: 'CANCELLED' } })
)
})
it('returns 200 for unhandled event types', async () => {
vi.mocked(constructWebhookEvent).mockReturnValue({ type: 'customer.created', data: { object: {} } } as any)
const res = await POST(makeRequest('{}'))
expect(res.status).toBe(200)
expect((await res.json()).received).toBe(true)
})
})
-31
View File
@@ -1,31 +0,0 @@
import '@testing-library/jest-dom'
import { vi } from 'vitest'
// Env vars necessarie per i test
process.env.STRIPE_SECRET_KEY = 'sk_test_mock'
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_mock'
process.env.SMTP_HOST = 'localhost'
process.env.SMTP_PORT = '1025'
process.env.APP_URL = 'http://localhost'
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'
process.env.NODE_ENV = 'test'
process.env.AUTH_SECRET = 'test-secret-32-chars-minimum-ok!'
// Mock di next/headers (cookies() lancia fuori dal runtime Next.js)
vi.mock('next/headers', () => {
const mockCookieStore = {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
}
return {
cookies: vi.fn(() => mockCookieStore),
}
})
// Mock di next/navigation
vi.mock('next/navigation', () => ({
useRouter: vi.fn(() => ({ push: vi.fn(), replace: vi.fn() })),
usePathname: vi.fn(() => '/'),
redirect: vi.fn(),
}))
-15
View File
@@ -1,15 +0,0 @@
{
"extends": "../app/tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["../app/src/*"]
},
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": [
"./**/*.ts",
"./**/*.tsx"
],
"exclude": ["node_modules"]
}
-222
View File
@@ -1,222 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import '../../../test/__mocks__/prisma'
import {
hashToken,
hashPassword,
verifyPassword,
validatePasswordStrength,
createSession,
getSessionToken,
getSession,
getCurrentUser,
deleteSession,
deleteAllUserSessions,
} from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { mockUser, mockSession } from '../../fixtures/users'
import { cookies } from 'next/headers'
// ─── hashToken ───────────────────────────────────────────────────────────────
describe('hashToken', () => {
it('returns a 64-char hex string', () => {
const hash = hashToken('sometoken')
expect(hash).toMatch(/^[a-f0-9]{64}$/)
})
it('is deterministic for the same input', () => {
expect(hashToken('abc')).toBe(hashToken('abc'))
})
it('produces different hashes for different inputs', () => {
expect(hashToken('abc')).not.toBe(hashToken('xyz'))
})
})
// ─── hashPassword / verifyPassword ───────────────────────────────────────────
describe('hashPassword / verifyPassword', () => {
it('hashes a password and verifies it correctly', async () => {
const hash = await hashPassword('MyPassword1!')
expect(await verifyPassword('MyPassword1!', hash)).toBe(true)
})
it('returns false for wrong password', async () => {
const hash = await hashPassword('MyPassword1!')
expect(await verifyPassword('WrongPassword1!', hash)).toBe(false)
})
it('produces different hashes for the same password (bcrypt salt)', async () => {
const h1 = await hashPassword('SamePass1!')
const h2 = await hashPassword('SamePass1!')
expect(h1).not.toBe(h2)
})
})
// ─── validatePasswordStrength ─────────────────────────────────────────────────
describe('validatePasswordStrength', () => {
it('returns null for a strong password', () => {
expect(validatePasswordStrength('StrongPass1!')).toBeNull()
})
it('rejects password shorter than 12 chars', () => {
expect(validatePasswordStrength('Short1!')).toMatch(/12 characters/)
})
it('rejects password without uppercase', () => {
expect(validatePasswordStrength('nouppercase1!')).toMatch(/uppercase/)
})
it('rejects password without lowercase', () => {
expect(validatePasswordStrength('NOLOWERCASE1!')).toMatch(/lowercase/)
})
it('rejects password without number', () => {
expect(validatePasswordStrength('NoNumberHere!')).toMatch(/number/)
})
it('rejects password without symbol', () => {
expect(validatePasswordStrength('NoSymbolHere12')).toMatch(/symbol/)
})
it('checks length first (shortest error message path)', () => {
expect(validatePasswordStrength('short')).toMatch(/12 characters/)
})
})
// ─── createSession ────────────────────────────────────────────────────────────
describe('createSession', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.session.create).mockResolvedValue(mockSession)
})
it('creates a session in DB and returns a token string', async () => {
const token = await createSession(mockUser.id)
expect(typeof token).toBe('string')
expect(token.length).toBeGreaterThan(0)
})
it('calls prisma.session.create with userId and tokenHash', async () => {
const token = await createSession(mockUser.id)
expect(prisma.session.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ userId: mockUser.id }),
})
)
const callArg = vi.mocked(prisma.session.create).mock.calls[0][0]
const tokenHash = hashToken(token)
expect(callArg.data.tokenHash).toBe(tokenHash)
})
it('sets expiry ~30 days from now', async () => {
await createSession(mockUser.id)
const callArg = vi.mocked(prisma.session.create).mock.calls[0][0]
const expiresAt = callArg.data.expiresAt as Date
const diffDays = (expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
expect(diffDays).toBeCloseTo(30, 0)
})
})
// ─── getSessionToken ──────────────────────────────────────────────────────────
describe('getSessionToken', () => {
it('returns token from cookie', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'mytoken' }), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
expect(await getSessionToken()).toBe('mytoken')
})
it('returns null when cookie is missing', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue(undefined), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
expect(await getSessionToken()).toBeNull()
})
})
// ─── getSession ───────────────────────────────────────────────────────────────
describe('getSession', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns null when no token cookie', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue(undefined), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
expect(await getSession()).toBeNull()
})
it('returns session when token is valid and not expired', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'validtoken' }), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
vi.mocked(prisma.session.findUnique).mockResolvedValue(mockSession as any)
const session = await getSession()
expect(session).not.toBeNull()
expect(session?.user.id).toBe(mockUser.id)
})
it('returns null and deletes session when expired', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'expiredtoken' }), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
const expiredSession = { ...mockSession, expiresAt: new Date(Date.now() - 1000) }
vi.mocked(prisma.session.findUnique).mockResolvedValue(expiredSession as any)
vi.mocked(prisma.session.delete).mockResolvedValue(expiredSession as any)
const result = await getSession()
expect(result).toBeNull()
expect(prisma.session.delete).toHaveBeenCalledWith({ where: { id: expiredSession.id } })
})
it('returns null when session not found in DB', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'unknowntoken' }), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
vi.mocked(prisma.session.findUnique).mockResolvedValue(null)
expect(await getSession()).toBeNull()
})
})
// ─── getCurrentUser ───────────────────────────────────────────────────────────
describe('getCurrentUser', () => {
it('returns user from valid session', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'validtoken' }), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
vi.mocked(prisma.session.findUnique).mockResolvedValue(mockSession as any)
const user = await getCurrentUser()
expect(user?.id).toBe(mockUser.id)
})
it('returns null when no session', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue(undefined), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
expect(await getCurrentUser()).toBeNull()
})
})
// ─── deleteSession / deleteAllUserSessions ────────────────────────────────────
describe('deleteSession', () => {
it('calls prisma.session.deleteMany with the correct tokenHash', async () => {
vi.mocked(prisma.session.deleteMany).mockResolvedValue({ count: 1 })
await deleteSession('mytoken')
expect(prisma.session.deleteMany).toHaveBeenCalledWith({
where: { tokenHash: hashToken('mytoken') },
})
})
})
describe('deleteAllUserSessions', () => {
it('calls prisma.session.deleteMany with the userId', async () => {
vi.mocked(prisma.session.deleteMany).mockResolvedValue({ count: 3 })
await deleteAllUserSessions('user-123')
expect(prisma.session.deleteMany).toHaveBeenCalledWith({ where: { userId: 'user-123' } })
})
})
-109
View File
@@ -1,109 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockSendMail, mockCreateTransport } = vi.hoisted(() => {
const mockSendMail = vi.fn().mockResolvedValue({ messageId: 'mock-id' })
const mockCreateTransport = vi.fn().mockReturnValue({ sendMail: mockSendMail })
return { mockSendMail, mockCreateTransport }
})
vi.mock('nodemailer', () => ({
default: { createTransport: mockCreateTransport },
}))
import { sendEmail, sendOrderConfirmationEmail, sendPasswordResetEmail } from '@/lib/email'
beforeEach(() => {
vi.clearAllMocks()
mockSendMail.mockResolvedValue({ messageId: 'mock-id' })
})
// ─── sendEmail ────────────────────────────────────────────────────────────────
describe('sendEmail', () => {
it('calls sendMail with correct to, subject, html', async () => {
await sendEmail({ to: 'user@example.com', subject: 'Test', html: '<p>Hello</p>' })
expect(mockSendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'user@example.com',
subject: 'Test',
html: '<p>Hello</p>',
})
)
})
it('includes optional text field when provided', async () => {
await sendEmail({ to: 'u@e.com', subject: 'S', html: '<p>h</p>', text: 'plain text' })
expect(mockSendMail).toHaveBeenCalledWith(expect.objectContaining({ text: 'plain text' }))
})
it('propagates sendMail errors', async () => {
mockSendMail.mockRejectedValueOnce(new Error('SMTP down'))
await expect(sendEmail({ to: 'u@e.com', subject: 'S', html: '<p>h</p>' })).rejects.toThrow('SMTP down')
})
})
// ─── sendOrderConfirmationEmail ───────────────────────────────────────────────
describe('sendOrderConfirmationEmail', () => {
it('sends email with correct subject containing orderId', async () => {
await sendOrderConfirmationEmail('customer@example.com', {
orderId: 'order-42',
grandTotal: 2999,
currency: 'EUR',
})
expect(mockSendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'customer@example.com',
subject: expect.stringContaining('order-42'),
})
)
})
it('formats price correctly (cents to euros)', async () => {
await sendOrderConfirmationEmail('customer@example.com', {
orderId: 'order-1',
grandTotal: 2999,
currency: 'EUR',
})
const callArg = mockSendMail.mock.calls[0][0]
expect(callArg.html).toContain('29.99')
expect(callArg.html).toContain('EUR')
})
it('includes orderId in the email body', async () => {
await sendOrderConfirmationEmail('customer@example.com', {
orderId: 'order-xyz',
grandTotal: 1000,
currency: 'USD',
})
const callArg = mockSendMail.mock.calls[0][0]
expect(callArg.html).toContain('order-xyz')
})
it('includes account orders link', async () => {
await sendOrderConfirmationEmail('customer@example.com', {
orderId: 'order-1',
grandTotal: 100,
currency: 'EUR',
})
const callArg = mockSendMail.mock.calls[0][0]
expect(callArg.html).toContain('/account/orders')
})
})
// ─── sendPasswordResetEmail ───────────────────────────────────────────────────
describe('sendPasswordResetEmail', () => {
it('sends email with reset token in URL', async () => {
await sendPasswordResetEmail('user@example.com', 'reset-token-abc')
const callArg = mockSendMail.mock.calls[0][0]
expect(callArg.html).toContain('reset-token-abc')
expect(callArg.to).toBe('user@example.com')
})
it('subject mentions password reset', async () => {
await sendPasswordResetEmail('user@example.com', 'token')
const callArg = mockSendMail.mock.calls[0][0]
expect(callArg.subject).toMatch(/[Pp]assword [Rr]eset/)
})
})
-71
View File
@@ -1,71 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import '../../../test/__mocks__/prisma'
import { checkRateLimit, recordAttempt } from '@/lib/rate-limit'
import { prisma } from '@/lib/prisma'
beforeEach(() => {
vi.clearAllMocks()
})
describe('checkRateLimit', () => {
it('returns not limited when attempts < 10', async () => {
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(5)
const result = await checkRateLimit('1.2.3.4')
expect(result.limited).toBe(false)
expect(result.remaining).toBe(5)
})
it('returns limited when attempts >= 10', async () => {
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(10)
const result = await checkRateLimit('1.2.3.4')
expect(result.limited).toBe(true)
expect(result.remaining).toBe(0)
})
it('returns limited when attempts > 10', async () => {
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(15)
const result = await checkRateLimit('1.2.3.4')
expect(result.limited).toBe(true)
})
it('returns remaining = 1 when attempts = 9', async () => {
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(9)
const result = await checkRateLimit('1.2.3.4')
expect(result.limited).toBe(false)
expect(result.remaining).toBe(1)
})
it('queries with a windowStart within the last 15 minutes', async () => {
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(0)
const before = new Date(Date.now() - 15 * 60 * 1000 - 100)
await checkRateLimit('1.2.3.4')
const callArg = vi.mocked(prisma.loginAttempt.count).mock.calls[0][0]
const windowStart = callArg?.where?.createdAt?.gte as Date
expect(windowStart.getTime()).toBeGreaterThan(before.getTime())
})
})
describe('recordAttempt', () => {
it('creates a login attempt record', async () => {
vi.mocked(prisma.loginAttempt.create).mockResolvedValue({ id: '1', key: '1.2.3.4', createdAt: new Date() })
vi.mocked(prisma.loginAttempt.deleteMany).mockResolvedValue({ count: 0 })
await recordAttempt('1.2.3.4')
expect(prisma.loginAttempt.create).toHaveBeenCalledWith({ data: { key: '1.2.3.4' } })
})
it('cleans up old records after creating', async () => {
vi.mocked(prisma.loginAttempt.create).mockResolvedValue({ id: '1', key: '1.2.3.4', createdAt: new Date() })
vi.mocked(prisma.loginAttempt.deleteMany).mockResolvedValue({ count: 0 })
await recordAttempt('1.2.3.4')
expect(prisma.loginAttempt.deleteMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ createdAt: expect.anything() }) })
)
})
})
-126
View File
@@ -1,126 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockMkdir, mockWriteFile, mockUnlink } = vi.hoisted(() => ({
mockMkdir: vi.fn().mockResolvedValue(undefined),
mockWriteFile: vi.fn().mockResolvedValue(undefined),
mockUnlink: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('fs/promises', () => ({
__esModule: true,
default: { mkdir: mockMkdir, writeFile: mockWriteFile, unlink: mockUnlink },
mkdir: mockMkdir,
writeFile: mockWriteFile,
unlink: mockUnlink,
}))
import { saveImage, validateImageMagicBytes, deleteImageFile } from '@/lib/storage'
function makeJpegBuffer(): Buffer {
const b = Buffer.alloc(12)
b[0] = 0xff; b[1] = 0xd8; b[2] = 0xff
return b
}
function makePngBuffer(): Buffer {
const b = Buffer.alloc(12)
b[0] = 0x89; b[1] = 0x50; b[2] = 0x4e; b[3] = 0x47
return b
}
function makeWebpBuffer(): Buffer {
const b = Buffer.alloc(12)
b[0] = 0x52; b[1] = 0x49; b[2] = 0x46; b[3] = 0x46
b[8] = 0x57; b[9] = 0x45; b[10] = 0x42; b[11] = 0x50
return b
}
function makeIcoBuffer(): Buffer {
const b = Buffer.alloc(12)
b[0] = 0x00; b[1] = 0x00; b[2] = 0x01; b[3] = 0x00
return b
}
function makeFileFromBuffer(buf: Buffer, type: string): File {
return new File([new Uint8Array(buf)], 'test.img', { type })
}
beforeEach(() => {
vi.clearAllMocks()
mockMkdir.mockResolvedValue(undefined)
mockWriteFile.mockResolvedValue(undefined)
mockUnlink.mockResolvedValue(undefined)
})
// ─── validateImageMagicBytes ──────────────────────────────────────────────────
describe('validateImageMagicBytes', () => {
it('accepts a valid JPEG', async () => {
const file = makeFileFromBuffer(makeJpegBuffer(), 'image/jpeg')
expect(await validateImageMagicBytes(file, 'image/jpeg')).toBe(true)
})
it('accepts a valid PNG', async () => {
const file = makeFileFromBuffer(makePngBuffer(), 'image/png')
expect(await validateImageMagicBytes(file, 'image/png')).toBe(true)
})
it('accepts a valid WebP', async () => {
const file = makeFileFromBuffer(makeWebpBuffer(), 'image/webp')
expect(await validateImageMagicBytes(file, 'image/webp')).toBe(true)
})
it('accepts a valid ICO', async () => {
const file = makeFileFromBuffer(makeIcoBuffer(), 'image/x-icon')
expect(await validateImageMagicBytes(file, 'image/x-icon')).toBe(true)
})
it('rejects JPEG buffer declared as PNG', async () => {
const file = makeFileFromBuffer(makeJpegBuffer(), 'image/png')
expect(await validateImageMagicBytes(file, 'image/png')).toBe(false)
})
it('rejects unknown MIME type', async () => {
const file = makeFileFromBuffer(makeJpegBuffer(), 'image/bmp')
expect(await validateImageMagicBytes(file, 'image/bmp')).toBe(false)
})
it('rejects a random buffer as JPEG', async () => {
const file = makeFileFromBuffer(Buffer.from([0x00, 0x01, 0x02]), 'image/jpeg')
expect(await validateImageMagicBytes(file, 'image/jpeg')).toBe(false)
})
})
// ─── saveImage ────────────────────────────────────────────────────────────────
describe('saveImage', () => {
it('creates the product directory and writes file', async () => {
const buf = Buffer.from('fakeimage')
const url = await saveImage('prod-123', buf, 'photo.jpg')
expect(mockMkdir).toHaveBeenCalledWith(expect.stringContaining('prod-123'), { recursive: true })
expect(mockWriteFile).toHaveBeenCalled()
expect(url).toMatch(/^\/uploads\/prod-123\//)
expect(url).toMatch(/photo\.jpg$/)
})
it('sanitizes filename (removes special chars)', async () => {
const buf = Buffer.from('fakeimage')
const url = await saveImage('prod-123', buf, 'my photo (1).JPG')
expect(url).toMatch(/my_photo__1_\.jpg$/)
})
it('returns a URL starting with /uploads/', async () => {
const url = await saveImage('prod-abc', Buffer.from('x'), 'img.png')
expect(url).toMatch(/^\/uploads\/prod-abc\//)
})
})
// ─── deleteImageFile ──────────────────────────────────────────────────────────
describe('deleteImageFile', () => {
it('calls unlink with the correct path', async () => {
await deleteImageFile('/uploads/prod-1/image.jpg')
expect(mockUnlink).toHaveBeenCalledWith(expect.stringContaining('/uploads/prod-1/image.jpg'))
})
})
-329
View File
@@ -1,329 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
loginSchema,
registerSchema,
changePasswordSchema,
productTypeSchema,
productSchema,
categorySchema,
reviewSchema,
cartItemSchema,
checkoutSchema,
settingSchema,
} from '@/lib/validate'
// ─── loginSchema ────────────────────────────────────────────────────────────
describe('loginSchema', () => {
it('accepts valid email and password', () => {
const result = loginSchema.safeParse({ email: 'user@example.com', password: 'anypass' })
expect(result.success).toBe(true)
})
it('rejects invalid email', () => {
const result = loginSchema.safeParse({ email: 'not-an-email', password: 'pass' })
expect(result.success).toBe(false)
expect(result.error?.errors[0].message).toBe('Invalid email address')
})
it('rejects empty password', () => {
const result = loginSchema.safeParse({ email: 'user@example.com', password: '' })
expect(result.success).toBe(false)
})
it('rejects missing fields', () => {
expect(loginSchema.safeParse({}).success).toBe(false)
})
})
// ─── registerSchema ──────────────────────────────────────────────────────────
describe('registerSchema', () => {
const valid = {
email: 'user@example.com',
password: 'ValidPass123!',
name: 'Mario Rossi',
}
it('accepts valid registration data', () => {
expect(registerSchema.safeParse(valid).success).toBe(true)
})
it('rejects password shorter than 12 chars', () => {
const r = registerSchema.safeParse({ ...valid, password: 'Short1!' })
expect(r.success).toBe(false)
expect(r.error?.errors[0].message).toMatch(/12 characters/)
})
it('rejects password without uppercase', () => {
const r = registerSchema.safeParse({ ...valid, password: 'nouppercase1!' })
expect(r.success).toBe(false)
expect(r.error?.errors[0].message).toMatch(/uppercase/)
})
it('rejects password without lowercase', () => {
const r = registerSchema.safeParse({ ...valid, password: 'NOLOWERCASE1!' })
expect(r.success).toBe(false)
expect(r.error?.errors[0].message).toMatch(/lowercase/)
})
it('rejects password without number', () => {
const r = registerSchema.safeParse({ ...valid, password: 'NoNumberHere!' })
expect(r.success).toBe(false)
expect(r.error?.errors[0].message).toMatch(/number/)
})
it('rejects password without symbol', () => {
const r = registerSchema.safeParse({ ...valid, password: 'NoSymbolHere1' })
expect(r.success).toBe(false)
expect(r.error?.errors[0].message).toMatch(/symbol/)
})
it('rejects empty name', () => {
const r = registerSchema.safeParse({ ...valid, name: '' })
expect(r.success).toBe(false)
})
it('rejects name longer than 100 chars', () => {
const r = registerSchema.safeParse({ ...valid, name: 'a'.repeat(101) })
expect(r.success).toBe(false)
})
})
// ─── changePasswordSchema ────────────────────────────────────────────────────
describe('changePasswordSchema', () => {
it('accepts valid passwords', () => {
const r = changePasswordSchema.safeParse({
currentPassword: 'OldPass1!',
newPassword: 'NewStrongPass1!',
})
expect(r.success).toBe(true)
})
it('rejects empty currentPassword', () => {
const r = changePasswordSchema.safeParse({
currentPassword: '',
newPassword: 'NewStrongPass1!',
})
expect(r.success).toBe(false)
})
it('rejects weak newPassword', () => {
const r = changePasswordSchema.safeParse({
currentPassword: 'OldPass1!',
newPassword: 'weak',
})
expect(r.success).toBe(false)
})
})
// ─── productTypeSchema ───────────────────────────────────────────────────────
describe('productTypeSchema', () => {
const valid = { name: 'Abbigliamento', slug: 'abbigliamento', schema: { color: 'string' } }
it('accepts valid product type', () => {
expect(productTypeSchema.safeParse(valid).success).toBe(true)
})
it('rejects slug with uppercase', () => {
const r = productTypeSchema.safeParse({ ...valid, slug: 'Abbigliamento' })
expect(r.success).toBe(false)
})
it('rejects slug with spaces', () => {
const r = productTypeSchema.safeParse({ ...valid, slug: 'con spazio' })
expect(r.success).toBe(false)
})
it('accepts slug with hyphens and numbers', () => {
const r = productTypeSchema.safeParse({ ...valid, slug: 'tipo-123' })
expect(r.success).toBe(true)
})
it('rejects empty name', () => {
expect(productTypeSchema.safeParse({ ...valid, name: '' }).success).toBe(false)
})
})
// ─── productSchema ───────────────────────────────────────────────────────────
describe('productSchema', () => {
const valid = {
typeId: 'type-1',
title: 'Maglietta',
slug: 'maglietta',
description: 'Una bella maglietta',
basePrice: 1999,
currency: 'EUR',
status: 'DRAFT' as const,
attributes: {},
}
it('accepts valid product', () => {
expect(productSchema.safeParse(valid).success).toBe(true)
})
it('rejects negative price', () => {
const r = productSchema.safeParse({ ...valid, basePrice: -1 })
expect(r.success).toBe(false)
})
it('accepts price = 0', () => {
const r = productSchema.safeParse({ ...valid, basePrice: 0 })
expect(r.success).toBe(true)
})
it('rejects currency not 3 chars', () => {
const r = productSchema.safeParse({ ...valid, currency: 'EU' })
expect(r.success).toBe(false)
})
it('rejects invalid status', () => {
const r = productSchema.safeParse({ ...valid, status: 'INVALID' })
expect(r.success).toBe(false)
})
it('accepts all valid statuses', () => {
for (const status of ['DRAFT', 'PUBLISHED', 'ARCHIVED'] as const) {
expect(productSchema.safeParse({ ...valid, status }).success).toBe(true)
}
})
it('accepts optional categoryIds', () => {
const r = productSchema.safeParse({ ...valid, categoryIds: ['cat-1', 'cat-2'] })
expect(r.success).toBe(true)
})
it('rejects non-integer price', () => {
const r = productSchema.safeParse({ ...valid, basePrice: 19.99 })
expect(r.success).toBe(false)
})
})
// ─── categorySchema ──────────────────────────────────────────────────────────
describe('categorySchema', () => {
const valid = { name: 'Uomo', slug: 'uomo' }
it('accepts valid category', () => {
expect(categorySchema.safeParse(valid).success).toBe(true)
})
it('accepts category with parentId', () => {
expect(categorySchema.safeParse({ ...valid, parentId: 'parent-1' }).success).toBe(true)
})
it('accepts parentId = null', () => {
expect(categorySchema.safeParse({ ...valid, parentId: null }).success).toBe(true)
})
it('rejects slug with special chars', () => {
const r = categorySchema.safeParse({ ...valid, slug: 'cat@home' })
expect(r.success).toBe(false)
})
})
// ─── reviewSchema ────────────────────────────────────────────────────────────
describe('reviewSchema', () => {
const valid = { productId: 'prod-1', rating: 4 }
it('accepts valid review', () => {
expect(reviewSchema.safeParse(valid).success).toBe(true)
})
it('rejects rating 0', () => {
expect(reviewSchema.safeParse({ ...valid, rating: 0 }).success).toBe(false)
})
it('rejects rating 6', () => {
expect(reviewSchema.safeParse({ ...valid, rating: 6 }).success).toBe(false)
})
it('accepts all valid ratings 1-5', () => {
for (const rating of [1, 2, 3, 4, 5]) {
expect(reviewSchema.safeParse({ ...valid, rating }).success).toBe(true)
}
})
it('rejects comment longer than 2000 chars', () => {
const r = reviewSchema.safeParse({ ...valid, comment: 'a'.repeat(2001) })
expect(r.success).toBe(false)
})
it('accepts title up to 200 chars', () => {
expect(reviewSchema.safeParse({ ...valid, title: 'a'.repeat(200) }).success).toBe(true)
})
})
// ─── cartItemSchema ──────────────────────────────────────────────────────────
describe('cartItemSchema', () => {
const valid = { productId: 'prod-1', quantity: 2 }
it('accepts valid cart item', () => {
expect(cartItemSchema.safeParse(valid).success).toBe(true)
})
it('rejects quantity 0', () => {
expect(cartItemSchema.safeParse({ ...valid, quantity: 0 }).success).toBe(false)
})
it('rejects quantity over 100', () => {
expect(cartItemSchema.safeParse({ ...valid, quantity: 101 }).success).toBe(false)
})
it('accepts quantity = 100', () => {
expect(cartItemSchema.safeParse({ ...valid, quantity: 100 }).success).toBe(true)
})
it('accepts optional variantId', () => {
expect(cartItemSchema.safeParse({ ...valid, variantId: 'var-1' }).success).toBe(true)
})
})
// ─── checkoutSchema ──────────────────────────────────────────────────────────
describe('checkoutSchema', () => {
const valid = { items: [{ productId: 'prod-1', quantity: 1 }] }
it('accepts valid checkout', () => {
expect(checkoutSchema.safeParse(valid).success).toBe(true)
})
it('rejects empty items array', () => {
const r = checkoutSchema.safeParse({ items: [] })
expect(r.success).toBe(false)
expect(r.error?.errors[0].message).toBe('Cart is empty')
})
it('accepts multiple items', () => {
const r = checkoutSchema.safeParse({
items: [
{ productId: 'prod-1', quantity: 2 },
{ productId: 'prod-2', quantity: 1 },
],
})
expect(r.success).toBe(true)
})
})
// ─── settingSchema ───────────────────────────────────────────────────────────
describe('settingSchema', () => {
it('accepts valid setting', () => {
expect(settingSchema.safeParse({ key: 'site_name', value: 'My Shop' }).success).toBe(true)
})
it('rejects empty key', () => {
expect(settingSchema.safeParse({ key: '', value: 'anything' }).success).toBe(false)
})
it('accepts any value type', () => {
for (const value of ['string', 42, true, null, { nested: true }]) {
expect(settingSchema.safeParse({ key: 'k', value }).success).toBe(true)
}
})
})
-36
View File
@@ -1,36 +0,0 @@
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
test: {
environment: 'happy-dom',
globals: true,
setupFiles: [path.resolve(__dirname, 'setup.ts')],
include: [
'../test/unit/**/*.{test,spec}.{ts,tsx}',
'../test/integration/**/*.{test,spec}.{ts,tsx}',
'../test/components/**/*.{test,spec}.{ts,tsx}',
],
exclude: ['**/node_modules/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.ts', 'src/**/*.tsx'],
exclude: [
'src/app/layout.tsx',
'src/lib/prisma.ts',
'src/**/*.d.ts',
],
thresholds: { lines: 70, functions: 70 },
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, '../app/src'),
},
},
esbuild: {
jsx: 'automatic',
jsxImportSource: 'react',
},
})