Compare commits
33 Commits
b62c02adc1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1247608441 | |||
| aac4ee6e43 | |||
| 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 |
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"stripe@claude-plugins-official": true,
|
||||||
|
"superpowers@claude-plugins-official": true,
|
||||||
|
"security-guidance@claude-plugins-official": true,
|
||||||
|
"ui-ux-pro-max@ui-ux-pro-max-skill": true,
|
||||||
|
"claude-md-management@claude-plugins-official": true,
|
||||||
|
"figma@claude-plugins-official": true,
|
||||||
|
"frontend-design@claude-plugins-official":true
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-2
@@ -1,8 +1,8 @@
|
|||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
DATABASE_URL=postgresql://ecommerce:ecommerce_password@db:5432/ecommerce
|
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_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_SECRET_KEY=sk_test_placeholder
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_placeholder
|
STRIPE_WEBHOOK_SECRET=whsec_placeholder
|
||||||
SMTP_HOST=mailpit
|
SMTP_HOST=mailpit
|
||||||
@@ -10,3 +10,4 @@ SMTP_PORT=1025
|
|||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM=noreply@localhost
|
SMTP_FROM=noreply@localhost
|
||||||
|
POSTGRES_PASSWORD=ecommerce_password
|
||||||
|
|||||||
+12
-7
@@ -6,11 +6,15 @@
|
|||||||
# Node
|
# Node
|
||||||
app/node_modules/
|
app/node_modules/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
test/node_modules
|
||||||
|
|
||||||
# Next.js build output
|
# Next.js build output
|
||||||
app/.next/
|
app/.next/
|
||||||
app/out/
|
app/out/
|
||||||
|
|
||||||
|
# Test coverage report
|
||||||
|
app/coverage/
|
||||||
|
|
||||||
# Prisma generated client (rebuilt on npm install)
|
# Prisma generated client (rebuilt on npm install)
|
||||||
app/node_modules/.prisma/
|
app/node_modules/.prisma/
|
||||||
|
|
||||||
@@ -24,14 +28,15 @@ Thumbs.db
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
# Docker volumes (dati locali)
|
# Dati locali (bind mount Docker) — ignora contenuto, traccia solo struttura
|
||||||
pgdata/
|
data/db/
|
||||||
caddy_data/
|
data/caddy/
|
||||||
caddy_config/
|
data/uploads/*
|
||||||
|
!data/uploads/.gitkeep
|
||||||
|
|
||||||
|
# Backup
|
||||||
|
backups/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.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 {
|
localhost {
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Frame-Options "DENY"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
}
|
||||||
handle /uploads/* {
|
handle /uploads/* {
|
||||||
root * /srv
|
root * /srv
|
||||||
file_server
|
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
|
- [Docker Desktop](https://docs.docker.com/get-docker/) installato e avviato
|
||||||
- Porta **80** libera (nessun altro web server in esecuzione)
|
- Porta **80** libera (nessun altro web server in esecuzione)
|
||||||
|
- Account [Stripe](https://stripe.com) gratuito (necessario per i pagamenti)
|
||||||
|
|
||||||
### 1. Clona il repository
|
### 1. Clona il repository
|
||||||
|
|
||||||
@@ -26,14 +27,28 @@ cd ecommerce-platform
|
|||||||
cp .env.example .env
|
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
|
### 3. Avvia la piattaforma
|
||||||
|
|
||||||
|
**Senza pagamenti** (admin, catalogo, email):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
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.
|
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:
|
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**
|
3. **Products** → crea un prodotto, assegna tipo e categoria, impostalo su **Published**
|
||||||
4. Apri http://localhost → il prodotto appare in homepage
|
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 |
|
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.
|
||||||
|---|---|
|
|
||||||
| 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) |
|
|
||||||
|
|
||||||
**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
|
```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
|
```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
|
```bash
|
||||||
docker compose down # ferma i container, i dati restano
|
docker compose logs stripe-cli --tail=20
|
||||||
docker compose down -v # ferma e cancella anche il database
|
# 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` |
|
| `APP_URL` | `http://localhost` | `https://tuodominio.com` |
|
||||||
| `AUTH_SECRET` | qualsiasi stringa | `openssl rand -hex 32` |
|
| `AUTH_SECRET` | qualsiasi stringa | `openssl rand -hex 32` |
|
||||||
| `STRIPE_SECRET_KEY` | `sk_test_...` (gratuita) | `sk_live_...` |
|
| `STRIPE_SECRET_KEY` | `sk_test_...` | `sk_live_...` |
|
||||||
| `STRIPE_WEBHOOK_SECRET` | opzionale | obbligatorio |
|
| `STRIPE_WEBHOOK_SECRET` | dal container stripe-cli | dal dashboard Stripe |
|
||||||
| `SMTP_*` | Mailpit locale (porta 8025) | provider reale (Resend, Mailgun, SendGrid…) |
|
| `SMTP_*` | Mailpit locale (porta 8025) | provider reale (Resend, Mailgun, SendGrid…) |
|
||||||
|
|
||||||
### 3 — Server
|
### 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
|
### 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
|
### 5. Avvia
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -251,10 +288,10 @@ Eventi da ascoltare:
|
|||||||
- payment_intent.payment_failed
|
- 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
|
```bash
|
||||||
docker compose restart app
|
docker compose up -d --force-recreate app
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7. Primo accesso e configurazione negozio
|
### 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
|
## Aggiornamenti
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -319,6 +393,20 @@ docker compose logs -f app
|
|||||||
**Errore "port 80 already in use"**
|
**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`.
|
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**
|
**Dimentico la password admin**
|
||||||
Resetta direttamente nel database:
|
Resetta direttamente nel database:
|
||||||
```bash
|
```bash
|
||||||
@@ -338,7 +426,7 @@ docker compose exec db psql -U ecommerce ecommerce -c \
|
|||||||
|
|
||||||
```
|
```
|
||||||
ecommerce-platform/
|
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
|
├── Caddyfile Reverse proxy — modifica qui il dominio
|
||||||
├── .env Variabili d'ambiente (non committare)
|
├── .env Variabili d'ambiente (non committare)
|
||||||
├── .env.example Template da copiare
|
├── .env.example Template da copiare
|
||||||
@@ -370,7 +458,7 @@ ecommerce-platform/
|
|||||||
| `INITIAL_ADMIN_EMAIL` | Email primo admin | qualsiasi | la tua email |
|
| `INITIAL_ADMIN_EMAIL` | Email primo admin | qualsiasi | la tua email |
|
||||||
| `INITIAL_ADMIN_PASSWORD` | Password primo admin | qualsiasi | sicura |
|
| `INITIAL_ADMIN_PASSWORD` | Password primo admin | qualsiasi | sicura |
|
||||||
| `STRIPE_SECRET_KEY` | Chiave Stripe | `sk_test_...` | `sk_live_...` |
|
| `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_HOST` | Server SMTP | `mailpit` | provider reale |
|
||||||
| `SMTP_PORT` | Porta SMTP | `1025` | `587` |
|
| `SMTP_PORT` | Porta SMTP | `1025` | `587` |
|
||||||
| `SMTP_USER` | Utente SMTP | vuoto | obbligatorio |
|
| `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",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"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": {
|
"dependencies": {
|
||||||
"next": "14.2.5",
|
"next": "14.2.5",
|
||||||
@@ -26,6 +29,12 @@
|
|||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"postcss": "^8",
|
"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?
|
metadata Json?
|
||||||
createdAt DateTime @default(now())
|
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 { Navbar } from '@/components/storefront/Navbar'
|
||||||
import { Badge } from '@/components/ui/Badge'
|
import { Badge } from '@/components/ui/Badge'
|
||||||
import { Alert } from '@/components/ui/Alert'
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { useUser } from '@/context/UserContext'
|
||||||
|
|
||||||
interface Order {
|
interface Order {
|
||||||
id: string
|
id: string
|
||||||
@@ -35,13 +36,14 @@ export default function OrdersPage() {
|
|||||||
function OrdersContent() {
|
function OrdersContent() {
|
||||||
const [orders, setOrders] = useState<Order[]>([])
|
const [orders, setOrders] = useState<Order[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const { user, isLoading: userLoading } = useUser()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const success = searchParams.get('success')
|
const success = searchParams.get('success')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = localStorage.getItem('user')
|
if (userLoading) return
|
||||||
if (!stored) {
|
if (!user) {
|
||||||
router.push('/login?redirect=/account/orders')
|
router.push('/login?redirect=/account/orders')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,22 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Navbar } from '@/components/storefront/Navbar'
|
import { Navbar } from '@/components/storefront/Navbar'
|
||||||
|
import { useUser } from '@/context/UserContext'
|
||||||
interface User {
|
|
||||||
id: string
|
|
||||||
email: string
|
|
||||||
name?: string
|
|
||||||
role: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AccountPage() {
|
export default function AccountPage() {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const { user, isLoading } = useUser()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = localStorage.getItem('user')
|
if (!isLoading && !user) {
|
||||||
if (!stored) {
|
|
||||||
router.push('/login?redirect=/account')
|
router.push('/login?redirect=/account')
|
||||||
return
|
|
||||||
}
|
}
|
||||||
try {
|
}, [isLoading, user, router])
|
||||||
setUser(JSON.parse(stored))
|
|
||||||
} catch {
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
if (!user) return null
|
if (isLoading || !user) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
|
|||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Alert } from '@/components/ui/Alert'
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { useUser } from '@/context/UserContext'
|
||||||
|
|
||||||
export default function ChangePasswordPage() {
|
export default function ChangePasswordPage() {
|
||||||
const [currentPassword, setCurrentPassword] = useState('')
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
@@ -14,6 +15,7 @@ export default function ChangePasswordPage() {
|
|||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { user, setUser } = useUser()
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -41,12 +43,9 @@ export default function ChangePasswordPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
// Update localStorage to remove mustChangePassword flag
|
// Update context to remove mustChangePassword flag
|
||||||
const userStr = localStorage.getItem('user')
|
if (user) {
|
||||||
if (userStr) {
|
setUser({ ...user, mustChangePassword: false })
|
||||||
const user = JSON.parse(userStr)
|
|
||||||
user.mustChangePassword = false
|
|
||||||
localStorage.setItem('user', JSON.stringify(user))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => router.push('/admin'), 1500)
|
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">
|
<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>
|
<h2 className="font-semibold mb-4">{editId ? 'Edit Type' : 'New Product Type'}</h2>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<Input
|
||||||
<Input
|
label="Name"
|
||||||
label="Name"
|
value={name}
|
||||||
value={name}
|
onChange={(e) => {
|
||||||
onChange={(e) => {
|
setName(e.target.value)
|
||||||
setName(e.target.value)
|
if (!editId) setSlug(generateSlug(e.target.value))
|
||||||
if (!editId) setSlug(generateSlug(e.target.value))
|
}}
|
||||||
}}
|
required
|
||||||
required
|
/>
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Slug"
|
|
||||||
value={slug}
|
|
||||||
onChange={(e) => setSlug(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Schema (JSON — define attribute fields)
|
Schema (JSON — define attribute fields)
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export default function AdminProductEditPage() {
|
|||||||
title: p.title,
|
title: p.title,
|
||||||
slug: p.slug,
|
slug: p.slug,
|
||||||
description: p.description,
|
description: p.description,
|
||||||
basePrice: String(p.basePrice),
|
basePrice: (p.basePrice / 100).toFixed(2),
|
||||||
currency: p.currency,
|
currency: p.currency,
|
||||||
status: p.status,
|
status: p.status,
|
||||||
attributes: JSON.stringify(p.attributes, null, 2),
|
attributes: JSON.stringify(p.attributes, null, 2),
|
||||||
@@ -171,7 +171,7 @@ export default function AdminProductEditPage() {
|
|||||||
title: form.title,
|
title: form.title,
|
||||||
slug: form.slug,
|
slug: form.slug,
|
||||||
description: form.description,
|
description: form.description,
|
||||||
basePrice: parseInt(form.basePrice),
|
basePrice: Math.round(parseFloat(form.basePrice) * 100),
|
||||||
currency: form.currency,
|
currency: form.currency,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
attributes,
|
attributes,
|
||||||
@@ -262,11 +262,12 @@ export default function AdminProductEditPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Input
|
<Input
|
||||||
label="Base Price (cents)"
|
label={`Base Price (${form.currency})`}
|
||||||
type="number"
|
type="number"
|
||||||
value={form.basePrice}
|
value={form.basePrice}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, basePrice: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, basePrice: e.target.value }))}
|
||||||
min="0"
|
min="0"
|
||||||
|
step="0.01"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -52,12 +52,19 @@ export default function AdminSettingsPage() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
setMessage('')
|
||||||
for (const key of ALL_KEYS) {
|
for (const key of ALL_KEYS) {
|
||||||
await fetch('/api/admin/settings', {
|
const res = await fetch('/api/admin/settings', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key, value: values[key] }),
|
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)
|
setSaving(false)
|
||||||
setMessage('Impostazioni salvate!')
|
setMessage('Impostazioni salvate!')
|
||||||
|
|||||||
@@ -72,6 +72,14 @@ export async function DELETE(request: NextRequest) {
|
|||||||
const id = searchParams.get('id')
|
const id = searchParams.get('id')
|
||||||
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
|
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 } })
|
await prisma.category.delete({ where: { id } })
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
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() {
|
async function requireAdmin() {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
|
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 })
|
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({
|
const order = await prisma.order.update({
|
||||||
where: { id: params.id },
|
where: { id: params.id },
|
||||||
data: { status: status as 'PENDING' | 'PAID' | 'CANCELLED' | 'REFUNDED' | 'FULFILLED' },
|
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 })
|
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const page = parseInt(searchParams.get('page') || '1')
|
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
|
||||||
const limit = parseInt(searchParams.get('limit') || '20')
|
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
|
||||||
const status = searchParams.get('status')
|
const status = searchParams.get('status')
|
||||||
|
|
||||||
const skip = (page - 1) * limit
|
const skip = (page - 1) * limit
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ export async function DELETE(request: NextRequest) {
|
|||||||
const id = searchParams.get('id')
|
const id = searchParams.get('id')
|
||||||
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
|
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 } })
|
await prisma.productType.delete({ where: { id } })
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
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 ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
|
||||||
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
|
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)) {
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
return NextResponse.json({ error: 'Only JPEG, PNG and WebP images are allowed' }, { status: 400 })
|
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) {
|
if (file.size > MAX_SIZE) {
|
||||||
return NextResponse.json({ error: 'File too large (max 5MB)' }, { status: 400 })
|
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 })
|
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const page = parseInt(searchParams.get('page') || '1')
|
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
|
||||||
const limit = parseInt(searchParams.get('limit') || '20')
|
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
|
||||||
const search = searchParams.get('search')
|
const search = searchParams.get('search')
|
||||||
const status = searchParams.get('status')
|
const status = searchParams.get('status')
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const parsed = productSchema.safeParse(body)
|
const parsed = productSchema.safeParse(body)
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json(
|
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 }
|
{ status: 400 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export async function GET(request: NextRequest) {
|
|||||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const page = parseInt(searchParams.get('page') || '1')
|
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
|
||||||
const limit = parseInt(searchParams.get('limit') || '20')
|
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
|
||||||
const status = searchParams.get('status')
|
const status = searchParams.get('status')
|
||||||
|
|
||||||
const skip = (page - 1) * limit
|
const skip = (page - 1) * limit
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
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() {
|
async function requireAdmin() {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
|
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 (!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({
|
const setting = await prisma.siteSettings.upsert({
|
||||||
where: { key },
|
where: { key },
|
||||||
update: { value: value as object },
|
update: { value: value as object },
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { mkdir, writeFile } from 'fs/promises'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
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 MAX_SIZE = 1 * 1024 * 1024 // 1MB
|
||||||
const FAVICON_URL = '/uploads/branding/favicon.png'
|
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 (!file) return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||||
|
|
||||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
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) {
|
if (file.size > MAX_SIZE) {
|
||||||
return NextResponse.json({ error: 'File troppo grande (max 1MB)' }, { status: 400 })
|
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 { prisma } from '@/lib/prisma'
|
||||||
import { getCurrentUser, verifyPassword, hashPassword } from '@/lib/auth'
|
import { getCurrentUser, verifyPassword, hashPassword } from '@/lib/auth'
|
||||||
import { changePasswordSchema } from '@/lib/validate'
|
import { changePasswordSchema } from '@/lib/validate'
|
||||||
|
import { checkRateLimit, recordAttempt } from '@/lib/rate-limit'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
@@ -9,6 +10,19 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
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
|
let body: unknown
|
||||||
try {
|
try {
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
@@ -28,6 +42,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const valid = await verifyPassword(currentPassword, user.passwordHash)
|
const valid = await verifyPassword(currentPassword, user.passwordHash)
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
|
await recordAttempt(ip)
|
||||||
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 400 })
|
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,33 +6,16 @@ import {
|
|||||||
setSessionCookie,
|
setSessionCookie,
|
||||||
} from '@/lib/auth'
|
} from '@/lib/auth'
|
||||||
import { loginSchema } from '@/lib/validate'
|
import { loginSchema } from '@/lib/validate'
|
||||||
|
import { checkRateLimit, recordAttempt } from '@/lib/rate-limit'
|
||||||
// Simple in-memory rate limiter
|
|
||||||
const loginAttempts = new Map<string, { count: number; resetAt: number }>()
|
|
||||||
|
|
||||||
function checkRateLimit(ip: string): boolean {
|
|
||||||
const now = Date.now()
|
|
||||||
const windowMs = 15 * 60 * 1000 // 15 minutes
|
|
||||||
const maxAttempts = 10
|
|
||||||
|
|
||||||
const record = loginAttempts.get(ip)
|
|
||||||
if (!record || record.resetAt < now) {
|
|
||||||
loginAttempts.set(ip, { count: 1, resetAt: now + windowMs })
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (record.count >= maxAttempts) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
record.count++
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
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(
|
return NextResponse.json(
|
||||||
{ error: 'Too many login attempts. Please try again later.' },
|
{ error: 'Too many login attempts. Please try again later.' },
|
||||||
{ status: 429 }
|
{ status: 429 }
|
||||||
@@ -58,11 +41,13 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { email } })
|
const user = await prisma.user.findUnique({ where: { email } })
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
await recordAttempt(ip)
|
||||||
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
|
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await verifyPassword(password, user.passwordHash)
|
const valid = await verifyPassword(password, user.passwordHash)
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
|
await recordAttempt(ip)
|
||||||
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
|
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +60,6 @@ export async function POST(request: NextRequest) {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
role: user.role,
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const page = parseInt(searchParams.get('page') || '1')
|
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
|
||||||
const limit = parseInt(searchParams.get('limit') || '20')
|
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
|
||||||
const category = searchParams.get('category')
|
const category = searchParams.get('category')
|
||||||
const search = searchParams.get('search')
|
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 {
|
try {
|
||||||
event = constructWebhookEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
|
event = constructWebhookEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
|
||||||
} catch (err) {
|
} 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 })
|
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ export async function POST(request: NextRequest) {
|
|||||||
currency: order.currency,
|
currency: order.currency,
|
||||||
})
|
})
|
||||||
} catch (emailErr) {
|
} 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}`)
|
console.log(`Unhandled event type: ${event.type}`)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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 })
|
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Navbar } from '@/components/storefront/Navbar'
|
import { Navbar } from '@/components/storefront/Navbar'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { useUser } from '@/context/UserContext'
|
||||||
|
|
||||||
interface CartItem {
|
interface CartItem {
|
||||||
productId: string
|
productId: string
|
||||||
@@ -18,6 +19,7 @@ interface CartItem {
|
|||||||
export default function CartPage() {
|
export default function CartPage() {
|
||||||
const [cart, setCart] = useState<CartItem[]>([])
|
const [cart, setCart] = useState<CartItem[]>([])
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { user } = useUser()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = JSON.parse(localStorage.getItem('cart') || '[]')
|
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)
|
const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||||
|
|
||||||
async function handleCheckout() {
|
function handleCheckout() {
|
||||||
const user = localStorage.getItem('user')
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
router.push('/login?redirect=/cart')
|
router.push('/login?redirect=/checkout')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
router.push('/checkout')
|
router.push('/checkout')
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
|
|||||||
import { Navbar } from '@/components/storefront/Navbar'
|
import { Navbar } from '@/components/storefront/Navbar'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Alert } from '@/components/ui/Alert'
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { useUser } from '@/context/UserContext'
|
||||||
|
|
||||||
interface CartItem {
|
interface CartItem {
|
||||||
productId: string
|
productId: string
|
||||||
@@ -19,20 +20,23 @@ export default function CheckoutPage() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { user, isLoading: userLoading } = useUser()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (userLoading) return
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
router.push('/login?redirect=/checkout')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const stored = JSON.parse(localStorage.getItem('cart') || '[]')
|
const stored = JSON.parse(localStorage.getItem('cart') || '[]')
|
||||||
if (stored.length === 0) {
|
if (stored.length === 0) {
|
||||||
router.push('/cart')
|
router.push('/cart')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setCart(stored)
|
setCart(stored)
|
||||||
|
}, [router, user, userLoading])
|
||||||
const user = localStorage.getItem('user')
|
|
||||||
if (!user) {
|
|
||||||
router.push('/login?redirect=/checkout')
|
|
||||||
}
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
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 './globals.css'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { Footer } from '@/components/storefront/Footer'
|
import { Footer } from '@/components/storefront/Footer'
|
||||||
|
import { UserProvider } from '@/context/UserContext'
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
try {
|
try {
|
||||||
@@ -23,7 +24,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
return (
|
return (
|
||||||
<html lang="it">
|
<html lang="it">
|
||||||
<body className="bg-gray-50 text-gray-900 min-h-screen flex flex-col">
|
<body className="bg-gray-50 text-gray-900 min-h-screen flex flex-col">
|
||||||
{children}
|
<UserProvider>
|
||||||
|
{children}
|
||||||
|
</UserProvider>
|
||||||
<Footer />
|
<Footer />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Suspense, useState } from 'react'
|
import { Suspense, useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Alert } from '@/components/ui/Alert'
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { useUser } from '@/context/UserContext'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
return (
|
return (
|
||||||
@@ -20,9 +21,18 @@ function LoginForm() {
|
|||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [siteName, setSiteName] = useState('ShopX')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const redirect = searchParams.get('redirect') || '/'
|
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) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -43,11 +53,11 @@ function LoginForm() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('user', JSON.stringify(data.user))
|
const refreshedUser = await refreshUser()
|
||||||
|
|
||||||
if (data.user.mustChangePassword) {
|
if (refreshedUser?.mustChangePassword) {
|
||||||
router.push('/admin/change-password')
|
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')
|
router.push('/admin')
|
||||||
} else {
|
} else {
|
||||||
router.push(redirect)
|
router.push(redirect)
|
||||||
@@ -64,7 +74,7 @@ function LoginForm() {
|
|||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
|
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
|
||||||
<div className="text-center mb-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>
|
<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>
|
<p className="text-gray-600 text-sm mt-1">Sign in to your account</p>
|
||||||
</div>
|
</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() {
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -24,9 +33,9 @@ export default async function HomePage() {
|
|||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<section className="bg-blue-600 text-white py-20">
|
<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">
|
<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">
|
<p className="text-xl text-blue-100 mb-8">
|
||||||
Discover our curated collection of products
|
{siteDescription}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/products"
|
href="/products"
|
||||||
@@ -90,7 +99,7 @@ export default async function HomePage() {
|
|||||||
|
|
||||||
<footer className="bg-gray-800 text-gray-300 py-8">
|
<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">
|
<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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function ProductDetailPage() {
|
|||||||
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
|
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
|
||||||
const existingIndex = cart.findIndex(
|
const existingIndex = cart.findIndex(
|
||||||
(item: { productId: string; variantId?: string }) =>
|
(item: { productId: string; variantId?: string }) =>
|
||||||
item.productId === product.id && item.variantId === selectedVariant
|
item.productId === product.id && item.variantId === (selectedVariant || undefined)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
@@ -249,7 +249,12 @@ export default function ProductDetailPage() {
|
|||||||
size="lg"
|
size="lg"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
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')
|
router.push('/cart')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Alert } from '@/components/ui/Alert'
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { useUser } from '@/context/UserContext'
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
@@ -13,7 +14,16 @@ export default function RegisterPage() {
|
|||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [siteName, setSiteName] = useState('ShopX')
|
||||||
const router = useRouter()
|
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) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -34,7 +44,7 @@ export default function RegisterPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('user', JSON.stringify(data.user))
|
await refreshUser()
|
||||||
router.push('/')
|
router.push('/')
|
||||||
} catch {
|
} catch {
|
||||||
setError('Something went wrong. Please try again.')
|
setError('Something went wrong. Please try again.')
|
||||||
@@ -48,9 +58,9 @@ export default function RegisterPage() {
|
|||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
|
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
|
||||||
<div className="text-center mb-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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
|
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
|
||||||
|
|||||||
@@ -3,31 +3,33 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useUser } from '@/context/UserContext'
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const [cartCount, setCartCount] = useState(0)
|
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()
|
const router = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
|
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
|
||||||
const count = cart.reduce((sum: number, item: { quantity: number }) => sum + item.quantity, 0)
|
const count = cart.reduce((sum: number, item: { quantity: number }) => sum + item.quantity, 0)
|
||||||
setCartCount(count)
|
setCartCount(count)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const userData = localStorage.getItem('user')
|
useEffect(() => {
|
||||||
if (userData) {
|
fetch('/api/settings')
|
||||||
try {
|
.then((r) => r.json())
|
||||||
setUser(JSON.parse(userData))
|
.then((data) => {
|
||||||
} catch {
|
const name = data.settings?.site_name
|
||||||
// ignore
|
if (name) setSiteName(name as string)
|
||||||
}
|
})
|
||||||
}
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await fetch('/api/auth/logout', { method: 'POST' })
|
await fetch('/api/auth/logout', { method: 'POST' })
|
||||||
localStorage.removeItem('user')
|
await refreshUser()
|
||||||
setUser(null)
|
|
||||||
router.push('/')
|
router.push('/')
|
||||||
router.refresh()
|
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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex items-center justify-between h-16">
|
<div className="flex items-center justify-between h-16">
|
||||||
<Link href="/" className="text-xl font-bold text-gray-900">
|
<Link href="/" className="text-xl font-bold text-gray-900">
|
||||||
ShopX
|
{siteName}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="flex items-center gap-6 text-sm">
|
<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}`
|
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> {
|
export async function deleteImageFile(url: string): Promise<void> {
|
||||||
const filePath = path.join(process.cwd(), 'public', url)
|
const filePath = path.join(process.cwd(), 'public', url)
|
||||||
await unlink(filePath)
|
await unlink(filePath)
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import type { NextRequest } from 'next/server'
|
|||||||
export function middleware(request: NextRequest) {
|
export function middleware(request: NextRequest) {
|
||||||
const response = NextResponse.next()
|
const response = NextResponse.next()
|
||||||
response.headers.set('x-pathname', request.nextUrl.pathname)
|
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
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
+18
-14
@@ -3,11 +3,11 @@ services:
|
|||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ecommerce
|
POSTGRES_USER: ${POSTGRES_USER:-ecommerce}
|
||||||
POSTGRES_PASSWORD: ecommerce_password
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ecommerce_password}
|
||||||
POSTGRES_DB: ecommerce
|
POSTGRES_DB: ${POSTGRES_DB:-ecommerce}
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- ./data/db:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ecommerce"]
|
test: ["CMD-SHELL", "pg_isready -U ecommerce"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -24,11 +24,11 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
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:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
volumes:
|
volumes:
|
||||||
- uploads:/app/public/uploads
|
- ./data/uploads:/app/public/uploads
|
||||||
|
|
||||||
mailpit:
|
mailpit:
|
||||||
image: axllent/mailpit:latest
|
image: axllent/mailpit:latest
|
||||||
@@ -44,14 +44,18 @@ services:
|
|||||||
- "443:443"
|
- "443:443"
|
||||||
volumes:
|
volumes:
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
- caddy_data:/data
|
- ./data/caddy/data:/data
|
||||||
- caddy_config:/config
|
- ./data/caddy/config:/config
|
||||||
- uploads:/srv/uploads
|
- ./data/uploads:/srv/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
|
|
||||||
volumes:
|
stripe-cli:
|
||||||
pgdata:
|
image: stripe/stripe-cli:latest
|
||||||
caddy_data:
|
command: listen --forward-to http://app:3000/api/webhooks/stripe --api-key ${STRIPE_SECRET_KEY}
|
||||||
caddy_config:
|
depends_on:
|
||||||
uploads:
|
- app
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
|
||||||
|
|||||||
+197
-30
@@ -169,13 +169,14 @@ Il form è diviso in 4 sezioni.
|
|||||||
#### Sezione 2 — Pricing & Inventory
|
#### Sezione 2 — Pricing & Inventory
|
||||||
|
|
||||||
**Base Price** *(obbligatorio)*
|
**Base Price** *(obbligatorio)*
|
||||||
- Campo: `number` (valore in centesimi)
|
- Campo: `number` (valore in euro/dollari, con decimali)
|
||||||
- **Attenzione:** il prezzo va inserito in centesimi, non in euro/dollari
|
- Inserisci il prezzo nella valuta selezionata, con il punto come separatore decimale
|
||||||
- Esempi:
|
- Esempi:
|
||||||
- `1999` → €19,99
|
- `19.99` → €19,99
|
||||||
- `4990` → €49,90
|
- `49.90` → €49,90
|
||||||
- `10000` → €100,00
|
- `100` → €100,00
|
||||||
- Valore minimo: `0`
|
- 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
|
1. Vai su http://localhost/admin/product-types
|
||||||
2. Clicca **"New Product Type"**
|
2. Clicca **"New Product Type"**
|
||||||
3. Compila:
|
3. Compila i campi (vedi sotto)
|
||||||
- **Name:** nome del tipo (es. `Abbigliamento`)
|
4. Clicca **"Save"**
|
||||||
- **Schema (JSON):** definizione degli attributi in formato JSON Schema
|
|
||||||
|
|
||||||
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
|
```json
|
||||||
{
|
{
|
||||||
"type": "object",
|
"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
|
### Modifica e gestione
|
||||||
|
|
||||||
@@ -395,12 +431,35 @@ Abbigliamento (padre)
|
|||||||
|
|
||||||
1. Vai su http://localhost/admin/categories
|
1. Vai su http://localhost/admin/categories
|
||||||
2. Clicca **"New Category"**
|
2. Clicca **"New Category"**
|
||||||
3. Compila:
|
3. Compila i campi (vedi sotto)
|
||||||
- **Name** *(obbligatorio):* nome della categoria (es. `Sneaker`)
|
|
||||||
- **Slug** *(obbligatorio):* generato automaticamente, usato nell'URL
|
|
||||||
- **Parent Category** *(opzionale):* seleziona la categoria padre per creare una sottocategoria
|
|
||||||
4. Clicca **"Save"**
|
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
|
### Modificare una categoria
|
||||||
|
|
||||||
Clicca **"Edit"** accanto alla categoria, modifica i campi, salva.
|
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
|
1. Vai su http://localhost/admin/admin-users
|
||||||
2. Clicca **"New Admin User"**
|
2. Clicca **"New Admin User"**
|
||||||
3. Compila:
|
3. Compila i campi (vedi sotto)
|
||||||
- **Name:** nome del nuovo admin
|
|
||||||
- **Email:** email di accesso (deve essere unica)
|
|
||||||
- **Password:** password iniziale (il sistema richiederà il cambio al primo login)
|
|
||||||
- **Role:** seleziona `ADMIN` o `OWNER`
|
|
||||||
4. Clicca **"Create"**
|
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
|
### Eliminare un admin
|
||||||
|
|
||||||
@@ -569,16 +662,6 @@ Clicca **"Delete"** accanto all'admin da rimuovere.
|
|||||||
|
|
||||||
Configura le impostazioni globali del negozio.
|
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
|
### Come modificare
|
||||||
|
|
||||||
1. Vai su http://localhost/admin/settings
|
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.
|
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
|
## 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