Compare commits
32 Commits
b62c02adc1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b49506e39 | |||
| 126a74cddb | |||
| 982c268acc | |||
| c966797073 | |||
| 8bfd7afdcb | |||
| 2526cf9543 | |||
| c7d2713c23 | |||
| b9ebd250e7 | |||
| db6b727902 | |||
| 6a5d5a6119 | |||
| 5428eeccc1 | |||
| b93f5d5bdf | |||
| ed7faa3be5 | |||
| 93cfe1ad5e | |||
| ea5fca6561 | |||
| 9797519e5c | |||
| 43a3efc94f | |||
| e18bc8fbda | |||
| 5654964d09 | |||
| 8cf038443f | |||
| d4b3398de5 | |||
| f4eedaffe2 | |||
| 45a50dc906 | |||
| fcfa0707a1 | |||
| 0395a78008 | |||
| 2a6c3a1222 | |||
| d958a4b9a5 | |||
| 4a7cd9fbd4 | |||
| 7afb609386 | |||
| 46d1596dce | |||
| 3b800463f5 | |||
| 2c6c847d76 |
+3
-2
@@ -1,8 +1,8 @@
|
||||
APP_URL=http://localhost
|
||||
DATABASE_URL=postgresql://ecommerce:ecommerce_password@db:5432/ecommerce
|
||||
AUTH_SECRET=dev-secret-change-in-production-32chars
|
||||
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
|
||||
INITIAL_ADMIN_EMAIL=admin@example.com
|
||||
INITIAL_ADMIN_PASSWORD=Admin1234!test
|
||||
INITIAL_ADMIN_PASSWORD=<change-this-use-openssl-rand-base64-32>
|
||||
STRIPE_SECRET_KEY=sk_test_placeholder
|
||||
STRIPE_WEBHOOK_SECRET=whsec_placeholder
|
||||
SMTP_HOST=mailpit
|
||||
@@ -10,3 +10,4 @@ SMTP_PORT=1025
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=noreply@localhost
|
||||
POSTGRES_PASSWORD=ecommerce_password
|
||||
|
||||
+12
-7
@@ -6,11 +6,15 @@
|
||||
# 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/
|
||||
|
||||
@@ -24,14 +28,15 @@ Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Docker volumes (dati locali)
|
||||
pgdata/
|
||||
caddy_data/
|
||||
caddy_config/
|
||||
# Dati locali (bind mount Docker) — ignora contenuto, traccia solo struttura
|
||||
data/db/
|
||||
data/caddy/
|
||||
data/uploads/*
|
||||
!data/uploads/.gitkeep
|
||||
|
||||
# Backup
|
||||
backups/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Uploads / media locali
|
||||
app/public/uploads/
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# 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.
|
||||
@@ -1,4 +1,9 @@
|
||||
localhost {
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
X-Frame-Options "DENY"
|
||||
X-Content-Type-Options "nosniff"
|
||||
}
|
||||
handle /uploads/* {
|
||||
root * /srv
|
||||
file_server
|
||||
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
|
||||
@@ -26,14 +27,28 @@ cd ecommerce-platform
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Il file `.env` di default è già configurato per localhost. **Non serve modificare nulla** per i primi test.
|
||||
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.
|
||||
|
||||
### 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 **5–10 minuti**: Docker scarica le immagini, installa le dipendenze npm, compila Next.js, esegue le migrazioni e crea l'utente admin.
|
||||
|
||||
Segui il progresso con:
|
||||
@@ -76,33 +91,57 @@ 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. Cosa funziona in locale senza configurazione extra
|
||||
### 7. Setup webhook Stripe (una tantum per macchina)
|
||||
|
||||
| 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) |
|
||||
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.
|
||||
|
||||
**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)):
|
||||
**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`:
|
||||
|
||||
```env
|
||||
STRIPE_SECRET_KEY=sk_test_la_tua_chiave
|
||||
STRIPE_WEBHOOK_SECRET=whsec_abc123...
|
||||
```
|
||||
|
||||
Poi riavvia l'app:
|
||||
Poi ricrea il container app per applicare la variabile (`restart` non ricarica il `.env`):
|
||||
|
||||
```bash
|
||||
docker compose restart app
|
||||
docker compose up -d --force-recreate app
|
||||
```
|
||||
|
||||
### 8. Ferma la piattaforma
|
||||
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:**
|
||||
|
||||
```bash
|
||||
docker compose down # ferma i container, i dati restano
|
||||
docker compose down -v # ferma e cancella anche il database
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
@@ -133,13 +172,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_...` (gratuita) | `sk_live_...` |
|
||||
| `STRIPE_WEBHOOK_SECRET` | opzionale | obbligatorio |
|
||||
| `STRIPE_SECRET_KEY` | `sk_test_...` | `sk_live_...` |
|
||||
| `STRIPE_WEBHOOK_SECRET` | dal container stripe-cli | dal dashboard Stripe |
|
||||
| `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`.
|
||||
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`).
|
||||
|
||||
### 4 — Backup
|
||||
|
||||
@@ -221,8 +260,6 @@ tuodominio.com {
|
||||
}
|
||||
```
|
||||
|
||||
Caddy ottiene e rinnova automaticamente il certificato HTTPS tramite Let's Encrypt. Non serve nessuna configurazione SSL manuale.
|
||||
|
||||
### 5. Avvia
|
||||
|
||||
```bash
|
||||
@@ -251,10 +288,10 @@ Eventi da ascoltare:
|
||||
- payment_intent.payment_failed
|
||||
```
|
||||
|
||||
Copia il **Signing secret** (`whsec_...`) nel `.env` come `STRIPE_WEBHOOK_SECRET`, poi:
|
||||
Copia il **Signing secret** (`whsec_...`) nel `.env` come `STRIPE_WEBHOOK_SECRET`, poi ricrea il container per applicarlo:
|
||||
|
||||
```bash
|
||||
docker compose restart app
|
||||
docker compose up -d --force-recreate app
|
||||
```
|
||||
|
||||
### 7. Primo accesso e configurazione negozio
|
||||
@@ -271,6 +308,43 @@ docker compose restart 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
|
||||
@@ -319,6 +393,20 @@ 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
|
||||
@@ -338,7 +426,7 @@ docker compose exec db psql -U ecommerce ecommerce -c \
|
||||
|
||||
```
|
||||
ecommerce-platform/
|
||||
├── docker-compose.yml Orchestrazione: db, app, caddy, mailpit
|
||||
├── docker-compose.yml Orchestrazione: db, app, caddy, mailpit, stripe-cli (profilo dev)
|
||||
├── Caddyfile Reverse proxy — modifica qui il dominio
|
||||
├── .env Variabili d'ambiente (non committare)
|
||||
├── .env.example Template da copiare
|
||||
@@ -370,7 +458,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 | opzionale | obbligatorio |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Segreto webhook Stripe | dal container stripe-cli | dal dashboard Stripe |
|
||||
| `SMTP_HOST` | Server SMTP | `mailpit` | provider reale |
|
||||
| `SMTP_PORT` | Porta SMTP | `1025` | `587` |
|
||||
| `SMTP_USER` | Utente SMTP | vuoto | obbligatorio |
|
||||
|
||||
Generated
+2453
-1
File diff suppressed because it is too large
Load Diff
+11
-2
@@ -6,7 +6,10 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"postinstall": "prisma generate"
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.5",
|
||||
@@ -26,6 +29,12 @@
|
||||
"typescript": "^5",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"postcss": "^8",
|
||||
"autoprefixer": "^10.0.1"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
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");
|
||||
@@ -255,3 +255,11 @@ 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])
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -35,13 +36,14 @@ 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(() => {
|
||||
const stored = localStorage.getItem('user')
|
||||
if (!stored) {
|
||||
if (userLoading) return
|
||||
if (!user) {
|
||||
router.push('/login?redirect=/account/orders')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,35 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Navbar } from '@/components/storefront/Navbar'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
name?: string
|
||||
role: string
|
||||
}
|
||||
import { useUser } from '@/context/UserContext'
|
||||
|
||||
export default function AccountPage() {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const { user, isLoading } = useUser()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('user')
|
||||
if (!stored) {
|
||||
if (!isLoading && !user) {
|
||||
router.push('/login?redirect=/account')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setUser(JSON.parse(stored))
|
||||
} catch {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [router])
|
||||
}, [isLoading, user, router])
|
||||
|
||||
if (!user) return null
|
||||
if (isLoading || !user) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -5,6 +5,7 @@ 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('')
|
||||
@@ -14,6 +15,7 @@ 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()
|
||||
@@ -41,12 +43,9 @@ export default function ChangePasswordPage() {
|
||||
}
|
||||
|
||||
setSuccess(true)
|
||||
// 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))
|
||||
// Update context to remove mustChangePassword flag
|
||||
if (user) {
|
||||
setUser({ ...user, mustChangePassword: false })
|
||||
}
|
||||
|
||||
setTimeout(() => router.push('/admin'), 1500)
|
||||
|
||||
@@ -112,23 +112,15 @@ 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">
|
||||
<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>
|
||||
<Input
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
if (!editId) setSlug(generateSlug(e.target.value))
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Schema (JSON — define attribute fields)
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function AdminProductEditPage() {
|
||||
title: p.title,
|
||||
slug: p.slug,
|
||||
description: p.description,
|
||||
basePrice: String(p.basePrice),
|
||||
basePrice: (p.basePrice / 100).toFixed(2),
|
||||
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: parseInt(form.basePrice),
|
||||
basePrice: Math.round(parseFloat(form.basePrice) * 100),
|
||||
currency: form.currency,
|
||||
status: form.status,
|
||||
attributes,
|
||||
@@ -262,11 +262,12 @@ export default function AdminProductEditPage() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Base Price (cents)"
|
||||
label={`Base Price (${form.currency})`}
|
||||
type="number"
|
||||
value={form.basePrice}
|
||||
onChange={(e) => setForm((f) => ({ ...f, basePrice: e.target.value }))}
|
||||
min="0"
|
||||
step="0.01"
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
|
||||
@@ -52,12 +52,19 @@ export default function AdminSettingsPage() {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
setMessage('')
|
||||
for (const key of ALL_KEYS) {
|
||||
await fetch('/api/admin/settings', {
|
||||
const res = 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,6 +72,14 @@ 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,6 +2,14 @@ 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
|
||||
@@ -50,6 +58,17 @@ 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' },
|
||||
|
||||
@@ -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 = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
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 status = searchParams.get('status')
|
||||
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
@@ -73,6 +73,14 @@ 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 } from '@/lib/storage'
|
||||
import { saveImage, deleteImageFile, validateImageMagicBytes } from '@/lib/storage'
|
||||
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
|
||||
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
@@ -26,6 +26,10 @@ 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 })
|
||||
}
|
||||
|
||||
@@ -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 = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
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 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', details: parsed.error.errors },
|
||||
{ error: parsed.error.errors[0]?.message ?? 'Invalid input' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
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 status = searchParams.get('status')
|
||||
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
@@ -2,6 +2,17 @@ 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
|
||||
@@ -40,6 +51,10 @@ 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,8 +3,9 @@ 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/svg+xml', 'image/jpeg', 'image/webp']
|
||||
const ALLOWED_TYPES = ['image/x-icon', 'image/png', 'image/jpeg', 'image/webp']
|
||||
const MAX_SIZE = 1 * 1024 * 1024 // 1MB
|
||||
const FAVICON_URL = '/uploads/branding/favicon.png'
|
||||
|
||||
@@ -23,7 +24,11 @@ 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, SVG o WebP)' }, { status: 400 })
|
||||
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 })
|
||||
}
|
||||
if (file.size > MAX_SIZE) {
|
||||
return NextResponse.json({ error: 'File troppo grande (max 1MB)' }, { status: 400 })
|
||||
|
||||
@@ -2,6 +2,7 @@ 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()
|
||||
@@ -9,6 +10,19 @@ 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()
|
||||
@@ -28,6 +42,7 @@ 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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -6,33 +6,16 @@ import {
|
||||
setSessionCookie,
|
||||
} from '@/lib/auth'
|
||||
import { loginSchema } from '@/lib/validate'
|
||||
|
||||
// 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
|
||||
}
|
||||
import { checkRateLimit, recordAttempt } from '@/lib/rate-limit'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ip = request.headers.get('x-forwarded-for') || 'unknown'
|
||||
const ip =
|
||||
request.headers.get('x-forwarded-for') ??
|
||||
request.headers.get('x-real-ip') ??
|
||||
'unknown'
|
||||
|
||||
if (!checkRateLimit(ip)) {
|
||||
const { limited } = await checkRateLimit(ip)
|
||||
if (limited) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many login attempts. Please try again later.' },
|
||||
{ status: 429 }
|
||||
@@ -58,11 +41,13 @@ 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 })
|
||||
}
|
||||
|
||||
@@ -75,7 +60,6 @@ export async function POST(request: NextRequest) {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
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 category = searchParams.get('category')
|
||||
const search = searchParams.get('search')
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
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 })
|
||||
}
|
||||
@@ -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)
|
||||
console.error('Webhook signature verification failed:', err instanceof Error ? err.message : String(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)
|
||||
console.error('Failed to send confirmation email:', emailErr instanceof Error ? emailErr.message : String(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)
|
||||
console.error('Error processing webhook:', err instanceof Error ? err.message : String(err))
|
||||
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 })
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -18,6 +19,7 @@ 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') || '[]')
|
||||
@@ -51,10 +53,9 @@ export default function CartPage() {
|
||||
|
||||
const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||
|
||||
async function handleCheckout() {
|
||||
const user = localStorage.getItem('user')
|
||||
function handleCheckout() {
|
||||
if (!user) {
|
||||
router.push('/login?redirect=/cart')
|
||||
router.push('/login?redirect=/checkout')
|
||||
return
|
||||
}
|
||||
router.push('/checkout')
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -19,20 +20,23 @@ 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)
|
||||
|
||||
const user = localStorage.getItem('user')
|
||||
if (!user) {
|
||||
router.push('/login?redirect=/checkout')
|
||||
}
|
||||
}, [router])
|
||||
}, [router, user, userLoading])
|
||||
|
||||
const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 {
|
||||
@@ -23,7 +24,9 @@ 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">
|
||||
{children}
|
||||
<UserProvider>
|
||||
{children}
|
||||
</UserProvider>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useState } from 'react'
|
||||
import { Suspense, useState, useEffect } 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 (
|
||||
@@ -20,9 +21,18 @@ 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()
|
||||
@@ -43,11 +53,11 @@ function LoginForm() {
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem('user', JSON.stringify(data.user))
|
||||
const refreshedUser = await refreshUser()
|
||||
|
||||
if (data.user.mustChangePassword) {
|
||||
if (refreshedUser?.mustChangePassword) {
|
||||
router.push('/admin/change-password')
|
||||
} else if (data.user.role === 'ADMIN' || data.user.role === 'OWNER') {
|
||||
} else if (refreshedUser?.role === 'ADMIN' || refreshedUser?.role === 'OWNER') {
|
||||
router.push('/admin')
|
||||
} else {
|
||||
router.push(redirect)
|
||||
@@ -64,7 +74,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">ShopX</Link>
|
||||
<Link href="/" className="text-2xl font-bold text-gray-900">{siteName}</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>
|
||||
|
||||
+13
-4
@@ -14,8 +14,17 @@ 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 = await getFeaturedProducts()
|
||||
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'
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -24,9 +33,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 ShopX</h1>
|
||||
<h1 className="text-4xl font-bold mb-4">Welcome to {siteName}</h1>
|
||||
<p className="text-xl text-blue-100 mb-8">
|
||||
Discover our curated collection of products
|
||||
{siteDescription}
|
||||
</p>
|
||||
<Link
|
||||
href="/products"
|
||||
@@ -90,7 +99,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>© {new Date().getFullYear()} ShopX. All rights reserved.</p>
|
||||
<p>© {new Date().getFullYear()} {siteName}. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
item.productId === product.id && item.variantId === (selectedVariant || undefined)
|
||||
)
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
@@ -249,7 +249,12 @@ export default function ProductDetailPage() {
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
addToCart()
|
||||
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()
|
||||
router.push('/cart')
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } 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('')
|
||||
@@ -13,7 +14,16 @@ 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()
|
||||
@@ -34,7 +44,7 @@ export default function RegisterPage() {
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem('user', JSON.stringify(data.user))
|
||||
await refreshUser()
|
||||
router.push('/')
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.')
|
||||
@@ -48,9 +58,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">ShopX</Link>
|
||||
<Link href="/" className="text-2xl font-bold text-gray-900">{siteName}</Link>
|
||||
<h1 className="text-xl font-semibold mt-4">Create Account</h1>
|
||||
<p className="text-gray-600 text-sm mt-1">Join ShopX today</p>
|
||||
<p className="text-gray-600 text-sm mt-1">Join {siteName} today</p>
|
||||
</div>
|
||||
|
||||
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
|
||||
|
||||
@@ -3,31 +3,33 @@
|
||||
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 [user, setUser] = useState<{ name?: string; email: string; role: string } | null>(null)
|
||||
const [siteName, setSiteName] = useState('ShopX')
|
||||
const { user, refreshUser } = useUser()
|
||||
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)
|
||||
}, [])
|
||||
|
||||
const userData = localStorage.getItem('user')
|
||||
if (userData) {
|
||||
try {
|
||||
setUser(JSON.parse(userData))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
fetch('/api/settings')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const name = data.settings?.site_name
|
||||
if (name) setSiteName(name as string)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
localStorage.removeItem('user')
|
||||
setUser(null)
|
||||
await refreshUser()
|
||||
router.push('/')
|
||||
router.refresh()
|
||||
}
|
||||
@@ -37,7 +39,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">
|
||||
ShopX
|
||||
{siteName}
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
'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
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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 } } })
|
||||
}
|
||||
@@ -16,6 +16,23 @@ 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)
|
||||
|
||||
@@ -4,6 +4,14 @@ 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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
+18
-14
@@ -3,11 +3,11 @@ services:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ecommerce
|
||||
POSTGRES_PASSWORD: ecommerce_password
|
||||
POSTGRES_DB: ecommerce
|
||||
POSTGRES_USER: ${POSTGRES_USER:-ecommerce}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ecommerce_password}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-ecommerce}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./data/db:/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://ecommerce:ecommerce_password@db:5432/ecommerce
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-ecommerce}:${POSTGRES_PASSWORD:-ecommerce_password}@db:5432/${POSTGRES_DB:-ecommerce}
|
||||
expose:
|
||||
- "3000"
|
||||
volumes:
|
||||
- uploads:/app/public/uploads
|
||||
- ./data/uploads:/app/public/uploads
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
@@ -44,14 +44,18 @@ services:
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
- uploads:/srv/uploads
|
||||
- ./data/caddy/data:/data
|
||||
- ./data/caddy/config:/config
|
||||
- ./data/uploads:/srv/uploads
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
uploads:
|
||||
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
|
||||
|
||||
|
||||
+197
-30
@@ -169,13 +169,14 @@ Il form è diviso in 4 sezioni.
|
||||
#### Sezione 2 — Pricing & Inventory
|
||||
|
||||
**Base Price** *(obbligatorio)*
|
||||
- Campo: `number` (valore in centesimi)
|
||||
- **Attenzione:** il prezzo va inserito in centesimi, non in euro/dollari
|
||||
- Campo: `number` (valore in euro/dollari, con decimali)
|
||||
- Inserisci il prezzo nella valuta selezionata, con il punto come separatore decimale
|
||||
- Esempi:
|
||||
- `1999` → €19,99
|
||||
- `4990` → €49,90
|
||||
- `10000` → €100,00
|
||||
- `19.99` → €19,99
|
||||
- `49.90` → €49,90
|
||||
- `100` → €100,00
|
||||
- Valore minimo: `0`
|
||||
- Il sistema converte automaticamente in centesimi internamente (compatibilità con Stripe)
|
||||
|
||||
---
|
||||
|
||||
@@ -344,12 +345,35 @@ 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:
|
||||
- **Name:** nome del tipo (es. `Abbigliamento`)
|
||||
- **Schema (JSON):** definizione degli attributi in formato JSON Schema
|
||||
3. Compila i campi (vedi sotto)
|
||||
4. Clicca **"Save"**
|
||||
|
||||
Esempio di schema:
|
||||
### Campi — Product Type
|
||||
|
||||
**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",
|
||||
@@ -361,7 +385,19 @@ Esempio di schema:
|
||||
}
|
||||
```
|
||||
|
||||
4. Clicca **"Save"**
|
||||
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.
|
||||
|
||||
### Modifica e gestione
|
||||
|
||||
@@ -395,12 +431,35 @@ Abbigliamento (padre)
|
||||
|
||||
1. Vai su http://localhost/admin/categories
|
||||
2. Clicca **"New Category"**
|
||||
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
|
||||
3. Compila i campi (vedi sotto)
|
||||
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.
|
||||
@@ -546,14 +605,48 @@ Gestisci gli account che possono accedere al pannello admin.
|
||||
|
||||
1. Vai su http://localhost/admin/admin-users
|
||||
2. Clicca **"New Admin User"**
|
||||
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`
|
||||
3. Compila i campi (vedi sotto)
|
||||
4. Clicca **"Create"**
|
||||
|
||||
Il nuovo admin riceverà le credenziali e dovrà cambiare la password al primo accesso.
|
||||
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!`
|
||||
|
||||
### Eliminare un admin
|
||||
|
||||
@@ -569,16 +662,6 @@ 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
|
||||
@@ -587,6 +670,90 @@ 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
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# 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.
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/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
@@ -0,0 +1,397 @@
|
||||
# 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
|
||||
@@ -0,0 +1,81 @@
|
||||
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 }))
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ProductCard } from '@/components/storefront/ProductCard'
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ href, children, className }: any) => (
|
||||
<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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders children text', () => {
|
||||
render(<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')
|
||||
})
|
||||
})
|
||||
Vendored
+53
@@ -0,0 +1,53 @@
|
||||
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',
|
||||
},
|
||||
},
|
||||
}
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
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(),
|
||||
}))
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../app/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["../app/src/*"]
|
||||
},
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": [
|
||||
"./**/*.ts",
|
||||
"./**/*.tsx"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
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' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
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/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
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() }) })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
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'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,329 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
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',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user