Compare commits

..

32 Commits

Author SHA1 Message Date
davide 4b49506e39 temp 2026-05-19 16:29:00 +02:00
davide 126a74cddb docs: rewrite setup guide with Stripe webhook and bug fix notes
- Clarify that STRIPE_SECRET_KEY is required from step 2
- Document --profile dev as the recommended local startup command
- Replace manual stripe listen instructions with docker-compose service
- Add Stripe test cards and payment verification checklist
- Fix docker compose restart → --force-recreate in webhook setup steps
- Add troubleshooting entries for PENDING orders and .env not reloading
2026-05-19 16:05:37 +02:00
davide 982c268acc feat(dev): add stripe-cli service for local webhook forwarding
Adds a stripe-cli container under the 'dev' profile that forwards
Stripe webhook events directly to app:3000, bypassing Caddy. Start
with: docker compose --profile dev up -d
2026-05-19 16:05:13 +02:00
davide c966797073 test(product): add cart behavior tests for ProductDetailPage
Covers: Add to Cart, Add to Cart twice (quantity increment), Buy Now
standalone, and Add to Cart followed by Buy Now (no duplication).
2026-05-19 16:04:58 +02:00
davide 8bfd7afdcb fix(product): fix cart deduplication for products without variants
addToCart() compared item.variantId === selectedVariant where
selectedVariant is null but saved items store variantId: undefined.
null !== undefined caused findIndex to never match, always inserting
a duplicate instead of incrementing quantity. Normalised with || undefined.
2026-05-19 16:04:25 +02:00
davide 2526cf9543 fix(cart): use session-based auth instead of localStorage for checkout
Cart page checked localStorage.getItem('user') which is never set by
the cookie-based auth system. Replaced with useUser() context and
corrected redirect target from /cart to /checkout.
2026-05-19 16:04:12 +02:00
davide c7d2713c23 docs(test): document expected stderr/stdout output during test runs
Explains that the Stripe webhook stderr log, the unhandled event stdout,
and the Vite CJS deprecation warning are all normal and expected.
2026-05-19 14:18:26 +02:00
davide b9ebd250e7 chore: ignore app/public/uploads/ and remove spurious root package-lock.json
uploads/ is generated at runtime by Docker and must not be tracked.
package-lock.json in the repo root was created by accident.
2026-05-19 14:11:01 +02:00
davide db6b727902 test: add component tests (Button, ProductCard) and test suite README
20 component tests covering Button (variants, disabled state, event handlers) and
ProductCard (rendering, price formatting, sale badge, image fallback). README
documents the full suite: 151 tests across 10 files, how to run, mock patterns,
and what's missing by priority (checkout flow, admin routes, more components).
2026-05-19 14:08:07 +02:00
davide 6a5d5a6119 test: add integration tests for API routes (auth, Stripe webhook)
27 tests covering POST /api/auth/login (9), POST /api/auth/register (9), and
POST /api/webhooks/stripe (11). Routes are tested by importing handlers directly
as functions, no HTTP server needed. Stripe false-positive fixed: thrown error
message now differs from the hardcoded 400 response to verify sanitization.
2026-05-19 14:07:53 +02:00
davide 5428eeccc1 test: add unit tests for lib/ (validate, auth, storage, email, rate-limit)
49 tests for all 10 Zod schemas in validate.ts, 26 tests for auth (hashPassword,
verifyPassword, createSession, getSession, getCurrentUser, deleteSession), 11 for
storage (magic-byte validation, saveImage, deleteImageFile), 9 for email (sendMail
scenarios), and 6 for rate limiting logic.
2026-05-19 14:07:38 +02:00
davide b93f5d5bdf test: add Vitest infrastructure (config, setup, mocks, fixtures)
Adds test harness for the suite:
- vitest.config.ts: happy-dom env, @/* alias, v8 coverage with 70% thresholds
- setup.ts: env vars, global next/headers and next/navigation mocks
- tsconfig.json: IDE alias resolution for test/
- __mocks__/prisma.ts: centralised Prisma mock auto-registered via vi.mock
- fixtures/users.ts, fixtures/orders.ts: typed test data
2026-05-19 14:07:22 +02:00
davide ed7faa3be5 build: add Vitest test dependencies and fix Docker build type error
Added devDependencies: vitest, @vitest/coverage-v8, happy-dom, @testing-library/react,
jest-dom, user-event, and test scripts (test, test:watch, test:coverage).
Removed @types/testing-library__jest-dom (redundant with jest-dom v6+, caused
Docker build to fail with "Cannot find type definition file" error).
2026-05-19 14:07:09 +02:00
davide 93cfe1ad5e docs: update CLAUDE.md with Italian rule, error workflow, and full data model
Added Italian communication requirement, error-first workflow (report then wait),
and missing Prisma models: ProductVariant, PasswordResetToken, Page/PageSection,
SiteSettings, AuditLog. Added app/coverage/ to .gitignore.
2026-05-19 14:05:34 +02:00
davide ea5fca6561 fix: replace hardcoded site name with dynamic settings
- Add public /api/settings endpoint (force-dynamic, no auth) exposing
  site_name, site_description, footer_copyright, footer_links
- Navbar, login, register pages fetch site_name via useEffect
- Homepage hero and footer read site_name and site_description from DB
- Fix admin settings form silently ignoring API errors on save
2026-05-19 11:30:15 +02:00
davide 9797519e5c fix: use named Docker volume for uploads to fix permission errors
Bind-mounting ./data/uploads caused EACCES errors because Docker creates
the host directory as root, while the container runs as nextjs (UID 1001).
A named volume is initialized from the image where chown is already set correctly.
2026-05-19 10:54:45 +02:00
davide 43a3efc94f fix(security): clamp pagination parameters to prevent negative or overflow values
Replace raw parseInt() with Math.max/min bounds: page >= 1, limit 1-100.
Affects public products, admin orders, and admin reviews endpoints.
2026-05-19 10:12:17 +02:00
davide e18bc8fbda fix(security): block deletion of categories and product types in use
Return 409 Conflict if any products reference the entity being deleted,
preventing accidental data corruption from orphaned foreign keys.
2026-05-19 10:12:03 +02:00
davide 5654964d09 fix(security): sanitize error logging and remove Zod schema details from responses
- Stripe webhook: log only error.message instead of full error objects
  to avoid exposing stack traces in aggregated logs
- Admin products POST: return only first Zod error message instead of
  the full error array which reveals internal schema structure
2026-05-19 10:11:47 +02:00
davide 8cf038443f fix(security): remove hardcoded default credentials from config files
- .env.example: replace weak default INITIAL_ADMIN_PASSWORD and
  AUTH_SECRET with instructive placeholders requiring manual generation
- docker-compose.yml: parameterize POSTGRES_USER, POSTGRES_PASSWORD,
  POSTGRES_DB and DATABASE_URL via environment variables with local fallbacks
2026-05-19 10:11:30 +02:00
davide d4b3398de5 fix(security): enforce order status state machine in admin endpoint
Add VALID_TRANSITIONS map and validate each status change before updating
the database. Prevents skipping payment (e.g. PENDING→FULFILLED) or
reopening closed orders.
2026-05-19 10:11:16 +02:00
davide f4eedaffe2 fix(security): replace in-memory rate limiting with persistent DB-backed limiter
- Add LoginAttempt model to Prisma schema with migration
- Create rate-limit.ts utility (10 attempts / 15 min window, DB-backed)
- Apply rate limiting to login endpoint (replaces in-memory Map)
- Apply rate limiting to change-password endpoint (previously unprotected)
- Rate limit state survives server restarts and works across multiple instances
2026-05-19 10:10:57 +02:00
davide 45a50dc906 fix(security): whitelist allowed keys in admin settings endpoint
Reject any key not in the explicit allowlist before writing to the database,
preventing arbitrary configuration injection by a malicious admin.
2026-05-19 10:10:42 +02:00
davide fcfa0707a1 fix(security): replace localStorage user state with server-side session
- Add GET /api/auth/me endpoint returning current user from httpOnly cookie
- Add UserContext + useUser() hook that fetches from /api/auth/me on mount
- Wrap root layout with UserProvider
- Remove all localStorage.setItem/getItem('user') calls from login, register,
  navbar, account pages, change-password, and checkout
- mustChangePassword redirect now reads from refreshed server session
2026-05-19 10:10:24 +02:00
davide 0395a78008 fix(security): add HTTP security headers (CSP, HSTS, X-Frame-Options)
- middleware.ts: set X-Frame-Options, X-Content-Type-Options,
  Referrer-Policy, Permissions-Policy, Content-Security-Policy on all responses
- Caddyfile: add Strict-Transport-Security (HSTS 1y), X-Frame-Options,
  X-Content-Type-Options at reverse proxy level
2026-05-19 10:10:08 +02:00
davide 2a6c3a1222 fix(security): validate file uploads with magic bytes, remove SVG from favicon whitelist
- Add validateImageMagicBytes() to storage.ts reading first 12 bytes
  to verify JPEG/PNG/WebP/ICO signatures regardless of declared MIME type
- Remove image/svg+xml from favicon upload whitelist (SVG can embed scripts)
- Apply magic bytes check in product image and favicon upload endpoints
2026-05-19 10:09:53 +02:00
davide d958a4b9a5 docs: add network ports section to README
Sezione dedicata alle porte con distinzione tra porte pubbliche (80,
443), interne Docker (3000, 5432, 1025) e solo sviluppo (8025).
Include comandi UFW pronti per Ubuntu/Debian.
2026-05-19 09:19:16 +02:00
davide 4a7cd9fbd4 docs: expand admin guide with field-by-field documentation
Aggiunta documentazione campo per campo per tutte le sezioni: Product

Types, Categorie, Admin Users, Impostazioni (generale, footer, favicon).

Corretto Base Price da centesimi a euro dopo la fix del form.
2026-05-19 09:17:38 +02:00
davide 7afb609386 fix: hide slug field in product type form, auto-generate from name
Lo slug viene calcolato automaticamente dal nome senza che l'utente
debba compilarlo — il campo è rimosso dal form ma continua ad essere
inviato nel payload e visibile nella tabella.
2026-05-19 09:17:17 +02:00
davide 46d1596dce fix: update tsconfig target from deprecated ES5 to ES2017 2026-05-19 09:01:51 +02:00
davide 3b800463f5 fix: accept price in currency units instead of cents in product form
Il campo prezzo del form admin ora accetta valori in unità (es. 19.99)
invece di centesimi (1999). La conversione *100 avviene al submit,
il DB e Stripe continuano a ricevere centesimi.
2026-05-19 08:58:46 +02:00
davide 2c6c847d76 feat: replace Docker named volumes with local bind mounts and add backup script
- docker-compose.yml: sostituisce pgdata/uploads/caddy_data/caddy_config con bind mount su ./data/
- app/public/.gitkeep: crea cartella richiesta dal Dockerfile durante il build
- scripts/backup.sh: backup automatico di DB (pg_dump) e uploads con rotazione 30 giorni
- docs/BACKUP.md: guida completa backup, ripristino e setup cron
- .gitignore: aggiorna con data/ e backups/
2026-05-19 08:49:28 +02:00
66 changed files with 5557 additions and 207 deletions
+3 -2
View File
@@ -1,8 +1,8 @@
APP_URL=http://localhost
DATABASE_URL=postgresql://ecommerce:ecommerce_password@db:5432/ecommerce
AUTH_SECRET=dev-secret-change-in-production-32chars
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
INITIAL_ADMIN_EMAIL=admin@example.com
INITIAL_ADMIN_PASSWORD=Admin1234!test
INITIAL_ADMIN_PASSWORD=<change-this-use-openssl-rand-base64-32>
STRIPE_SECRET_KEY=sk_test_placeholder
STRIPE_WEBHOOK_SECRET=whsec_placeholder
SMTP_HOST=mailpit
@@ -10,3 +10,4 @@ SMTP_PORT=1025
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=noreply@localhost
POSTGRES_PASSWORD=ecommerce_password
+12 -7
View File
@@ -6,11 +6,15 @@
# Node
app/node_modules/
node_modules/
test/node_modules
# Next.js build output
app/.next/
app/out/
# Test coverage report
app/coverage/
# Prisma generated client (rebuilt on npm install)
app/node_modules/.prisma/
@@ -24,14 +28,15 @@ Thumbs.db
*.swp
*.swo
# Docker volumes (dati locali)
pgdata/
caddy_data/
caddy_config/
# Dati locali (bind mount Docker) — ignora contenuto, traccia solo struttura
data/db/
data/caddy/
data/uploads/*
!data/uploads/.gitkeep
# Backup
backups/
# Logs
*.log
npm-debug.log*
# Uploads / media locali
app/public/uploads/
+135
View File
@@ -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.
+5
View File
@@ -1,4 +1,9 @@
localhost {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
}
handle /uploads/* {
root * /srv
file_server
+113 -25
View File
@@ -12,6 +12,7 @@ Piattaforma e-commerce containerizzata, avviabile con un singolo comando.
- [Docker Desktop](https://docs.docker.com/get-docker/) installato e avviato
- Porta **80** libera (nessun altro web server in esecuzione)
- Account [Stripe](https://stripe.com) gratuito (necessario per i pagamenti)
### 1. Clona il repository
@@ -26,14 +27,28 @@ cd ecommerce-platform
cp .env.example .env
```
Il file `.env` di default è già configurato per localhost. **Non serve modificare nulla** per i primi test.
Apri `.env` e inserisci la tua chiave Stripe test (gratuita, dalla [Stripe Dashboard → API keys](https://dashboard.stripe.com/test/apikeys)):
```env
STRIPE_SECRET_KEY=sk_test_la_tua_chiave
```
Il resto dei valori è già preconfigurato per localhost e non va modificato.
### 3. Avvia la piattaforma
**Senza pagamenti** (admin, catalogo, email):
```bash
docker compose up -d
```
**Con pagamenti e webhook Stripe attivi** (consigliato):
```bash
docker compose --profile dev up -d
```
Il primo avvio richiede **510 minuti**: Docker scarica le immagini, installa le dipendenze npm, compila Next.js, esegue le migrazioni e crea l'utente admin.
Segui il progresso con:
@@ -76,33 +91,57 @@ Al primo accesso il sistema ti obbliga a cambiare la password.
3. **Products** → crea un prodotto, assegna tipo e categoria, impostalo su **Published**
4. Apri http://localhost → il prodotto appare in homepage
### 7. Cosa funziona in locale senza configurazione extra
### 7. Setup webhook Stripe (una tantum per macchina)
| Funzionalità | Stato |
|---|---|
| Admin dashboard completa | ✅ |
| Gestione prodotti, categorie, ordini | ✅ |
| Registrazione e login clienti | ✅ |
| Email (reset password, ecc.) | ✅ visibili su http://localhost:8025 |
| Pagamenti Stripe | ⚠️ richiede chiavi reali (vedi sotto) |
Il profilo `dev` include un container `stripe-cli` che riceve i webhook da Stripe e li inoltra all'app. Senza di esso, dopo il pagamento l'ordine resta in `PENDING` e nessuna email viene inviata.
**Per testare i pagamenti Stripe** in locale, inserisci una chiave `sk_test_...` reale nel `.env` (gratuita, modalità test su [dashboard.stripe.com](https://dashboard.stripe.com/test/apikeys)):
**Al primo avvio con `--profile dev`, recupera il webhook secret dai log:**
```bash
docker compose logs stripe-cli | grep "webhook signing secret"
```
Copia il valore `whsec_...` nel `.env`:
```env
STRIPE_SECRET_KEY=sk_test_la_tua_chiave
STRIPE_WEBHOOK_SECRET=whsec_abc123...
```
Poi riavvia l'app:
Poi ricrea il container app per applicare la variabile (`restart` non ricarica il `.env`):
```bash
docker compose restart app
docker compose up -d --force-recreate app
```
### 8. Ferma la piattaforma
Da questo momento `docker compose --profile dev up -d` avvia tutto inclusi i webhook — non serve altro.
### 8. Testa i pagamenti
**Carte di test Stripe:**
| Carta | Risultato |
|-------|-----------|
| `4242 4242 4242 4242` | Pagamento riuscito |
| `4000 0000 0000 0002` | Carta rifiutata |
| `4000 0025 0000 3155` | Richiede autenticazione 3D Secure |
Scadenza: qualsiasi data futura · CVC: qualsiasi 3 cifre
**Verifica che tutto funzioni dopo un pagamento:**
```bash
docker compose down # ferma i container, i dati restano
docker compose down -v # ferma e cancella anche il database
docker compose logs stripe-cli --tail=20
# deve mostrare [200] per checkout.session.completed
```
1. L'ordine nel pannello admin passa da `PENDING` a `PAID`
2. L'email di conferma appare su http://localhost:8025
### 9. Ferma la piattaforma
```bash
docker compose --profile dev down # ferma tutto (con stripe-cli), i dati restano
docker compose down -v # ferma e cancella anche il database
```
---
@@ -133,13 +172,13 @@ Cinque variabili da aggiornare nel `.env`:
|-----------|-----------|------------|
| `APP_URL` | `http://localhost` | `https://tuodominio.com` |
| `AUTH_SECRET` | qualsiasi stringa | `openssl rand -hex 32` |
| `STRIPE_SECRET_KEY` | `sk_test_...` (gratuita) | `sk_live_...` |
| `STRIPE_WEBHOOK_SECRET` | opzionale | obbligatorio |
| `STRIPE_SECRET_KEY` | `sk_test_...` | `sk_live_...` |
| `STRIPE_WEBHOOK_SECRET` | dal container stripe-cli | dal dashboard Stripe |
| `SMTP_*` | Mailpit locale (porta 8025) | provider reale (Resend, Mailgun, SendGrid…) |
### 3 — Server
Serve un VPS Linux con Docker installato (DigitalOcean, Hetzner, OVH, ecc.) e il record DNS del dominio puntato all'IP del server. Il comando di avvio è identico: `docker compose up -d`.
Serve un VPS Linux con Docker installato (DigitalOcean, Hetzner, OVH, ecc.) e il record DNS del dominio puntato all'IP del server. Il comando di avvio è identico: `docker compose up -d` (senza `--profile dev`).
### 4 — Backup
@@ -221,8 +260,6 @@ tuodominio.com {
}
```
Caddy ottiene e rinnova automaticamente il certificato HTTPS tramite Let's Encrypt. Non serve nessuna configurazione SSL manuale.
### 5. Avvia
```bash
@@ -251,10 +288,10 @@ Eventi da ascoltare:
- payment_intent.payment_failed
```
Copia il **Signing secret** (`whsec_...`) nel `.env` come `STRIPE_WEBHOOK_SECRET`, poi:
Copia il **Signing secret** (`whsec_...`) nel `.env` come `STRIPE_WEBHOOK_SECRET`, poi ricrea il container per applicarlo:
```bash
docker compose restart app
docker compose up -d --force-recreate app
```
### 7. Primo accesso e configurazione negozio
@@ -271,6 +308,43 @@ docker compose restart app
---
## Porte di rete
### Porte da aprire nel firewall del server (produzione)
| Porta | Protocollo | Servizio | Perché è necessaria |
|-------|-----------|---------|---------------------|
| **80** | TCP | Caddy (HTTP) | Redirect automatico HTTP → HTTPS e rinnovo certificati Let's Encrypt (ACME challenge) |
| **443** | TCP | Caddy (HTTPS) | Traffico web principale — sito pubblico e pannello admin |
> Solo queste due porte devono essere aperte verso l'esterno. Tutto il resto è interno a Docker.
### Porte interne (rete Docker — non esporre al pubblico)
| Porta | Servizio | Note |
|-------|---------|------|
| **3000** | Next.js (app) | Accessibile solo da Caddy tramite `reverse_proxy app:3000` |
| **5432** | PostgreSQL (db) | Accessibile solo dall'app tramite `DATABASE_URL` |
| **1025** | Mailpit SMTP | Accessibile solo dall'app per l'invio email |
### Porta solo per sviluppo locale (non aprire in produzione)
| Porta | Servizio | Note |
|-------|---------|------|
| **8025** | Mailpit UI | Interfaccia web per ispezionare le email di test — non deve mai essere esposta in produzione |
### Riepilogo comandi firewall (UFW — Ubuntu/Debian)
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw deny 8025/tcp
sudo ufw enable
sudo ufw status
```
---
## Aggiornamenti
```bash
@@ -319,6 +393,20 @@ docker compose logs -f app
**Errore "port 80 already in use"**
Un altro servizio usa la porta 80 (Apache, Nginx, ecc.). Fermalo o cambia la porta nel `docker-compose.yml`.
**L'ordine resta in PENDING dopo il pagamento**
Il container `stripe-cli` non è in esecuzione o `STRIPE_WEBHOOK_SECRET` non è configurato. Verifica:
```bash
docker compose --profile dev ps # stripe-cli deve essere "running"
docker compose logs stripe-cli --tail=10
```
Se il `whsec_...` manca nel `.env`, segui lo step 7 del setup locale.
**Le variabili del `.env` non vengono applicate dopo la modifica**
`docker compose restart` non ricarica il `.env`. Usa sempre:
```bash
docker compose up -d --force-recreate app
```
**Dimentico la password admin**
Resetta direttamente nel database:
```bash
@@ -338,7 +426,7 @@ docker compose exec db psql -U ecommerce ecommerce -c \
```
ecommerce-platform/
├── docker-compose.yml Orchestrazione: db, app, caddy, mailpit
├── docker-compose.yml Orchestrazione: db, app, caddy, mailpit, stripe-cli (profilo dev)
├── Caddyfile Reverse proxy — modifica qui il dominio
├── .env Variabili d'ambiente (non committare)
├── .env.example Template da copiare
@@ -370,7 +458,7 @@ ecommerce-platform/
| `INITIAL_ADMIN_EMAIL` | Email primo admin | qualsiasi | la tua email |
| `INITIAL_ADMIN_PASSWORD` | Password primo admin | qualsiasi | sicura |
| `STRIPE_SECRET_KEY` | Chiave Stripe | `sk_test_...` | `sk_live_...` |
| `STRIPE_WEBHOOK_SECRET` | Segreto webhook Stripe | opzionale | obbligatorio |
| `STRIPE_WEBHOOK_SECRET` | Segreto webhook Stripe | dal container stripe-cli | dal dashboard Stripe |
| `SMTP_HOST` | Server SMTP | `mailpit` | provider reale |
| `SMTP_PORT` | Porta SMTP | `1025` | `587` |
| `SMTP_USER` | Utente SMTP | vuoto | obbligatorio |
+2453 -1
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -6,7 +6,10 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"postinstall": "prisma generate"
"postinstall": "prisma generate",
"test": "vitest run --config ../test/vitest.config.ts",
"test:watch": "vitest --config ../test/vitest.config.ts",
"test:coverage": "vitest run --config ../test/vitest.config.ts --coverage"
},
"dependencies": {
"next": "14.2.5",
@@ -26,6 +29,12 @@
"typescript": "^5",
"tailwindcss": "^3.4.1",
"postcss": "^8",
"autoprefixer": "^10.0.1"
"autoprefixer": "^10.0.1",
"vitest": "^1.6.0",
"@vitest/coverage-v8": "^1.6.0",
"happy-dom": "^14.12.0",
"@testing-library/react": "^16.0.0",
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/user-event": "^14.5.2"
}
}
@@ -0,0 +1,7 @@
CREATE TABLE "LoginAttempt" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LoginAttempt_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "LoginAttempt_key_createdAt_idx" ON "LoginAttempt"("key", "createdAt");
+8
View File
@@ -255,3 +255,11 @@ model AuditLog {
metadata Json?
createdAt DateTime @default(now())
}
model LoginAttempt {
id String @id @default(cuid())
key String // IP address or identifier
createdAt DateTime @default(now())
@@index([key, createdAt])
}
View File
+4 -2
View File
@@ -6,6 +6,7 @@ import Link from 'next/link'
import { Navbar } from '@/components/storefront/Navbar'
import { Badge } from '@/components/ui/Badge'
import { Alert } from '@/components/ui/Alert'
import { useUser } from '@/context/UserContext'
interface Order {
id: string
@@ -35,13 +36,14 @@ export default function OrdersPage() {
function OrdersContent() {
const [orders, setOrders] = useState<Order[]>([])
const [loading, setLoading] = useState(true)
const { user, isLoading: userLoading } = useUser()
const router = useRouter()
const searchParams = useSearchParams()
const success = searchParams.get('success')
useEffect(() => {
const stored = localStorage.getItem('user')
if (!stored) {
if (userLoading) return
if (!user) {
router.push('/login?redirect=/account/orders')
return
}
+6 -19
View File
@@ -1,35 +1,22 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Navbar } from '@/components/storefront/Navbar'
interface User {
id: string
email: string
name?: string
role: string
}
import { useUser } from '@/context/UserContext'
export default function AccountPage() {
const [user, setUser] = useState<User | null>(null)
const { user, isLoading } = useUser()
const router = useRouter()
useEffect(() => {
const stored = localStorage.getItem('user')
if (!stored) {
if (!isLoading && !user) {
router.push('/login?redirect=/account')
return
}
try {
setUser(JSON.parse(stored))
} catch {
router.push('/login')
}
}, [router])
}, [isLoading, user, router])
if (!user) return null
if (isLoading || !user) return null
return (
<div>
+5 -6
View File
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
import { useUser } from '@/context/UserContext'
export default function ChangePasswordPage() {
const [currentPassword, setCurrentPassword] = useState('')
@@ -14,6 +15,7 @@ export default function ChangePasswordPage() {
const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false)
const router = useRouter()
const { user, setUser } = useUser()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -41,12 +43,9 @@ export default function ChangePasswordPage() {
}
setSuccess(true)
// Update localStorage to remove mustChangePassword flag
const userStr = localStorage.getItem('user')
if (userStr) {
const user = JSON.parse(userStr)
user.mustChangePassword = false
localStorage.setItem('user', JSON.stringify(user))
// Update context to remove mustChangePassword flag
if (user) {
setUser({ ...user, mustChangePassword: false })
}
setTimeout(() => router.push('/admin'), 1500)
+9 -17
View File
@@ -112,23 +112,15 @@ export default function AdminProductTypesPage() {
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
<h2 className="font-semibold mb-4">{editId ? 'Edit Type' : 'New Product Type'}</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Input
label="Name"
value={name}
onChange={(e) => {
setName(e.target.value)
if (!editId) setSlug(generateSlug(e.target.value))
}}
required
/>
<Input
label="Slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
required
/>
</div>
<Input
label="Name"
value={name}
onChange={(e) => {
setName(e.target.value)
if (!editId) setSlug(generateSlug(e.target.value))
}}
required
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Schema (JSON define attribute fields)
+4 -3
View File
@@ -82,7 +82,7 @@ export default function AdminProductEditPage() {
title: p.title,
slug: p.slug,
description: p.description,
basePrice: String(p.basePrice),
basePrice: (p.basePrice / 100).toFixed(2),
currency: p.currency,
status: p.status,
attributes: JSON.stringify(p.attributes, null, 2),
@@ -171,7 +171,7 @@ export default function AdminProductEditPage() {
title: form.title,
slug: form.slug,
description: form.description,
basePrice: parseInt(form.basePrice),
basePrice: Math.round(parseFloat(form.basePrice) * 100),
currency: form.currency,
status: form.status,
attributes,
@@ -262,11 +262,12 @@ export default function AdminProductEditPage() {
<div className="grid grid-cols-2 gap-4">
<Input
label="Base Price (cents)"
label={`Base Price (${form.currency})`}
type="number"
value={form.basePrice}
onChange={(e) => setForm((f) => ({ ...f, basePrice: e.target.value }))}
min="0"
step="0.01"
required
/>
<Select
+8 -1
View File
@@ -52,12 +52,19 @@ export default function AdminSettingsPage() {
e.preventDefault()
setSaving(true)
setError('')
setMessage('')
for (const key of ALL_KEYS) {
await fetch('/api/admin/settings', {
const res = await fetch('/api/admin/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value: values[key] }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setSaving(false)
setError(data.error || `Errore nel salvare "${key}" (${res.status})`)
return
}
}
setSaving(false)
setMessage('Impostazioni salvate!')
@@ -72,6 +72,14 @@ export async function DELETE(request: NextRequest) {
const id = searchParams.get('id')
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
const productCount = await prisma.productCategory.count({ where: { categoryId: id } })
if (productCount > 0) {
return NextResponse.json(
{ error: `Cannot delete: ${productCount} product(s) use this category` },
{ status: 409 }
)
}
await prisma.category.delete({ where: { id } })
return NextResponse.json({ success: true })
@@ -2,6 +2,14 @@ import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
const VALID_TRANSITIONS: Record<string, string[]> = {
PENDING: ['PAID', 'CANCELLED'],
PAID: ['FULFILLED', 'REFUNDED', 'CANCELLED'],
FULFILLED: ['REFUNDED'],
CANCELLED: [],
REFUNDED: [],
}
async function requireAdmin() {
const user = await getCurrentUser()
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
@@ -50,6 +58,17 @@ export async function PUT(
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
}
const currentOrder = await prisma.order.findUnique({ where: { id: params.id }, select: { status: true } })
if (!currentOrder) return NextResponse.json({ error: 'Order not found' }, { status: 404 })
const allowed = VALID_TRANSITIONS[currentOrder.status] ?? []
if (!allowed.includes(status)) {
return NextResponse.json(
{ error: `Cannot transition order from ${currentOrder.status} to ${status}` },
{ status: 422 }
)
}
const order = await prisma.order.update({
where: { id: params.id },
data: { status: status as 'PENDING' | 'PAID' | 'CANCELLED' | 'REFUNDED' | 'FULFILLED' },
+2 -2
View File
@@ -13,8 +13,8 @@ export async function GET(request: NextRequest) {
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
const status = searchParams.get('status')
const skip = (page - 1) * limit
@@ -73,6 +73,14 @@ export async function DELETE(request: NextRequest) {
const id = searchParams.get('id')
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
const productCount = await prisma.product.count({ where: { typeId: id } })
if (productCount > 0) {
return NextResponse.json(
{ error: `Cannot delete: ${productCount} product(s) use this product type` },
{ status: 409 }
)
}
await prisma.productType.delete({ where: { id } })
return NextResponse.json({ success: true })
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { saveImage, deleteImageFile } from '@/lib/storage'
import { saveImage, deleteImageFile, validateImageMagicBytes } from '@/lib/storage'
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
@@ -26,6 +26,10 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json({ error: 'Only JPEG, PNG and WebP images are allowed' }, { status: 400 })
}
const isValidImage = await validateImageMagicBytes(file, file.type)
if (!isValidImage) {
return NextResponse.json({ error: 'File content does not match declared image type' }, { status: 400 })
}
if (file.size > MAX_SIZE) {
return NextResponse.json({ error: 'File too large (max 5MB)' }, { status: 400 })
}
+3 -3
View File
@@ -16,8 +16,8 @@ export async function GET(request: NextRequest) {
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
const search = searchParams.get('search')
const status = searchParams.get('status')
@@ -65,7 +65,7 @@ export async function POST(request: NextRequest) {
const parsed = productSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message || 'Invalid input', details: parsed.error.errors },
{ error: parsed.error.errors[0]?.message ?? 'Invalid input' },
{ status: 400 }
)
}
+2 -2
View File
@@ -13,8 +13,8 @@ export async function GET(request: NextRequest) {
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
const status = searchParams.get('status')
const skip = (page - 1) * limit
+15
View File
@@ -2,6 +2,17 @@ import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
const ALLOWED_SETTING_KEYS = [
'site_name',
'site_description',
'support_email',
'currency',
'tax_rate',
'footer_copyright',
'footer_links',
'favicon_url',
] as const
async function requireAdmin() {
const user = await getCurrentUser()
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
@@ -40,6 +51,10 @@ export async function POST(request: NextRequest) {
if (!key) return NextResponse.json({ error: 'Key is required' }, { status: 400 })
if (!ALLOWED_SETTING_KEYS.includes(key as (typeof ALLOWED_SETTING_KEYS)[number])) {
return NextResponse.json({ error: 'Invalid setting key' }, { status: 400 })
}
const setting = await prisma.siteSettings.upsert({
where: { key },
update: { value: value as object },
@@ -3,8 +3,9 @@ import { mkdir, writeFile } from 'fs/promises'
import path from 'path'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { validateImageMagicBytes } from '@/lib/storage'
const ALLOWED_TYPES = ['image/x-icon', 'image/png', 'image/svg+xml', 'image/jpeg', 'image/webp']
const ALLOWED_TYPES = ['image/x-icon', 'image/png', 'image/jpeg', 'image/webp']
const MAX_SIZE = 1 * 1024 * 1024 // 1MB
const FAVICON_URL = '/uploads/branding/favicon.png'
@@ -23,7 +24,11 @@ export async function POST(req: NextRequest) {
if (!file) return NextResponse.json({ error: 'No file provided' }, { status: 400 })
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json({ error: 'Formato non supportato (usa PNG, ICO, SVG o WebP)' }, { status: 400 })
return NextResponse.json({ error: 'Formato non supportato (usa PNG, ICO, JPEG o WebP)' }, { status: 400 })
}
const isValidImage = await validateImageMagicBytes(file, file.type)
if (!isValidImage) {
return NextResponse.json({ error: 'File content does not match declared image type' }, { status: 400 })
}
if (file.size > MAX_SIZE) {
return NextResponse.json({ error: 'File troppo grande (max 1MB)' }, { status: 400 })
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser, verifyPassword, hashPassword } from '@/lib/auth'
import { changePasswordSchema } from '@/lib/validate'
import { checkRateLimit, recordAttempt } from '@/lib/rate-limit'
export async function POST(request: NextRequest) {
const user = await getCurrentUser()
@@ -9,6 +10,19 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const ip =
request.headers.get('x-forwarded-for') ??
request.headers.get('x-real-ip') ??
'unknown'
const { limited } = await checkRateLimit(ip)
if (limited) {
return NextResponse.json(
{ error: 'Too many attempts. Please try again later.' },
{ status: 429 }
)
}
let body: unknown
try {
body = await request.json()
@@ -28,6 +42,7 @@ export async function POST(request: NextRequest) {
const valid = await verifyPassword(currentPassword, user.passwordHash)
if (!valid) {
await recordAttempt(ip)
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 400 })
}
+9 -25
View File
@@ -6,33 +6,16 @@ import {
setSessionCookie,
} from '@/lib/auth'
import { loginSchema } from '@/lib/validate'
// Simple in-memory rate limiter
const loginAttempts = new Map<string, { count: number; resetAt: number }>()
function checkRateLimit(ip: string): boolean {
const now = Date.now()
const windowMs = 15 * 60 * 1000 // 15 minutes
const maxAttempts = 10
const record = loginAttempts.get(ip)
if (!record || record.resetAt < now) {
loginAttempts.set(ip, { count: 1, resetAt: now + windowMs })
return true
}
if (record.count >= maxAttempts) {
return false
}
record.count++
return true
}
import { checkRateLimit, recordAttempt } from '@/lib/rate-limit'
export async function POST(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for') || 'unknown'
const ip =
request.headers.get('x-forwarded-for') ??
request.headers.get('x-real-ip') ??
'unknown'
if (!checkRateLimit(ip)) {
const { limited } = await checkRateLimit(ip)
if (limited) {
return NextResponse.json(
{ error: 'Too many login attempts. Please try again later.' },
{ status: 429 }
@@ -58,11 +41,13 @@ export async function POST(request: NextRequest) {
const user = await prisma.user.findUnique({ where: { email } })
if (!user) {
await recordAttempt(ip)
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
}
const valid = await verifyPassword(password, user.passwordHash)
if (!valid) {
await recordAttempt(ip)
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
}
@@ -75,7 +60,6 @@ export async function POST(request: NextRequest) {
email: user.email,
name: user.name,
role: user.role,
mustChangePassword: user.mustChangePassword,
},
})
}
+16
View File
@@ -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,
},
})
}
+2 -2
View File
@@ -3,8 +3,8 @@ import { prisma } from '@/lib/prisma'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
const category = searchParams.get('category')
const search = searchParams.get('search')
+14
View File
@@ -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 })
}
+3 -3
View File
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
try {
event = constructWebhookEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
} catch (err) {
console.error('Webhook signature verification failed:', err)
console.error('Webhook signature verification failed:', err instanceof Error ? err.message : String(err))
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
@@ -55,7 +55,7 @@ export async function POST(request: NextRequest) {
currency: order.currency,
})
} catch (emailErr) {
console.error('Failed to send confirmation email:', emailErr)
console.error('Failed to send confirmation email:', emailErr instanceof Error ? emailErr.message : String(emailErr))
}
}
@@ -116,7 +116,7 @@ export async function POST(request: NextRequest) {
console.log(`Unhandled event type: ${event.type}`)
}
} catch (err) {
console.error('Error processing webhook:', err)
console.error('Error processing webhook:', err instanceof Error ? err.message : String(err))
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 })
}
+4 -3
View File
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Navbar } from '@/components/storefront/Navbar'
import { Button } from '@/components/ui/Button'
import { useUser } from '@/context/UserContext'
interface CartItem {
productId: string
@@ -18,6 +19,7 @@ interface CartItem {
export default function CartPage() {
const [cart, setCart] = useState<CartItem[]>([])
const router = useRouter()
const { user } = useUser()
useEffect(() => {
const stored = JSON.parse(localStorage.getItem('cart') || '[]')
@@ -51,10 +53,9 @@ export default function CartPage() {
const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
async function handleCheckout() {
const user = localStorage.getItem('user')
function handleCheckout() {
if (!user) {
router.push('/login?redirect=/cart')
router.push('/login?redirect=/checkout')
return
}
router.push('/checkout')
+10 -6
View File
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
import { Navbar } from '@/components/storefront/Navbar'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
import { useUser } from '@/context/UserContext'
interface CartItem {
productId: string
@@ -19,20 +20,23 @@ export default function CheckoutPage() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const router = useRouter()
const { user, isLoading: userLoading } = useUser()
useEffect(() => {
if (userLoading) return
if (!user) {
router.push('/login?redirect=/checkout')
return
}
const stored = JSON.parse(localStorage.getItem('cart') || '[]')
if (stored.length === 0) {
router.push('/cart')
return
}
setCart(stored)
const user = localStorage.getItem('user')
if (!user) {
router.push('/login?redirect=/checkout')
}
}, [router])
}, [router, user, userLoading])
const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
+4 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import './globals.css'
import { prisma } from '@/lib/prisma'
import { Footer } from '@/components/storefront/Footer'
import { UserProvider } from '@/context/UserContext'
export async function generateMetadata(): Promise<Metadata> {
try {
@@ -23,7 +24,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="it">
<body className="bg-gray-50 text-gray-900 min-h-screen flex flex-col">
{children}
<UserProvider>
{children}
</UserProvider>
<Footer />
</body>
</html>
+15 -5
View File
@@ -1,11 +1,12 @@
'use client'
import { Suspense, useState } from 'react'
import { Suspense, useState, useEffect } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
import { useUser } from '@/context/UserContext'
export default function LoginPage() {
return (
@@ -20,9 +21,18 @@ function LoginForm() {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [siteName, setSiteName] = useState('ShopX')
const router = useRouter()
const searchParams = useSearchParams()
const redirect = searchParams.get('redirect') || '/'
const { refreshUser } = useUser()
useEffect(() => {
fetch('/api/settings')
.then((r) => r.json())
.then((data) => { if (data.settings?.site_name) setSiteName(data.settings.site_name as string) })
.catch(() => {})
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -43,11 +53,11 @@ function LoginForm() {
return
}
localStorage.setItem('user', JSON.stringify(data.user))
const refreshedUser = await refreshUser()
if (data.user.mustChangePassword) {
if (refreshedUser?.mustChangePassword) {
router.push('/admin/change-password')
} else if (data.user.role === 'ADMIN' || data.user.role === 'OWNER') {
} else if (refreshedUser?.role === 'ADMIN' || refreshedUser?.role === 'OWNER') {
router.push('/admin')
} else {
router.push(redirect)
@@ -64,7 +74,7 @@ function LoginForm() {
<div className="w-full max-w-md">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
<div className="text-center mb-8">
<Link href="/" className="text-2xl font-bold text-gray-900">ShopX</Link>
<Link href="/" className="text-2xl font-bold text-gray-900">{siteName}</Link>
<h1 className="text-xl font-semibold mt-4">Welcome back</h1>
<p className="text-gray-600 text-sm mt-1">Sign in to your account</p>
</div>
+13 -4
View File
@@ -14,8 +14,17 @@ async function getFeaturedProducts() {
})
}
async function getSiteSettings() {
const rows = await prisma.siteSettings.findMany({
where: { key: { in: ['site_name', 'site_description'] } },
})
return Object.fromEntries(rows.map((r) => [r.key, r.value as string]))
}
export default async function HomePage() {
const products = await getFeaturedProducts()
const [products, settings] = await Promise.all([getFeaturedProducts(), getSiteSettings()])
const siteName = settings.site_name || 'ShopX'
const siteDescription = settings.site_description || 'Discover our curated collection of products'
return (
<div>
@@ -24,9 +33,9 @@ export default async function HomePage() {
{/* Hero */}
<section className="bg-blue-600 text-white py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 className="text-4xl font-bold mb-4">Welcome to ShopX</h1>
<h1 className="text-4xl font-bold mb-4">Welcome to {siteName}</h1>
<p className="text-xl text-blue-100 mb-8">
Discover our curated collection of products
{siteDescription}
</p>
<Link
href="/products"
@@ -90,7 +99,7 @@ export default async function HomePage() {
<footer className="bg-gray-800 text-gray-300 py-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-sm">
<p>&copy; {new Date().getFullYear()} ShopX. All rights reserved.</p>
<p>&copy; {new Date().getFullYear()} {siteName}. All rights reserved.</p>
</div>
</footer>
</div>
+7 -2
View File
@@ -54,7 +54,7 @@ export default function ProductDetailPage() {
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
const existingIndex = cart.findIndex(
(item: { productId: string; variantId?: string }) =>
item.productId === product.id && item.variantId === selectedVariant
item.productId === product.id && item.variantId === (selectedVariant || undefined)
)
if (existingIndex >= 0) {
@@ -249,7 +249,12 @@ export default function ProductDetailPage() {
size="lg"
variant="secondary"
onClick={() => {
addToCart()
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
const alreadyInCart = cart.some(
(item: { productId: string; variantId?: string }) =>
item.productId === product.id && item.variantId === (selectedVariant || undefined)
)
if (!alreadyInCart) addToCart()
router.push('/cart')
}}
>
+14 -4
View File
@@ -1,11 +1,12 @@
'use client'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
import { useUser } from '@/context/UserContext'
export default function RegisterPage() {
const [name, setName] = useState('')
@@ -13,7 +14,16 @@ export default function RegisterPage() {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [siteName, setSiteName] = useState('ShopX')
const router = useRouter()
const { refreshUser } = useUser()
useEffect(() => {
fetch('/api/settings')
.then((r) => r.json())
.then((data) => { if (data.settings?.site_name) setSiteName(data.settings.site_name as string) })
.catch(() => {})
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -34,7 +44,7 @@ export default function RegisterPage() {
return
}
localStorage.setItem('user', JSON.stringify(data.user))
await refreshUser()
router.push('/')
} catch {
setError('Something went wrong. Please try again.')
@@ -48,9 +58,9 @@ export default function RegisterPage() {
<div className="w-full max-w-md">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
<div className="text-center mb-8">
<Link href="/" className="text-2xl font-bold text-gray-900">ShopX</Link>
<Link href="/" className="text-2xl font-bold text-gray-900">{siteName}</Link>
<h1 className="text-xl font-semibold mt-4">Create Account</h1>
<p className="text-gray-600 text-sm mt-1">Join ShopX today</p>
<p className="text-gray-600 text-sm mt-1">Join {siteName} today</p>
</div>
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
+14 -12
View File
@@ -3,31 +3,33 @@
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useUser } from '@/context/UserContext'
export function Navbar() {
const [cartCount, setCartCount] = useState(0)
const [user, setUser] = useState<{ name?: string; email: string; role: string } | null>(null)
const [siteName, setSiteName] = useState('ShopX')
const { user, refreshUser } = useUser()
const router = useRouter()
useEffect(() => {
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
const count = cart.reduce((sum: number, item: { quantity: number }) => sum + item.quantity, 0)
setCartCount(count)
}, [])
const userData = localStorage.getItem('user')
if (userData) {
try {
setUser(JSON.parse(userData))
} catch {
// ignore
}
}
useEffect(() => {
fetch('/api/settings')
.then((r) => r.json())
.then((data) => {
const name = data.settings?.site_name
if (name) setSiteName(name as string)
})
.catch(() => {})
}, [])
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' })
localStorage.removeItem('user')
setUser(null)
await refreshUser()
router.push('/')
router.refresh()
}
@@ -37,7 +39,7 @@ export function Navbar() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<Link href="/" className="text-xl font-bold text-gray-900">
ShopX
{siteName}
</Link>
<nav className="flex items-center gap-6 text-sm">
+60
View File
@@ -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
}
+25
View File
@@ -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 } } })
}
+17
View File
@@ -16,6 +16,23 @@ export async function saveImage(
return `/uploads/${productId}/${filename}`
}
const IMAGE_MAGIC_BYTES: Record<string, (buf: Buffer) => boolean> = {
'image/jpeg': (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
'image/png': (b) => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47,
'image/webp': (b) =>
b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50,
'image/x-icon': (b) => b[0] === 0x00 && b[1] === 0x00 && b[2] === 0x01 && b[3] === 0x00,
}
export async function validateImageMagicBytes(file: File, declaredType: string): Promise<boolean> {
const checker = IMAGE_MAGIC_BYTES[declaredType]
if (!checker) return false
const arrayBuffer = await file.slice(0, 12).arrayBuffer()
const buf = Buffer.from(arrayBuffer)
return checker(buf)
}
export async function deleteImageFile(url: string): Promise<void> {
const filePath = path.join(process.cwd(), 'public', url)
await unlink(filePath)
+8
View File
@@ -4,6 +4,14 @@ import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const response = NextResponse.next()
response.headers.set('x-pathname', request.nextUrl.pathname)
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://api.stripe.com; frame-src https://js.stripe.com; frame-ancestors 'none'"
)
return response
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
+18 -14
View File
@@ -3,11 +3,11 @@ services:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ecommerce
POSTGRES_PASSWORD: ecommerce_password
POSTGRES_DB: ecommerce
POSTGRES_USER: ${POSTGRES_USER:-ecommerce}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ecommerce_password}
POSTGRES_DB: ${POSTGRES_DB:-ecommerce}
volumes:
- pgdata:/var/lib/postgresql/data
- ./data/db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ecommerce"]
interval: 5s
@@ -24,11 +24,11 @@ services:
condition: service_healthy
env_file: .env
environment:
DATABASE_URL: postgresql://ecommerce:ecommerce_password@db:5432/ecommerce
DATABASE_URL: postgresql://${POSTGRES_USER:-ecommerce}:${POSTGRES_PASSWORD:-ecommerce_password}@db:5432/${POSTGRES_DB:-ecommerce}
expose:
- "3000"
volumes:
- uploads:/app/public/uploads
- ./data/uploads:/app/public/uploads
mailpit:
image: axllent/mailpit:latest
@@ -44,14 +44,18 @@ services:
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
- uploads:/srv/uploads
- ./data/caddy/data:/data
- ./data/caddy/config:/config
- ./data/uploads:/srv/uploads
depends_on:
- app
volumes:
pgdata:
caddy_data:
caddy_config:
uploads:
stripe-cli:
image: stripe/stripe-cli:latest
command: listen --forward-to http://app:3000/api/webhooks/stripe --api-key ${STRIPE_SECRET_KEY}
depends_on:
- app
restart: unless-stopped
profiles:
- dev
+197 -30
View File
@@ -169,13 +169,14 @@ Il form è diviso in 4 sezioni.
#### Sezione 2 — Pricing & Inventory
**Base Price** *(obbligatorio)*
- Campo: `number` (valore in centesimi)
- **Attenzione:** il prezzo va inserito in centesimi, non in euro/dollari
- Campo: `number` (valore in euro/dollari, con decimali)
- Inserisci il prezzo nella valuta selezionata, con il punto come separatore decimale
- Esempi:
- `1999` → €19,99
- `4990` → €49,90
- `10000` → €100,00
- `19.99` → €19,99
- `49.90` → €49,90
- `100` → €100,00
- Valore minimo: `0`
- Il sistema converte automaticamente in centesimi internamente (compatibilità con Stripe)
---
@@ -344,12 +345,35 @@ Un tipo di prodotto è essenzialmente uno schema che dice: "i prodotti di questa
1. Vai su http://localhost/admin/product-types
2. Clicca **"New Product Type"**
3. Compila:
- **Name:** nome del tipo (es. `Abbigliamento`)
- **Schema (JSON):** definizione degli attributi in formato JSON Schema
3. Compila i campi (vedi sotto)
4. Clicca **"Save"**
Esempio di schema:
### Campi — Product Type
**Name** *(obbligatorio)*
- Campo: `text`
- Il nome del tipo di prodotto, visibile solo nell'admin
- Esempio: `Abbigliamento`, `Elettronica`, `Libri`
- Usa nomi chiari che descrivano la categoria merceologica
---
**Slug** *(obbligatorio)*
- Campo: `text`
- Identificatore univoco generato automaticamente dal nome
- Formato: solo lettere minuscole, numeri e trattini (es. `abbigliamento`, `elettronica`)
- Non può contenere spazi o caratteri speciali
- Viene usato internamente dal sistema per identificare il tipo — non è visibile ai clienti
- **Non modificarlo dopo aver creato prodotti** con questo tipo
---
**Schema (JSON)** *(opzionale)*
- Campo: `textarea` con formato JSON
- Definisce quali attributi personalizzati avranno i prodotti di questo tipo
- Usa il formato JSON Schema standard
Esempio per abbigliamento:
```json
{
"type": "object",
@@ -361,7 +385,19 @@ Esempio di schema:
}
```
4. Clicca **"Save"**
Esempio per elettronica:
```json
{
"type": "object",
"properties": {
"marca": { "type": "string" },
"ram_gb": { "type": "number" },
"storage_gb": { "type": "number" }
}
}
```
> Lo schema è una guida per te — il sistema non blocca l'inserimento di attributi non definiti nello schema.
### Modifica e gestione
@@ -395,12 +431,35 @@ Abbigliamento (padre)
1. Vai su http://localhost/admin/categories
2. Clicca **"New Category"**
3. Compila:
- **Name** *(obbligatorio):* nome della categoria (es. `Sneaker`)
- **Slug** *(obbligatorio):* generato automaticamente, usato nell'URL
- **Parent Category** *(opzionale):* seleziona la categoria padre per creare una sottocategoria
3. Compila i campi (vedi sotto)
4. Clicca **"Save"**
### Campi — Categoria
**Name** *(obbligatorio)*
- Campo: `text`
- Il nome della categoria visibile ai clienti nel negozio
- Esempio: `Sneaker`, `T-shirt Uomo`, `Smartphone`
- Usa nomi brevi e chiari
---
**Slug** *(obbligatorio)*
- Campo: `text`
- Generato automaticamente dal nome
- Formato: solo lettere minuscole, numeri e trattini (es. `sneaker`, `t-shirt-uomo`)
- Viene usato nell'URL della categoria nel negozio: `/category/sneaker`
- **Non modificarlo** dopo aver pubblicato la categoria per evitare link rotti
---
**Parent Category** *(opzionale)*
- Campo: `select` (menu a tendina)
- Permette di creare una gerarchia padre/figlio
- Lascia vuoto per una categoria di primo livello (es. `Abbigliamento`)
- Seleziona una categoria esistente per creare una sottocategoria (es. `Sneaker` → padre: `Scarpe`)
- Puoi annidare più livelli (es. `Running` → padre: `Sneaker` → nonno: `Scarpe`)
### Modificare una categoria
Clicca **"Edit"** accanto alla categoria, modifica i campi, salva.
@@ -546,14 +605,48 @@ Gestisci gli account che possono accedere al pannello admin.
1. Vai su http://localhost/admin/admin-users
2. Clicca **"New Admin User"**
3. Compila:
- **Name:** nome del nuovo admin
- **Email:** email di accesso (deve essere unica)
- **Password:** password iniziale (il sistema richiederà il cambio al primo login)
- **Role:** seleziona `ADMIN` o `OWNER`
3. Compila i campi (vedi sotto)
4. Clicca **"Create"**
Il nuovo admin riceverà le credenziali e dovrà cambiare la password al primo accesso.
Il nuovo admin dovrà cambiare la password al primo accesso.
### Campi — Admin User
**Name** *(obbligatorio)*
- Campo: `text`
- Nome e cognome dell'amministratore
- Visibile nella lista admin e nei log di audit
- Esempio: `Mario Rossi`
---
**Email** *(obbligatorio)*
- Campo: `email`
- Indirizzo email usato per accedere al pannello admin
- Deve essere unica — non è possibile avere due admin con la stessa email
- Esempio: `mario.rossi@negozio.it`
---
**Role** *(obbligatorio)*
- Campo: `select`
- Seleziona il livello di accesso:
| Ruolo | Cosa può fare |
|-------|--------------|
| `ADMIN` | Gestire prodotti, ordini, clienti, recensioni, categorie, tipi prodotto |
| `OWNER` | Tutto ciò che può fare ADMIN, più: gestire altri admin, modificare le impostazioni di sistema |
> Assegna `OWNER` solo a persone di fiducia — può modificare impostazioni critiche e creare/eliminare altri admin.
---
**Password** *(obbligatorio)*
- Campo: `password`
- Password temporanea assegnata al nuovo admin
- Deve rispettare i requisiti di sicurezza: minimo 12 caratteri, almeno una maiuscola, una minuscola, un numero e un simbolo
- Il sistema chiederà di cambiarla al primo accesso
- Esempio sicuro: `Temp#2026Admin!`
### Eliminare un admin
@@ -569,16 +662,6 @@ Clicca **"Delete"** accanto all'admin da rimuovere.
Configura le impostazioni globali del negozio.
### Campi configurabili
| Campo | Descrizione | Esempio |
|-------|-------------|---------|
| **Site Name** | Nome del negozio | `Il Mio Negozio` |
| **Site Description** | Descrizione breve | `Il miglior ecommerce italiano` |
| **Support Email** | Email di contatto per i clienti | `support@mionegozio.it` |
| **Currency** | Valuta di default | `EUR` |
| **Tax Rate** | Aliquota IVA in percentuale | `22` (per il 22%) |
### Come modificare
1. Vai su http://localhost/admin/settings
@@ -587,6 +670,90 @@ Configura le impostazioni globali del negozio.
Le modifiche hanno effetto immediato su tutto il negozio.
### Campi — Impostazioni generali
**Site Name**
- Campo: `text`
- Il nome del negozio, mostrato nel titolo del browser, nelle email ai clienti e nel footer
- Esempio: `Il Mio Negozio`, `ShopX Italia`
- Tienilo breve e riconoscibile
---
**Site Description**
- Campo: `text`
- Breve descrizione del negozio, usata nei meta tag per i motori di ricerca (SEO)
- Esempio: `Il miglior ecommerce di abbigliamento sportivo italiano`
- Consigliato: massimo 160 caratteri
---
**Support Email**
- Campo: `email`
- Indirizzo email mostrato ai clienti per il supporto (es. nella pagina contatti, nelle email di conferma ordine)
- Esempio: `supporto@mionegozio.it`
- Assicurati che sia una casella monitorata
---
**Default Currency**
- Campo: `text`
- Valuta usata di default per i nuovi prodotti e per il negozio
- Valori accettati: `EUR`, `USD`, `GBP` (codice ISO 4217 a 3 lettere)
- Esempio: `EUR`
- **Attenzione:** cambiare la valuta dopo aver creato prodotti non converte automaticamente i prezzi esistenti
---
**Tax Rate (%)**
- Campo: `number`
- Aliquota IVA applicata agli ordini, espressa in percentuale
- Esempi:
- `22` → IVA italiana al 22%
- `10` → IVA ridotta al 10%
- `0` → nessuna tassa applicata
- Il valore viene mostrato nel riepilogo dell'ordine al checkout
---
### Campi — Footer
**Testo copyright**
- Campo: `text`
- Testo mostrato nel footer del negozio, solitamente l'indicazione del copyright
- Esempio: `© 2026 Il Mio Negozio. Tutti i diritti riservati.`
- Lascia vuoto per non mostrare nulla
---
**Link footer** (Footer Links)
- Campo: `textarea` con formato JSON
- Lista di link mostrati nel footer (es. Privacy Policy, Termini, Contatti)
- Formato: array JSON di oggetti con `label` (testo del link) e `url` (destinazione)
- Esempio:
```json
[
{"label": "Privacy Policy", "url": "/privacy"},
{"label": "Termini e Condizioni", "url": "/termini"},
{"label": "Contatti", "url": "/contatti"}
]
```
- Lascia `[]` per non mostrare link nel footer
- Gli URL possono essere relativi (`/privacy`) o assoluti (`https://...`)
---
### Campi — Branding
**Favicon**
- Campo: `file upload`
- Icona del negozio mostrata nel tab del browser e nei preferiti
- Formati accettati: PNG, ICO, SVG, JPEG, WebP
- Dimensione massima: 1 MB
- Dimensione consigliata: **32×32 px** o **64×64 px** (quadrata)
- Come caricare: clicca **"Scegli file"**, seleziona l'immagine, clicca **"Upload Favicon"**
- La favicon viene aggiornata immediatamente su tutte le pagine
---
## 11. Flusso Consigliato per Iniziare
+95
View File
@@ -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.
+29
View File
@@ -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
View File
@@ -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
+81
View File
@@ -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')
})
})
+60
View File
@@ -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')
})
})
+53
View File
@@ -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',
},
},
}
+27
View File
@@ -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,
}
+119
View File
@@ -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')
})
})
+107
View File
@@ -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)
})
})
+31
View File
@@ -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(),
}))
+15
View File
@@ -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"]
}
+222
View File
@@ -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' } })
})
})
+109
View File
@@ -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/)
})
})
+71
View File
@@ -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() }) })
)
})
})
+126
View File
@@ -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'))
})
})
+329
View File
@@ -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)
}
})
})
+36
View File
@@ -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',
},
})