Compare commits

...
Sign in to create a new pull request.

6 commits

Author SHA1 Message Date
Imugiii
2f98168182 feat(borne): produit/menu en rupture stock non commandable (RG-T21)
All checks were successful
CI / secret-scan (push) Successful in 12s
CI / php-lint (push) Successful in 26s
CI / secret-scan (pull_request) Successful in 13s
CI / php-lint (pull_request) Successful in 25s
CI / static-tests (pull_request) Successful in 52s
CI / static-tests (push) Successful in 57s
CI / js-tests (push) Successful in 32s
CI / js-tests (pull_request) Successful in 31s
La rupture calculee (autoUnavailableIds) etait deja derivee mais pas
appliquee au parcours de commande. Desormais :

- CatalogueController expose is_orderable par produit/menu (menu = burger
  impose seul), en croisant le catalogue avec autoUnavailableIds en une
  requete (pas de N+1). La borne (data.js -> commandable) grise la tuile +
  badge "Indisponible" et bloque le clic (page-products.js + CSS).
- Garde SERVEUR a la creation de commande (OrderRepository::resolveLine) :
  un produit, ou le burger d'un menu, en rupture est refuse quel que soit
  le canal, y compris par acces direct ou repli sans-JS. C'est la couche
  qui fait foi ; le grisage borne n'est qu'un echo UX.

Tests : CatalogueControllerTest (is_orderable liste+detail, produits+menus),
OrderRepositoryTest (refus a la commande produit + menu burger), data.test
(commandable). Doubles desambiguises (autoUnavailableIds vs composition).
PHPStan L6 propre.
2026-06-24 09:20:40 +00:00
411b04d548 feat(borne): menu Maxi agrandit la boisson en 50cl + transport du format (#98)
All checks were successful
CI / secret-scan (push) Successful in 13s
CI / php-lint (push) Successful in 33s
CI / static-tests (push) Successful in 1m5s
CI / js-tests (push) Successful in 35s
2026-06-24 11:04:20 +02:00
8e2e0382ba fix(devops): passer les variables SMTP/MAIL au conteneur wakdo-app (#97)
All checks were successful
CI / secret-scan (push) Successful in 11s
CI / php-lint (push) Successful in 27s
CI / static-tests (push) Successful in 58s
CI / js-tests (push) Successful in 28s
2026-06-23 16:11:31 +02:00
ef71101453 feat(auth): envoi reel de l'email de reset via relais SMTP (Brevo) (#96)
All checks were successful
CI / secret-scan (push) Successful in 14s
CI / php-lint (push) Successful in 28s
CI / static-tests (push) Successful in 1m3s
CI / js-tests (push) Successful in 1m29s
2026-06-23 15:34:27 +02:00
80b8272291 chore(devops): modeles versionnes docker-compose.prod.yml + .env de prod (#95)
All checks were successful
CI / secret-scan (push) Successful in 12s
CI / php-lint (push) Successful in 25s
CI / static-tests (push) Successful in 58s
CI / js-tests (push) Successful in 36s
2026-06-23 15:01:02 +02:00
8c5d942de8 feat(devops): CD push-based vers Vision (prod) + preuve de version (#94)
All checks were successful
CI / secret-scan (push) Successful in 14s
CI / php-lint (push) Successful in 24s
CI / static-tests (push) Successful in 1m4s
CI / js-tests (push) Successful in 35s
2026-06-23 11:32:57 +02:00
36 changed files with 1565 additions and 42 deletions

View file

@ -131,3 +131,16 @@ CRON_TIMEZONE=Europe/Paris
# Nom du reseau Docker externe partage avec le Traefik de l'hote (doit exister
# AVANT le up : cree par la stack Traefik, ou `docker network create <nom>`).
REVERSE_PROXY_NETWORK=admin_proxy
# ===================================================================
# Envoi d'email (reinitialisation mot de passe) - OPTIONNEL
# ===================================================================
# Absentes en local : l'app journalise le lien de reset (LogMailer), aucun envoi.
# Renseigner SMTP_HOST + SMTP_USER + SMTP_PASSWORD active l'envoi via relais SMTP.
# Mettre les vraies valeurs uniquement dans le .env de l'hote (jamais versionnees).
# SMTP_HOST=smtp-relay.brevo.com
# SMTP_PORT=587
# SMTP_USER=
# SMTP_PASSWORD=
# MAIL_FROM_EMAIL=noreply@example.com
# MAIL_FROM_NAME=Wakdo

75
.env.prod.example Normal file
View file

@ -0,0 +1,75 @@
# Modele de configuration de PRODUCTION (derriere Traefik).
#
# cp .env.prod.example .env
# puis renseigner les lignes <REMPLIR> (domaines, mots de passe, reseau Traefik).
#
# Difference avec .env.example (dev) : APP_ENV=prod, APP_DEBUG=false, URLs en HTTPS,
# mots de passe forts, REVERSE_PROXY_NETWORK renseigne.
APP_ENV=prod
APP_DEBUG=false
APP_TIMEZONE=Europe/Paris
# Domaines publics (doivent resoudre en DNS vers l'hote de prod).
APP_HOST_KIOSK=<REMPLIR-domaine-borne>
APP_HOST_ADMIN=<REMPLIR-domaine-admin>
APP_URL_KIOSK=https://<REMPLIR-domaine-borne>
APP_URL_ADMIN=https://<REMPLIR-domaine-admin>
# Base de donnees : mots de passe FORTS en prod (openssl rand -base64 24).
DB_HOST=wakdo-db
DB_PORT=3306
DB_NAME=wakdo
DB_USER=wakdo
DB_PASSWORD=<REMPLIR-mot-de-passe-fort>
DB_ROOT_PASSWORD=<REMPLIR-autre-mot-de-passe-fort>
SESSION_LIFETIME_IDLE=14400
SESSION_LIFETIME_ABSOLUTE=36000
SESSION_NAME=WAKDO_SID
# Doit correspondre EXACTEMENT a APP_URL_KIOSK (pas de wildcard).
CORS_ALLOWED_ORIGIN=https://<REMPLIR-domaine-borne>
ARGON2_MEMORY_COST=65536
ARGON2_TIME_COST=4
ARGON2_THREADS=1
ACCOUNT_LOCKOUT_THRESHOLD=5
ACCOUNT_LOCKOUT_BASE_SECONDS=60
ACCOUNT_LOCKOUT_MAX_SECONDS=900
IP_THROTTLE_WINDOW_SECONDS=900
IP_THROTTLE_MAX_ATTEMPTS=20
STAFF_PIN_MIN_LENGTH=4
STAFF_PIN_MAX_LENGTH=12
PIN_THROTTLE_THRESHOLD=5
PIN_THROTTLE_BASE_SECONDS=30
PIN_THROTTLE_MAX_SECONDS=300
PIN_THROTTLE_WINDOW_SECONDS=900
PASSWORD_RESET_TTL=3600
AUDIT_LOG_RETENTION_DAYS=365
THROTTLE_PURGE_AFTER_HOURS=24
ORDER_RETENTION_DAYS=1095
UPLOAD_MAX_SIZE_MB=5
UPLOAD_ALLOWED_MIME=image/jpeg,image/png,image/webp
CRON_TIMEZONE=Europe/Paris
# Nom du reseau Docker externe du Traefik de l'hote (doit exister avant le up).
REVERSE_PROXY_NETWORK=<REMPLIR-reseau-traefik>
# ===================================================================
# Envoi d'email (reinitialisation mot de passe) - relais SMTP
# ===================================================================
# Si SMTP_HOST + SMTP_USER + SMTP_PASSWORD sont presents, l'app envoie via le
# relais ; sinon elle se rabat sur le journal (LogMailer). Renseigner ces 3
# valeurs UNIQUEMENT ici (jamais dans le depot). Exemple : relais Brevo.
SMTP_HOST=smtp-relay.brevo.com
SMTP_PORT=587
SMTP_USER=<REMPLIR-login-smtp>
SMTP_PASSWORD=<REMPLIR-cle-smtp-secrete>
MAIL_FROM_EMAIL=noreply@a3n.fr
MAIL_FROM_NAME=Wakdo

View file

@ -0,0 +1,45 @@
name: Deploy
# Deploiement continu (CD) vers Vision (prod) a chaque release sur main.
#
# Topologie : le runner tourne sur Stark (dev) et n'a pas le socket Docker. Il ne
# pilote donc PAS Docker lui-meme : il OUVRE une session SSH vers Vision (prod, hote
# distinct) ou une forced command (cote Vision) lance scripts/deploy.sh. La cle CI ne
# peut ainsi declencher QUE le deploiement, rien d'autre.
#
# main n'est alimentee que par des PR dev->main deja passees par la CI : le code
# deploye a donc deja ete teste. Voir docs/architecture/deployment.md pour la mise en
# place cote Vision (utilisateur deploy, forced command) et les secrets a creer.
on:
push:
branches: [main]
jobs:
deploy:
runs-on: docker
steps:
- name: Install SSH client
run: |
apt-get update -qq
apt-get install -y -qq openssh-client >/dev/null
- name: Deploy to Vision over SSH
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
run: |
set -eu
install -d -m 700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
# Cle d'hote epinglee (pas de TOFU) : la connexion echoue si Vision ne
# presente pas la cle attendue.
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
# Aucune commande passee : la forced command cote Vision lance deploy.sh.
# BatchMode : pas de prompt interactif (un echec d'auth echoue vite au lieu
# de pendre le job) ; ConnectTimeout borne l'attente si Vision est injoignable.
ssh -i ~/.ssh/id_deploy -o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o BatchMode=yes -o ConnectTimeout=15 \
"$DEPLOY_USER@$DEPLOY_HOST"

3
.gitignore vendored
View file

@ -56,6 +56,9 @@ Thumbs.db
*.log
/logs/
# === Marqueur de version (ecrit par scripts/deploy.sh sur l'hote, propre au deploiement) ===
/src/VERSION
# === Data / Uploads / Backups ===
# /var/ : contient /var/backups/ (bind-mount des dumps BDD du conteneur cron)
# et tout futur artefact run-time (caches persistes, logs).

View file

@ -8,10 +8,15 @@
-- domaine commande facture deja par product_id : le flux de commande
-- reste inchange, la borne resout juste la taille choisie en product_id.
--
-- Grouping DEDIE, distinct de maxi_variant_product_id (migration 0006) :
-- ce dernier pilote la substitution Maxi de l'accompagnement de menu
-- (resolveSelections) ; le reutiliser ferait basculer en 50 cl une
-- boisson 30 cl glissee dans un menu Maxi (effet de bord non voulu).
-- Grouping DEDIE (base_product_id), distinct de maxi_variant_product_id
-- (migration 0006) : base_product_id pilote la selection de taille A LA
-- CARTE (picker 30/50 cl) ; maxi_variant_product_id pilote la substitution
-- Maxi en MENU (resolveSelections). Les deux coexistent sur une boisson :
-- le seed 0006 pointe desormais chaque soda 30 cl vers sa variante 50 cl
-- pour qu'un menu Maxi serve la grande boisson (decision metier). Cet
-- "effet" est VOULU et ne s'applique qu'aux selections de menu au format
-- maxi ; une boisson 30 cl commandee a la carte (resolveLine type product)
-- ne consulte jamais maxi_variant_product_id et reste en 30 cl.
-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci.
-- =============================================================================

View file

@ -0,0 +1,50 @@
-- =============================================================================
-- Wakdo — Seed 0006 : boisson de menu = variante 50 cl automatique en Maxi
-- =============================================================================
-- Purpose : cabler la regle metier "boisson Maxi" sur les donnees seedees, sans
-- toucher au code. En menu Maxi, la boisson fontaine doit passer en
-- grande (50 cl), comme l'accompagnement passe en Grande Frite.
--
-- Mecanique reutilisee : product.maxi_variant_product_id (schema 0006),
-- deja exploite par OrderRepository::resolveSelections (substitution de
-- toute selection de menu au format 'maxi', sans garde sur le slot_type).
-- Il suffit donc de POINTER chaque soda fontaine 30 cl vers sa variante
-- 50 cl (creee par le seed 0005) : aucune ligne de code serveur a ecrire.
-- Le decrement de stock (consumption) frappera la 50 cl, et le snapshot
-- de libelle reflechira "<soda> 50cl".
--
-- Perimetre : seules les boissons fontaine ont une variante 50 cl (Coca Cola, Coca
-- Sans Sucres, Fanta Orange, Ice Tea Peche, Ice Tea Citron). Les boissons en
-- bouteille (Eau, Jus d'Orange, Jus de Pommes Bio) n'ont pas de variante : elles
-- restent en taille standard meme en Maxi (degradation gracieuse, modele fast-food
-- usuel). Le surcout Maxi est porte par le menu (price_maxi_cents), pas par la
-- boisson : aucune incidence de prix sur ces bouteilles.
--
-- Phase : depend du schema 0006 (maxi_variant_product_id) ET du seed 0005 (les
-- variantes 50 cl doivent exister). Joue donc APRES 0005 (ordre
-- lexicographique du runner db/seed.sh).
--
-- Conventions:
-- - Aucun id en dur : la cible est resolue structurellement (la variante 50 cl
-- est la ligne dont base_product_id pointe la base et size_cl = 50).
-- - IDEMPOTENT : UPDATE ... JOIN convergent (repositionne la meme valeur a chaque
-- execution). MariaDB autorise le self-join en UPDATE multi-tables (l'erreur
-- 1093 ne vise que les sous-requetes sur la table cible, pas les JOIN).
-- =============================================================================
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- -----------------------------------------------------------------------------
-- Lier chaque boisson de base (30 cl, base_product_id NULL) a sa variante 50 cl.
-- La jointure ne matche que les produits ayant une variante de taille 50 cl :
-- structurellement, les seules boissons fontaine. Les accompagnements (frites,
-- deja relies par 0004) ne sont pas des variantes de taille -> non touches. Les
-- bouteilles sans variante 50 cl ne matchent pas -> maxi_variant_product_id reste
-- NULL.
-- -----------------------------------------------------------------------------
UPDATE product AS base
JOIN product AS variant
ON variant.base_product_id = base.id
AND variant.size_cl = 50
SET base.maxi_variant_product_id = variant.id
WHERE base.base_product_id IS NULL;

View file

@ -0,0 +1,184 @@
# Modele de compose de production (derriere un reverse proxy Traefik).
#
# Entierement pilote par le .env : le meme fichier marche sur n'importe quel hote,
# seules les valeurs du .env changent. Sur l'hote de prod :
# cp docker-compose.prod.yml.example docker-compose.prod.yml
# cp .env.prod.example .env # puis renseigner domaines + mots de passe
# docker compose -f docker-compose.prod.yml up -d --build
#
# Prerequis : le reseau externe ${REVERSE_PROXY_NETWORK} existe (cree par la stack
# Traefik de l'hote). Les entrypoints (websecure) et le certresolver (letsencrypt)
# doivent correspondre a la config Traefik de l'hote.
name: wakdo
networks:
wakdo_internal:
driver: bridge
reverse_proxy:
name: ${REVERSE_PROXY_NETWORK}
external: true
volumes:
wakdo_db_data:
wakdo_uploads:
services:
wakdo-db:
image: mariadb:11.4
container_name: wakdo-db
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_DATABASE: ${DB_NAME}
MARIADB_USER: ${DB_USER}
MARIADB_PASSWORD: ${DB_PASSWORD}
MARIADB_AUTO_UPGRADE: "1"
TZ: ${APP_TIMEZONE:-Europe/Paris}
volumes:
- wakdo_db_data:/var/lib/mysql
- ./db/init:/docker-entrypoint-initdb.d:ro
networks:
- wakdo_internal
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 6
start_period: 30s
wakdo-migrate:
image: mariadb:11.4
container_name: wakdo-migrate
restart: "no"
environment:
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_NAME: ${DB_NAME}
DB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- ./db:/db:ro
networks:
- wakdo_internal
depends_on:
wakdo-db:
condition: service_healthy
entrypoint: ["bash", "/db/migrate-container.sh"]
wakdo-app:
build:
context: ./docker/php-fpm
dockerfile: Dockerfile
container_name: wakdo-app
restart: unless-stopped
environment:
APP_ENV: ${APP_ENV}
APP_DEBUG: ${APP_DEBUG}
APP_TIMEZONE: ${APP_TIMEZONE}
APP_URL_KIOSK: ${APP_URL_KIOSK}
APP_URL_ADMIN: ${APP_URL_ADMIN}
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
SESSION_LIFETIME_IDLE: ${SESSION_LIFETIME_IDLE}
SESSION_LIFETIME_ABSOLUTE: ${SESSION_LIFETIME_ABSOLUTE}
SESSION_NAME: ${SESSION_NAME}
CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN}
ARGON2_MEMORY_COST: ${ARGON2_MEMORY_COST}
ARGON2_TIME_COST: ${ARGON2_TIME_COST}
ARGON2_THREADS: ${ARGON2_THREADS}
ACCOUNT_LOCKOUT_THRESHOLD: ${ACCOUNT_LOCKOUT_THRESHOLD}
ACCOUNT_LOCKOUT_BASE_SECONDS: ${ACCOUNT_LOCKOUT_BASE_SECONDS}
ACCOUNT_LOCKOUT_MAX_SECONDS: ${ACCOUNT_LOCKOUT_MAX_SECONDS}
IP_THROTTLE_WINDOW_SECONDS: ${IP_THROTTLE_WINDOW_SECONDS}
IP_THROTTLE_MAX_ATTEMPTS: ${IP_THROTTLE_MAX_ATTEMPTS}
STAFF_PIN_MIN_LENGTH: ${STAFF_PIN_MIN_LENGTH}
STAFF_PIN_MAX_LENGTH: ${STAFF_PIN_MAX_LENGTH}
PIN_THROTTLE_THRESHOLD: ${PIN_THROTTLE_THRESHOLD}
PIN_THROTTLE_BASE_SECONDS: ${PIN_THROTTLE_BASE_SECONDS}
PIN_THROTTLE_MAX_SECONDS: ${PIN_THROTTLE_MAX_SECONDS}
PIN_THROTTLE_WINDOW_SECONDS: ${PIN_THROTTLE_WINDOW_SECONDS}
PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL}
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB}
UPLOAD_ALLOWED_MIME: ${UPLOAD_ALLOWED_MIME}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
MAIL_FROM_EMAIL: ${MAIL_FROM_EMAIL:-}
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Wakdo}
volumes:
- ./src:/var/www/html
- wakdo_uploads:/var/www/html/public/uploads
networks:
- wakdo_internal
depends_on:
wakdo-migrate:
condition: service_completed_successfully
wakdo-db:
condition: service_healthy
wakdo-web:
build:
context: ./docker/apache
dockerfile: Dockerfile
container_name: wakdo-web
restart: unless-stopped
environment:
APP_HOST_KIOSK: ${APP_HOST_KIOSK}
APP_HOST_ADMIN: ${APP_HOST_ADMIN}
volumes:
- ./src:/var/www/html
- wakdo_uploads:/var/www/html/public/uploads
networks:
- wakdo_internal
- reverse_proxy
depends_on:
wakdo-migrate:
condition: service_completed_successfully
wakdo-app:
condition: service_started
wakdo-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.docker.network=${REVERSE_PROXY_NETWORK}"
- "traefik.http.routers.wakdo-kiosk.rule=Host(`${APP_HOST_KIOSK}`)"
- "traefik.http.routers.wakdo-kiosk.entrypoints=websecure"
- "traefik.http.routers.wakdo-kiosk.tls=true"
- "traefik.http.routers.wakdo-kiosk.tls.certresolver=letsencrypt"
- "traefik.http.routers.wakdo-kiosk.service=wakdo-kiosk-svc"
- "traefik.http.services.wakdo-kiosk-svc.loadbalancer.server.port=80"
- "traefik.http.routers.wakdo-admin.rule=Host(`${APP_HOST_ADMIN}`)"
- "traefik.http.routers.wakdo-admin.entrypoints=websecure"
- "traefik.http.routers.wakdo-admin.tls=true"
- "traefik.http.routers.wakdo-admin.tls.certresolver=letsencrypt"
- "traefik.http.routers.wakdo-admin.service=wakdo-admin-svc"
- "traefik.http.services.wakdo-admin-svc.loadbalancer.server.port=80"
wakdo-cron:
build:
context: ./docker/cron
dockerfile: Dockerfile
container_name: wakdo-cron
restart: unless-stopped
init: true
environment:
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
AUDIT_LOG_RETENTION_DAYS: ${AUDIT_LOG_RETENTION_DAYS:-365}
THROTTLE_PURGE_AFTER_HOURS: ${THROTTLE_PURGE_AFTER_HOURS:-24}
TZ: ${CRON_TIMEZONE:-Europe/Paris}
volumes:
- ./var/backups:/backups
networks:
- wakdo_internal
depends_on:
wakdo-db:
condition: service_healthy

View file

@ -89,6 +89,12 @@ services:
PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL}
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB}
UPLOAD_ALLOWED_MIME: ${UPLOAD_ALLOWED_MIME}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
MAIL_FROM_EMAIL: ${MAIL_FROM_EMAIL:-}
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Wakdo}
volumes:
- ./src:/var/www/html
- wakdo_uploads:/var/www/html/public/uploads

View file

@ -0,0 +1,115 @@
# Deploiement continu (CD) — Wakdo
Ce document decrit le deploiement automatique vers la production et la mise en place
a faire une seule fois cote serveur. Il complete `scripts/deploy.sh` et
`.forgejo/workflows/deploy.yml`.
## Topologie
| Hote | Role |
|---|---|
| **Thanos** (`git.acadenice.com`) | Forge : depot Git + Forgejo Actions |
| **Stark** | Environnement de dev ; heberge le runner Forgejo |
| **Vision** | Production : la stack Wakdo y tourne, cible du deploiement |
Le runner (sur Stark) n'a pas acces au socket Docker, par choix de securite : un job
CI ne peut pas piloter Docker sur son hote. Le deploiement vers Vision se fait donc
par SSH — ce qui correspond au schema normal d'un deploiement vers un hote distant.
## Flux
```
merge dev -> main (release, deja passee par la CI sur la PR)
Forgejo Actions: workflow Deploy (.forgejo/workflows/deploy.yml)
│ ssh deploy@vision (sans commande : forced command cote Vision)
Vision: scripts/deploy.sh (git ff-only -> VERSION + deploy.log -> compose build/up)
GET /api/health renvoie le nouveau SHA ← preuve du deploiement
```
## Ce qui est automatise (dans le depot)
- `.forgejo/workflows/deploy.yml` : sur push `main`, ouvre la session SSH vers Vision.
- `scripts/deploy.sh` : recupere `main` (fast-forward), ecrit le marqueur de version
(`src/VERSION`) et une ligne dans `deploy.log`, reconstruit et recree la stack.
Mode non-interactif via `DEPLOY_YES=1`.
- `GET /api/health` expose `version` (SHA) et `deployed_at` (date), lus depuis
`src/VERSION`.
## Mise en place cote Vision (une fois)
Prerequis : Docker + docker compose, le depot clone (ex. `/srv/wakdo`).
Le compose et le `.env` de prod ne sont pas versionnes (propres a l'hote) ; ils se
derivent des modeles fournis dans le depot :
```bash
cp docker-compose.prod.yml.example docker-compose.prod.yml
cp .env.prod.example .env # puis renseigner domaines + mots de passe + reseau Traefik
docker compose -f docker-compose.prod.yml up -d --build
```
Le compose est entierement pilote par le `.env` : le meme fichier marche sur tout hote.
1. Creer un utilisateur dedie au deploiement, membre du groupe `docker` :
```bash
sudo useradd -m -G docker deploy
```
2. Lui donner le depot (ou ajuster les droits du clone existant) :
```bash
sudo chown -R deploy:deploy /srv/wakdo
```
3. Autoriser la cle CI avec une **forced command** : la cle ne peut lancer que le
deploiement, aucune autre commande. Dans `~deploy/.ssh/authorized_keys` :
```
command="cd /srv/wakdo && DEPLOY_YES=1 scripts/deploy.sh main",no-pty,no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAA...CLE_PUBLIQUE... deploy@wakdo-ci
```
`deploy.sh` ne lit pas `$SSH_ORIGINAL_COMMAND` : meme si un appel SSH tentait de
passer une autre commande, elle serait ignoree.
## Generer la cle et la connaitre cote forge
Sur un poste de confiance :
```bash
ssh-keygen -t ed25519 -f wakdo-deploy -C "deploy@wakdo-ci" -N ""
# wakdo-deploy -> cle PRIVEE (secret de la forge, ci-dessous)
# wakdo-deploy.pub -> cle PUBLIQUE (authorized_keys de Vision, etape 3)
ssh-keyscan -t ed25519 <hote-vision> # -> contenu du secret DEPLOY_KNOWN_HOSTS
```
## Secrets et variables a creer sur la forge
Depot -> Settings -> Actions -> Secrets / Variables :
| Type | Nom | Valeur |
|---|---|---|
| Secret | `DEPLOY_SSH_KEY` | contenu de la cle privee `wakdo-deploy` |
| Secret | `DEPLOY_KNOWN_HOSTS` | sortie de `ssh-keyscan` (cle d'hote de Vision) |
| Secret | `DEPLOY_HOST` | nom/IP de Vision |
| Variable | `DEPLOY_USER` | `deploy` |
## Verification
1. Faire une release (`dev -> main`).
2. Suivre le workflow **Deploy** dans l'interface de la forge (il se declenche au push
sur `main`).
3. Interroger la sonde et lire la version deployee :
```bash
curl -s https://<fqdn-admin-prod>/api/health
# { ... "version": "<sha>", "deployed_at": "<date>" }
```
Le `version` correspond au HEAD de `main` apres la release — preuve que Vision a ete
mise a jour sans intervention manuelle.
## Notes de securite
- Cle SSH dediee au seul deploiement, **forced command** + options `no-*` qui retirent
shell, tunnels et forwarding.
- Cle d'hote **epinglee** (`DEPLOY_KNOWN_HOSTS`, `StrictHostKeyChecking=yes`) : pas de
confiance a la premiere connexion.
- Secrets stockes cote forge, hors du depot. `.env` et `docker-compose.prod.yml`
restent gitignores.
- Le runner n'a pas le socket Docker : un job ne peut pas agir sur Docker localement.

View file

@ -52,25 +52,40 @@ if ! command -v docker >/dev/null 2>&1; then
fi
echo "Deploiement Wakdo : branche '$BRANCH' depuis '$REMOTE' via $COMPOSE_FILE"
printf 'Confirmer le deploiement en production ? [oui/NON] '
read -r answer
if [ "$answer" != "oui" ]; then
echo "deploy: annule."
exit 1
# Mode non-interactif pour le CD : DEPLOY_YES=1 saute la confirmation (la forced
# command SSH le pose). On NE lit PAS $SSH_ORIGINAL_COMMAND : la cle CI ne peut
# influencer ni la branche ni le compose, seulement declencher CE script.
if [ "${DEPLOY_YES:-}" = "1" ] || [ "${DEPLOY_YES:-}" = "oui" ]; then
echo "deploy: confirmation automatique (DEPLOY_YES)."
else
printf 'Confirmer le deploiement en production ? [oui/NON] '
read -r answer
if [ "$answer" != "oui" ]; then
echo "deploy: annule."
exit 1
fi
fi
echo "[1/4] recuperation de '$BRANCH' depuis '$REMOTE' (fast-forward only)"
echo "[1/5] recuperation de '$BRANCH' depuis '$REMOTE' (fast-forward only)"
git fetch --prune "$REMOTE" "$BRANCH"
git checkout "$BRANCH"
git merge --ff-only "$REMOTE/$BRANCH"
echo "[2/4] reconstruction des images depuis les Dockerfiles (--pull rafraichit les bases)"
echo "[2/5] marqueur de version (preuve CD cote app)"
SHA="$(git rev-parse --short HEAD)"
NOW="$(date --iso-8601=seconds)"
# Sous src/ pour etre visible dans le conteneur (mount ./src -> /var/www/html),
# lu a chaud par GET /api/health. Journal d'historique a la racine du depot.
printf '%s %s\n' "$SHA" "$NOW" > src/VERSION
printf '[%s] deploy %s (branche %s)\n' "$NOW" "$SHA" "$BRANCH" >> deploy.log
echo "[3/5] reconstruction des images depuis les Dockerfiles (--pull rafraichit les bases)"
docker compose -f "$COMPOSE_FILE" build --pull
echo "[3/4] demarrage de la stack (migrate + seed idempotents puis app)"
echo "[4/5] demarrage de la stack (migrate + seed idempotents puis app)"
docker compose -f "$COMPOSE_FILE" up -d
echo "[4/4] etat des services"
echo "[5/5] etat des services"
docker compose -f "$COMPOSE_FILE" ps
echo "Deploiement termine."
echo "Deploiement termine ($SHA)."

View file

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use RuntimeException;
/**
* Client SMTP minimal (sans dependance) : ESMTP + STARTTLS + AUTH LOGIN, suffisant
* pour un relais authentifie type Brevo. Conduit la conversation contre un
* SmtpTransport injecte ; chaque etape verifie le code de reponse attendu et leve
* en cas d'ecart. La construction du message est laissee a l'appelant (SmtpMailer).
*/
final class SmtpClient
{
public function __construct(
private readonly SmtpTransport $transport,
private readonly string $heloName = 'wakdo',
) {
}
/**
* Ouvre la session, s'authentifie, transmet un message deja assemble
* (en-tetes + corps, lignes en CRLF, dot-stuffing applique) puis ferme.
*/
public function send(
string $host,
int $port,
string $user,
string $password,
string $from,
string $to,
string $message,
): void {
// Defense en profondeur : un CRLF dans une adresse injecterait une commande
// SMTP (RCPT supplementaire) ou un en-tete. On refuse avant toute connexion.
$this->assertNoInjection($from, 'expediteur');
$this->assertNoInjection($to, 'destinataire');
$t = $this->transport;
try {
$t->open($host, $port, 15);
$this->expect($t->readReply(), 220, 'greeting');
$this->command('EHLO ' . $this->heloName, 250, 'EHLO');
$this->command('STARTTLS', 220, 'STARTTLS');
$t->enableCrypto();
// Re-EHLO obligatoire apres bascule TLS (la session repart de zero).
$this->command('EHLO ' . $this->heloName, 250, 'EHLO TLS');
$this->command('AUTH LOGIN', 334, 'AUTH LOGIN');
$this->command(base64_encode($user), 334, 'AUTH user');
$this->command(base64_encode($password), 235, 'AUTH password');
$this->command('MAIL FROM:<' . $from . '>', 250, 'MAIL FROM');
$this->command('RCPT TO:<' . $to . '>', 250, 'RCPT TO');
$this->command('DATA', 354, 'DATA');
// Corps + terminateur "<CRLF>.<CRLF>".
$t->write($message . "\r\n.\r\n");
$this->expect($t->readReply(), 250, 'corps du message');
$t->write("QUIT\r\n");
// La fermeture (221) n'est pas bloquante : le message est deja accepte.
$t->readReply();
} finally {
$t->close();
}
}
private function command(string $line, int $expected, string $stage): void
{
$this->transport->write($line . "\r\n");
$this->expect($this->transport->readReply(), $expected, $stage);
}
private function assertNoInjection(string $address, string $label): void
{
if (preg_match('/[\r\n]/', $address) === 1) {
throw new RuntimeException(
sprintf('SMTP : adresse %s invalide (saut de ligne interdit)', $label),
);
}
}
private function expect(string $reply, int $code, string $stage): void
{
$got = (int) substr(ltrim($reply), 0, 3);
if ($got !== $code) {
// On ne journalise pas le corps : il peut contenir le lien de reset.
throw new RuntimeException(
sprintf('SMTP %s : attendu %d, recu "%s"', $stage, $code, trim($reply)),
);
}
}
}

101
src/app/Auth/SmtpMailer.php Normal file
View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use RuntimeException;
/**
* Mailer SMTP reel (relais authentifie type Brevo). Implemente l'interface Mailer
* a la place de LogMailer quand le SMTP est configure (voir PasswordResetController).
* Assemble un message texte/plain UTF-8 conforme puis delegue l'envoi a SmtpClient.
*/
final class SmtpMailer implements Mailer
{
public function __construct(
private readonly SmtpClient $client,
private readonly string $host,
private readonly int $port,
private readonly string $user,
private readonly string $password,
private readonly string $fromEmail,
private readonly string $fromName,
) {
}
public function sendPasswordReset(string $email, string $resetUrl): void
{
// Garde destinataire : une adresse valide ne contient ni CRLF ni structure
// d'injection (verrou en plus de la garde transport de SmtpClient).
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
throw new RuntimeException('SmtpMailer : adresse destinataire invalide');
}
$subject = 'Reinitialisation de votre mot de passe Wakdo';
$body = "Bonjour,\r\n\r\n"
. "Une reinitialisation de mot de passe a ete demandee pour ce compte.\r\n"
. "Pour definir un nouveau mot de passe, ouvrez ce lien :\r\n\r\n"
. $resetUrl . "\r\n\r\n"
. "Ce lien expire rapidement. Si vous n'etes pas a l'origine de la demande, "
. "ignorez cet email.\r\n";
$message = $this->buildMessage($email, $subject, $body);
$this->client->send(
$this->host,
$this->port,
$this->user,
$this->password,
$this->fromEmail,
$email,
$message,
);
}
/** Assemble en-tetes + corps en CRLF, avec dot-stuffing pour la phase DATA. */
private function buildMessage(string $to, string $subject, string $body): string
{
$headers = [
'From: ' . $this->encodeHeader($this->fromName) . ' <' . $this->fromEmail . '>',
'To: <' . $to . '>',
'Subject: ' . $this->encodeHeader($subject),
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: 8bit',
];
$raw = implode("\r\n", $headers) . "\r\n\r\n" . $this->normalizeEol($body);
return $this->dotStuff($raw);
}
/** RFC 2047 (encoded-word base64) si la valeur sort de l'ASCII imprimable. */
private function encodeHeader(string $value): string
{
if (preg_match('/^[\x20-\x7E]*$/', $value) === 1) {
return $value;
}
return '=?UTF-8?B?' . base64_encode($value) . '?=';
}
/** Normalise toutes les fins de ligne en CRLF (LF ou CR isoles -> CRLF). */
private function normalizeEol(string $text): string
{
return (string) preg_replace('/\r\n|\r|\n/', "\r\n", $text);
}
/** Double un point en debut de ligne (RFC 5321 transparency). */
private function dotStuff(string $message): string
{
$lines = explode("\r\n", $message);
foreach ($lines as $i => $line) {
if (isset($line[0]) && $line[0] === '.') {
$lines[$i] = '.' . $line;
}
}
return implode("\r\n", $lines);
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Auth;
/**
* Couche transport d'une session SMTP : abstrait le socket reel pour que la
* logique du protocole (SmtpClient) soit testable sans reseau (double en test).
*/
interface SmtpTransport
{
public function open(string $host, int $port, int $timeoutSeconds): void;
/** Ecrit exactement $raw sur la connexion (CRLF inclus par l'appelant). */
public function write(string $raw): void;
/**
* Lit une reponse SMTP complete. Gere le multiligne (RFC 5321 : les lignes
* de continuation ont un '-' en 4e position, la derniere un espace).
*/
public function readReply(): string;
/** Bascule la connexion en TLS (apres STARTTLS). */
public function enableCrypto(): void;
public function close(): void;
}

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use RuntimeException;
/**
* Transport SMTP reel sur socket TCP (stream_socket_client + STARTTLS). Aucune
* dependance externe. Non teste unitairement (effet de bord reseau) : la logique
* du protocole est couverte via SmtpClient + un transport double.
*/
final class StreamSmtpTransport implements SmtpTransport
{
/** @var resource|null */
private $stream = null;
public function open(string $host, int $port, int $timeoutSeconds): void
{
$errno = 0;
$errstr = '';
$stream = @stream_socket_client(
sprintf('tcp://%s:%d', $host, $port),
$errno,
$errstr,
$timeoutSeconds,
);
if ($stream === false) {
throw new RuntimeException(sprintf('SMTP : connexion echouee (%s)', $errstr));
}
stream_set_timeout($stream, $timeoutSeconds);
$this->stream = $stream;
}
public function write(string $raw): void
{
fwrite($this->requireStream(), $raw);
}
public function readReply(): string
{
$stream = $this->requireStream();
$data = '';
$lines = 0;
while (($line = fgets($stream, 515)) !== false) {
$data .= $line;
// Bornes anti-boucle sur reponse malformee (ni ligne finale, ni EOF).
if (++$lines > 100 || strlen($data) > 65536) {
break;
}
// Continuation UNIQUEMENT si '-' en 4e position ; toute autre ligne
// (y compris trop courte) termine la reponse.
if (!(strlen($line) >= 4 && $line[3] === '-')) {
break;
}
}
if ($data === '') {
throw new RuntimeException('SMTP : aucune reponse du serveur');
}
return $data;
}
public function enableCrypto(): void
{
if (!stream_socket_enable_crypto($this->requireStream(), true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new RuntimeException('SMTP : echec de la negociation TLS (STARTTLS)');
}
}
public function close(): void
{
if (is_resource($this->stream)) {
fclose($this->stream);
}
$this->stream = null;
}
/** @return resource */
private function requireStream()
{
if (!is_resource($this->stream)) {
throw new RuntimeException('SMTP : transport non ouvert');
}
return $this->stream;
}
}

View file

@ -63,8 +63,9 @@ final class MenuRepository
* disponibles (is_available = 1) ET en categorie active (c.is_active = 1).
* Projection enrichie (description, image_path) absente de all() back-office.
* Liste LEGERE : sans les slots (le detail /api/menus/{id} les porte). La
* disponibilite du burger impose (B1) reste un raffinement de la dispo calculee
* RG-T21, differe au seed des recettes.
* disponibilite du burger impose (B1, RG-T21) est calculee par CatalogueController
* (croisement avec ProductRepository::autoUnavailableIds) et exposee en is_orderable :
* un menu dont le burger est en rupture est grise par la borne (granularite burger seul).
*
* @return array<int, array<string, mixed>>
*/

View file

@ -63,8 +63,10 @@ final class ProductRepository
* (c.is_active = 1), pour ne jamais proposer un produit dont l'onglet de
* categorie n'apparait pas. vat_rate n'est pas selectionne : le calcul fiscal
* vit cote serveur a la commande, la borne ne l'affiche pas. Filtre de
* disponibilite = flag is_available ; la dispo CALCULEE RG-T21 (exclusion des
* ruptures auto via autoUnavailableIds) se branchera au seed des recettes.
* disponibilite = flag is_available (retrait manuel) ; la dispo CALCULEE RG-T21
* (rupture par stock) n'exclut PAS la ligne ici : CatalogueController la croise
* avec autoUnavailableIds() pour exposer is_orderable, et la borne grise la tuile
* (visible mais non commandable) au lieu de la masquer.
*
* base_product_id IS NULL (R4) : les VARIANTES de taille (ex. "Coca Cola 50cl")
* ne sont jamais des tuiles catalogue autonomes ; elles sont atteintes via le

View file

@ -56,8 +56,16 @@ class CatalogueController extends Controller
// requete (sizesByBase), pas une par produit -> /api/products reste un seul
// aller-retour cache-friendly cote borne (data.js memoise la liste).
$sizesByBase = $repo->sizesByBase();
// RG-T21 : rupture calculee par le stock, en UNE requete (set d'ids). Un
// produit liste (is_available=1) mais en rupture devient non commandable ->
// la borne le grise au lieu de laisser composer une commande vouee a echouer.
$unavailable = array_fill_keys($repo->autoUnavailableIds(), true);
$rows = array_map(
fn (array $row): array => $this->presentProduct($row, $sizesByBase[(int) ($row['id'] ?? 0)] ?? []),
fn (array $row): array => $this->presentProduct(
$row,
$sizesByBase[(int) ($row['id'] ?? 0)] ?? [],
!isset($unavailable[(int) ($row['id'] ?? 0)]),
),
$repo->availableForCatalogue(),
);
@ -84,8 +92,10 @@ class CatalogueController extends Controller
// au moins une VARIANTE (sinon sizesForProduct ne remonte que la base, et la
// base seule n'est pas une dimension de taille -> sizes vide cote presentation).
$sizes = $repo->sizesForProduct($id);
// RG-T21 : meme dispo calculee qu'en liste, pour ce produit (membership du set).
$orderable = !in_array($id, $repo->autoUnavailableIds(), true);
return $this->json(['data' => $this->presentProduct($row, count($sizes) > 1 ? $sizes : [])]);
return $this->json(['data' => $this->presentProduct($row, count($sizes) > 1 ? $sizes : [], $orderable)]);
}
/**
@ -93,8 +103,15 @@ class CatalogueController extends Controller
*/
public function menus(array $params = []): Response
{
// RG-T21 (granularite : burger impose seul) : un menu dont le burger principal
// est en rupture calculee n'est plus commandable. Set d'ids produits en rupture
// reutilise pour tous les menus (pas de N+1).
$unavailable = array_fill_keys($this->productsRepo()->autoUnavailableIds(), true);
$rows = array_map(
fn (array $row): array => $this->presentMenu($row),
fn (array $row): array => $this->presentMenu(
$row,
!isset($unavailable[(int) ($row['burger_product_id'] ?? 0)]),
),
$this->menusRepo()->availableForCatalogue(),
);
@ -117,8 +134,10 @@ class CatalogueController extends Controller
);
}
// RG-T21 (burger impose seul) : dispo calculee du menu = burger non en rupture.
$orderable = !in_array((int) ($row['burger_product_id'] ?? 0), $this->productsRepo()->autoUnavailableIds(), true);
// Detail = menu + ses slots de composition (B1 burger impose, B2 Normal/Maxi).
$menu = $this->presentMenu($row) + ['slots' => $this->presentSlots($repo->slotsWithOptions($id))];
$menu = $this->presentMenu($row, $orderable) + ['slots' => $this->presentSlots($repo->slotsWithOptions($id))];
return $this->json(['data' => $menu]);
}
@ -200,9 +219,9 @@ class CatalogueController extends Controller
* variantes ; vide si le produit n'a pas de dimension taille. Chaque entree
* devient {product_id, size_cl, price_cents, label} ; le label humain est
* derive du volume ("30 cl") -- aucun slug/enum ne fuit a l'ecran.
* @return array{id: int, category_id: int, name: string, description: ?string, price_cents: int, image_path: ?string, display_order: int, maxi_variant_name: ?string, sizes: list<array{product_id: int, size_cl: int, price_cents: int, label: string}>}
* @return array{id: int, category_id: int, name: string, description: ?string, price_cents: int, image_path: ?string, display_order: int, maxi_variant_name: ?string, sizes: list<array{product_id: int, size_cl: int, price_cents: int, label: string}>, is_orderable: bool}
*/
private function presentProduct(array $row, array $sizes = []): array
private function presentProduct(array $row, array $sizes = [], bool $isOrderable = true): array
{
return [
'id' => (int) ($row['id'] ?? 0),
@ -229,14 +248,19 @@ class CatalogueController extends Controller
},
array_values($sizes),
),
// is_orderable : false si rupture calculee par le stock (RG-T21). La borne
// grise la tuile (echo UX) ; l'enforcement qui fait foi est cote serveur a la
// creation de commande (OrderRepository::resolveLine refuse un item en
// rupture). Le retrait manuel (is_available=0) est deja exclu en amont.
'is_orderable' => $isOrderable,
];
}
/**
* @param array<string, mixed> $row
* @return array{id: int, category_id: int, burger_product_id: int, name: string, description: ?string, price_normal_cents: int, price_maxi_cents: int, image_path: ?string, display_order: int}
* @return array{id: int, category_id: int, burger_product_id: int, name: string, description: ?string, price_normal_cents: int, price_maxi_cents: int, image_path: ?string, display_order: int, is_orderable: bool}
*/
private function presentMenu(array $row): array
private function presentMenu(array $row, bool $isOrderable = true): array
{
return [
'id' => (int) ($row['id'] ?? 0),
@ -248,6 +272,9 @@ class CatalogueController extends Controller
'price_maxi_cents' => (int) ($row['price_maxi_cents'] ?? 0),
'image_path' => $this->nullableString($row['image_path'] ?? null),
'display_order' => (int) ($row['display_order'] ?? 0),
// is_orderable : false si le burger impose est en rupture calculee (RG-T21,
// granularite burger seul). La borne grise le menu.
'is_orderable' => $isOrderable,
];
}

View file

@ -12,9 +12,13 @@ use App\Core\Response;
* Sonde de sante. GET /api/health.
*
* Le comptage des categories prouve la chaine complete (autoloader -> routeur
* -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond.
* -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond. Expose aussi
* la version deployee (SHA + date), ecrite par scripts/deploy.sh : c'est la preuve
* cote app du CD (apres un deploiement, ce champ reflete le nouveau commit).
*
* Non-final : seam de test (la sous-classe redirige versionFilePath sur une fixture).
*/
final class HealthController extends Controller
class HealthController extends Controller
{
/**
* @param array<string, string> $params
@ -35,6 +39,8 @@ final class HealthController extends Controller
$httpStatus = 503;
}
$version = $this->readVersion();
return $this->json(
[
'status' => $dbStatus === 'ok' ? 'ok' : 'degraded',
@ -42,8 +48,45 @@ final class HealthController extends Controller
'php_version' => PHP_VERSION,
'db' => $dbStatus,
'categories' => $categories,
'version' => $version['version'],
'deployed_at' => $version['deployed_at'],
],
$httpStatus,
);
}
/**
* Chemin du marqueur de version. Sous le mount du code (./src -> /var/www/html),
* donc lisible a chaud par l'app sans rebuild.
*/
protected function versionFilePath(): string
{
return dirname(__DIR__, 2) . '/VERSION';
}
/**
* Lit "SHA<espace>date" ecrit par deploy.sh. Absence toleree (dev / avant 1er
* deploiement) : les deux champs retombent a null.
*
* @return array{version: ?string, deployed_at: ?string}
*/
private function readVersion(): array
{
$path = $this->versionFilePath();
if (!is_file($path) || !is_readable($path)) {
return ['version' => null, 'deployed_at' => null];
}
$line = trim((string) @file_get_contents($path));
if ($line === '') {
return ['version' => null, 'deployed_at' => null];
}
$parts = explode(' ', $line, 2);
return [
'version' => $parts[0] !== '' ? $parts[0] : null,
'deployed_at' => isset($parts[1]) && $parts[1] !== '' ? $parts[1] : null,
];
}
}

View file

@ -7,9 +7,13 @@ namespace App\Controllers;
use Throwable;
use App\Auth\Csrf;
use App\Auth\LogMailer;
use App\Auth\Mailer;
use App\Auth\PasswordHasher;
use App\Auth\PasswordResetService;
use App\Auth\SessionManager;
use App\Auth\SmtpClient;
use App\Auth\SmtpMailer;
use App\Auth\StreamSmtpTransport;
use App\Core\Controller;
use App\Core\Response;
@ -124,7 +128,33 @@ class PasswordResetController extends Controller
$this->database,
$this->config,
new PasswordHasher($this->config),
new LogMailer(),
$this->mailer(),
);
}
/**
* SMTP reel si configure (SMTP_HOST + SMTP_USER + SMTP_PASSWORD presents),
* sinon repli sur LogMailer (le lien est journalise, pas d'envoi) : le dev
* reste sans infra mail, la prod envoie via le relais.
*/
protected function mailer(): Mailer
{
$host = $this->config->get('SMTP_HOST');
$user = $this->config->get('SMTP_USER');
$password = $this->config->get('SMTP_PASSWORD');
if ($host === null || $user === null || $password === null) {
return new LogMailer();
}
return new SmtpMailer(
new SmtpClient(new StreamSmtpTransport()),
$host,
(int) ($this->config->get('SMTP_PORT', '587') ?? '587'),
$user,
$password,
$this->config->get('MAIL_FROM_EMAIL', 'noreply@localhost') ?? 'noreply@localhost',
$this->config->get('MAIL_FROM_NAME', 'Wakdo') ?? 'Wakdo',
);
}

View file

@ -184,8 +184,14 @@ class OrderRepository
throw new OrderValidationException('EMPTY_ORDER');
}
// RG-T21 : garde a la creation de commande. Un produit (ou le burger d'un menu)
// en rupture calculee par le stock est REFUSE, quel que soit le canal et meme par
// acces direct (la borne ne fait que griser l'affichage, F2). C'est la couche qui
// fait foi. Set calcule UNE seule fois (pas de N+1 sur les lignes).
$unavailable = array_fill_keys($this->products->autoUnavailableIds(), true);
// Resolution + calcul (lecture seule) AVANT la transaction d'ecriture.
$lines = array_map(fn (array $item): array => $this->resolveLine($item), $items);
$lines = array_map(fn (array $item): array => $this->resolveLine($item, $unavailable), $items);
$totalTtc = 0;
$totalHt = 0;
@ -597,9 +603,10 @@ class OrderRepository
* Resout une ligne (produit ou menu) : lit le catalogue, valide, calcule le prix.
*
* @param array<string, mixed> $item
* @param array<int, bool> $unavailable set des product_id en rupture calculee (RG-T21)
* @return array{item_type:string, product_id:?int, menu_id:?int, format:string, label:string, unit_ttc:int, unit_ht:int, vat_rate:int, quantity:int, selections:list<array{menu_slot_id:int,product_id:int,label:string}>, modifiers:list<array{ingredient_id:int,action:string,extra_price_cents:int}>}
*/
private function resolveLine(array $item): array
private function resolveLine(array $item, array $unavailable = []): array
{
$type = (string) ($item['type'] ?? '');
$quantity = max(1, (int) ($item['quantity'] ?? 1));
@ -610,6 +617,10 @@ class OrderRepository
if ($product === null || (int) ($product['is_available'] ?? 0) !== 1) {
throw new OrderValidationException('PRODUCT_UNAVAILABLE');
}
// RG-T21 : rupture calculee (ingredient requis sous la bande critique).
if (isset($unavailable[(int) $product['id']])) {
throw new OrderValidationException('PRODUCT_UNAVAILABLE');
}
$unitBase = (int) $product['price_cents'];
$vat = (int) $product['vat_rate'];
$modifiers = $this->resolveModifiers($item, (int) $product['id']);
@ -623,6 +634,14 @@ class OrderRepository
if ($menu === null || (int) ($menu['is_available'] ?? 0) !== 1) {
throw new OrderValidationException('MENU_UNAVAILABLE');
}
// RG-T21, granularite "burger impose seul" (coherente avec l'affichage borne
// F2) : si le burger principal est en rupture calculee, le menu n'est pas
// commandable. La rupture d'un accompagnement/boisson requis n'est PAS
// verifiee ici (decision produit : granularite burger seul, a elargir au
// besoin via les produits des slots requis).
if (isset($unavailable[(int) ($menu['burger_product_id'] ?? 0)])) {
throw new OrderValidationException('MENU_UNAVAILABLE');
}
$burger = $this->products->find((int) $menu['burger_product_id']);
$vat = $burger !== null ? (int) $burger['vat_rate'] : 100;
$unitBase = $format === 'maxi' ? (int) $menu['price_maxi_cents'] : (int) $menu['price_normal_cents'];

View file

@ -676,6 +676,35 @@ button {
box-shadow: var(--shadow-card);
}
/* Rupture de stock (RG-T21) : tuile visible mais non commandable. Grisee, non
cliquable, sans effet de survol (l'image est attenuee, pas l'etat ne ment pas). */
.product-card--unavailable {
opacity: 0.55;
filter: grayscale(0.6);
cursor: not-allowed;
}
.product-card--unavailable:hover,
.product-card--unavailable:focus-visible {
border-color: var(--color-border-default);
box-shadow: var(--shadow-card);
transform: none;
}
/* Badge "Indisponible" superpose a l'image d'une tuile en rupture. */
.product-card__badge {
position: absolute;
top: var(--space-2);
left: var(--space-2);
z-index: 2;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
background: var(--color-brand-dark);
color: #fff;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
}
.product-card__image-wrap {
width: 100%;
aspect-ratio: 1 / 1;

View file

@ -8,7 +8,8 @@
* Traduction panier borne -> contrat API :
* - produit simple -> { type:'product', product_id, quantity }
* - menu -> { type:'menu', menu_id, quantity, format, selections }
* format = 'maxi' si supplement_cents>0, sinon 'normal'.
* format = cartItem.format (choix Normal/Maxi porte par l'item panier) ; repli
* historique sur supplement_cents>0 pour un panier serialise avant cette version.
* selections = [{menu_slot_id, product_id}] reconstruites depuis la composition
* (accompagnement/boisson/sauce) mappee aux slots reels du menu (re-fetch).
* - service_mode : 'sur-place' -> 'dine_in', 'a-emporter' -> 'takeaway'.
@ -64,7 +65,10 @@ export function buildOrderItem(cartItem, menuSlotsById) {
type: 'menu',
menu_id: cartItem.id,
quantity: cartItem.quantite,
format: (cartItem.supplement_cents ?? 0) > 0 ? 'maxi' : 'normal',
// Format choisi par l'utilisateur, transporte explicitement. Repli sur
// l'ancienne inference (supplement_cents>0) pour un panier serialise en
// sessionStorage avant l'ajout du champ format.
format: cartItem.format ?? ((cartItem.supplement_cents ?? 0) > 0 ? 'maxi' : 'normal'),
selections: buildSelections(cartItem.composition, menuSlotsById[cartItem.id] || []),
};
}

View file

@ -89,12 +89,16 @@ export function loadProducts() {
// en a une, sinon null. Le composeur de menu l'affiche en format Maxi.
maxiNom: p.maxi_variant_name ?? null,
sizes: Array.isArray(p.sizes) ? p.sizes : [],
// commandable : false si rupture de stock calculee (RG-T21, is_orderable
// serveur) -> la borne grise la tuile et bloque le clic. Defaut true si
// l'API ne porte pas le flag (compat).
commandable: p.is_orderable !== false,
});
}
for (const m of menus) {
const slug = slugByCategoryId[m.category_id];
if (slug === undefined) continue;
bySlug[slug].push({ id: m.id, nom: m.name, prix: m.price_normal_cents, image: m.image_path, type: 'menu' });
bySlug[slug].push({ id: m.id, nom: m.name, prix: m.price_normal_cents, image: m.image_path, type: 'menu', commandable: m.is_orderable !== false });
}
return bySlug;
}).catch(e => { _productsPromise = null; throw e; });

View file

@ -121,6 +121,11 @@ export function buildMenuCartItem(menu, model, { size, selections }) {
quantite: 1,
image: menu.image,
supplement_cents: supplement,
// format PORTE le choix Normal/Maxi de l'utilisateur, transporte tel quel
// jusqu'au contrat API. Le serveur l'utilise pour le prix Maxi ET la
// substitution des variantes (accompagnement Grande, boisson 50 cl). A NE
// PAS re-deviner depuis supplement_cents (faux negatif si maxi == normal).
format: isMaxi ? 'maxi' : 'normal',
composition,
};
}

View file

@ -63,10 +63,14 @@ async function renderProducts() {
grid.innerHTML = '';
products.forEach(product => {
// commandable : false = rupture de stock (RG-T21). La tuile reste visible
// (le client voit le produit de la carte) mais grisee et non cliquable.
const orderable = product.commandable !== false;
const card = document.createElement('a');
card.className = 'product-card';
card.className = orderable ? 'product-card' : 'product-card product-card--unavailable';
card.href = `product.html?id=${product.id}&category=${categorySlug}`;
card.setAttribute('aria-label', `${product.nom} - ${formatPrice(product.prix)}`);
card.setAttribute('aria-label', `${product.nom} - ${formatPrice(product.prix)}${orderable ? '' : ' - indisponible'}`);
if (!orderable) card.setAttribute('aria-disabled', 'true');
card.innerHTML = `
<div class="product-card__image-wrap">
@ -77,6 +81,7 @@ async function renderProducts() {
loading="lazy"
onerror="this.src='assets/images/ui/logo.png'; this.alt='Image non disponible';"
>
${orderable ? '' : '<span class="product-card__badge">Indisponible</span>'}
</div>
<div class="product-card__body">
<span class="product-card__name">${escHtml(product.nom)}</span>
@ -91,9 +96,11 @@ async function renderProducts() {
// Clic produit -> modale au-dessus de la grille (paradigme maquette) au lieu
// de naviguer vers product.html : menu -> composeur (L2), produit -> options
// (L3). Le <a href> reste un repli (lien direct / sans JS).
// (L3). Le <a href> reste un repli (lien direct / sans JS). Une tuile en
// rupture ne fait rien (ni navigation ni modale).
card.addEventListener('click', (e) => {
e.preventDefault();
if (!orderable) return;
if (product.type === 'menu') openMenuComposer(product, categorySlug);
else openProductOptions(product, categorySlug);
});

View file

@ -79,6 +79,14 @@ final class FakeCatalogueDatabase implements DatabaseInterface
*/
public array $productSizes = [];
/**
* Lignes {product_id} renvoyees par ProductRepository::autoUnavailableIds()
* (RG-T21 : produits en rupture calculee par le stock). Vide = rien en rupture.
*
* @var list<array<string, mixed>>
*/
public array $autoUnavailableRows = [];
/**
* Trace des lectures pour asserter le court-circuit du detail (id <= 0).
*
@ -109,6 +117,12 @@ final class FakeCatalogueDatabase implements DatabaseInterface
return $this->categoriesRows;
}
// RG-T21 : ids des produits en rupture calculee (autoUnavailableIds). Desambigue
// de composition() (meme table) par SELECT DISTINCT, propre a cette requete.
if (str_contains($sql, 'SELECT DISTINCT pi.product_id')) {
return $this->autoUnavailableRows;
}
// R4 : tailles groupees (sizesByBase) et tailles d'un produit (sizesForProduct).
// Testees avant la branche catalogue : toutes deux lisent FROM product.
if (str_contains($sql, 'AS base_id')) {

View file

@ -28,6 +28,8 @@ final class FakeOrderDatabase implements DatabaseInterface
public array $slotRows = [];
/** @var array<int, list<array<string,mixed>>> recettes (composition) par produit id. */
public array $compositions = [];
/** @var list<array<string,mixed>> ids produits en rupture calculee (autoUnavailableIds, RG-T21). */
public array $autoUnavailableRows = [];
/** Commande existante renvoyee par la recherche idempotency_key ; null = aucune. */
/** @var array<string,mixed>|null */
@ -93,6 +95,11 @@ final class FakeOrderDatabase implements DatabaseInterface
if (str_contains($sql, 'FROM menu_slot s')) {
return $this->slotRows[(int) $params['id']] ?? [];
}
// RG-T21 : autoUnavailableIds() (sans param) AVANT composition() (avec :id) :
// les deux lisent product_ingredient ; on desambiguise sur SELECT DISTINCT.
if (str_contains($sql, 'SELECT DISTINCT pi.product_id')) {
return $this->autoUnavailableRows;
}
if (str_contains($sql, 'FROM product_ingredient pi')) {
return $this->compositions[(int) $params['id']] ?? [];
}

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Tests\Support;
use App\Auth\SmtpTransport;
use RuntimeException;
/**
* Transport SMTP double : rejoue des reponses serveur scriptees et enregistre les
* ecritures du client, pour tester la logique du protocole sans reseau.
*/
final class FakeSmtpTransport implements SmtpTransport
{
/** @var list<string> ce que le client a ecrit, dans l'ordre */
public array $writes = [];
public bool $cryptoEnabled = false;
public bool $closed = false;
public bool $opened = false;
/** @var list<string> reponses a rendre, dans l'ordre des readReply() */
private array $replies;
/** @param list<string> $replies */
public function __construct(array $replies)
{
$this->replies = $replies;
}
public function open(string $host, int $port, int $timeoutSeconds): void
{
$this->opened = true;
}
public function write(string $raw): void
{
$this->writes[] = $raw;
}
public function readReply(): string
{
if ($this->replies === []) {
throw new RuntimeException('FakeSmtpTransport : plus de reponse scriptee');
}
return array_shift($this->replies);
}
public function enableCrypto(): void
{
$this->cryptoEnabled = true;
}
public function close(): void
{
$this->closed = true;
}
/** Concatene toutes les ecritures (pratique pour assertions sur le message). */
public function written(): string
{
return implode('', $this->writes);
}
}

View file

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Auth;
use PHPUnit\Framework\TestCase;
use App\Auth\SmtpClient;
use App\Tests\Support\FakeSmtpTransport;
use RuntimeException;
final class SmtpClientTest extends TestCase
{
/** @return list<string> sequence nominale de reponses serveur */
private function happyReplies(): array
{
return [
"220 smtp.brevo ready\r\n", // greeting
"250-smtp\r\n250 AUTH LOGIN\r\n", // EHLO (multiligne)
"220 go ahead\r\n", // STARTTLS
"250 ok\r\n", // EHLO post-TLS
"334 VXNlcm5hbWU6\r\n", // AUTH LOGIN
"334 UGFzc3dvcmQ6\r\n", // user
"235 authenticated\r\n", // password
"250 ok\r\n", // MAIL FROM
"250 ok\r\n", // RCPT TO
"354 end data with <CRLF>.<CRLF>\r\n", // DATA
"250 queued\r\n", // body
"221 bye\r\n", // QUIT
];
}
public function testNominalConversationAuthenticatesAndSends(): void
{
$t = new FakeSmtpTransport($this->happyReplies());
$client = new SmtpClient($t);
$client->send('smtp-relay.brevo.com', 587, 'user@x', 'secret', 'from@a.fr', 'to@b.fr', "Subject: hi\r\n\r\ncorps");
self::assertTrue($t->opened);
self::assertTrue($t->cryptoEnabled, 'STARTTLS doit basculer le transport en TLS');
self::assertTrue($t->closed, 'le transport doit etre ferme');
$sent = $t->written();
self::assertStringContainsString("STARTTLS\r\n", $sent);
self::assertStringContainsString("AUTH LOGIN\r\n", $sent);
self::assertStringContainsString(base64_encode('user@x') . "\r\n", $sent);
self::assertStringContainsString(base64_encode('secret') . "\r\n", $sent);
self::assertStringContainsString("MAIL FROM:<from@a.fr>\r\n", $sent);
self::assertStringContainsString("RCPT TO:<to@b.fr>\r\n", $sent);
self::assertStringContainsString("DATA\r\n", $sent);
self::assertStringContainsString("\r\n.\r\n", $sent, 'le corps doit finir par le terminateur DATA');
self::assertStringContainsString("QUIT\r\n", $sent);
}
public function testReEhloHappensAfterStarttls(): void
{
$t = new FakeSmtpTransport($this->happyReplies());
(new SmtpClient($t))->send('h', 587, 'u', 'p', 'f@a.fr', 't@b.fr', "x");
// Deux EHLO : un avant STARTTLS, un apres (session repart a zero apres TLS).
$ehloCount = substr_count($t->written(), 'EHLO ');
self::assertSame(2, $ehloCount);
}
public function testRejectedAuthThrowsAndCloses(): void
{
$replies = $this->happyReplies();
$replies[6] = "535 authentication failed\r\n"; // reponse au mot de passe
$t = new FakeSmtpTransport($replies);
$client = new SmtpClient($t);
try {
$client->send('h', 587, 'u', 'bad', 'f@a.fr', 't@b.fr', 'x');
self::fail('une auth refusee doit lever');
} catch (RuntimeException $e) {
self::assertStringContainsString('AUTH password', $e->getMessage());
}
self::assertTrue($t->closed, 'le transport doit etre ferme meme en cas d echec (finally)');
}
public function testUnexpectedGreetingThrows(): void
{
$t = new FakeSmtpTransport(["554 service unavailable\r\n"]);
$this->expectException(RuntimeException::class);
(new SmtpClient($t))->send('h', 587, 'u', 'p', 'f@a.fr', 't@b.fr', 'x');
}
public function testRejectsCrlfInRecipientBeforeConnecting(): void
{
// Tentative d'injection d'une commande RCPT via le destinataire.
$t = new FakeSmtpTransport($this->happyReplies());
$client = new SmtpClient($t);
try {
$client->send('h', 587, 'u', 'p', 'f@a.fr', "t@b.fr>\r\nRCPT TO:<evil@x.com", 'x');
self::fail('un CRLF dans l adresse doit lever');
} catch (RuntimeException $e) {
self::assertStringContainsString('destinataire', $e->getMessage());
}
self::assertFalse($t->opened, 'aucune connexion ne doit s ouvrir si l adresse est invalide');
self::assertSame([], $t->writes, 'rien ne doit etre emis');
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Auth;
use PHPUnit\Framework\TestCase;
use App\Auth\SmtpClient;
use App\Auth\SmtpMailer;
use App\Tests\Support\FakeSmtpTransport;
final class SmtpMailerTest extends TestCase
{
/** @return list<string> sequence nominale de reponses serveur */
private function happyReplies(): array
{
return [
"220 ready\r\n", "250 ok\r\n", "220 go\r\n", "250 ok\r\n",
"334 u\r\n", "334 p\r\n", "235 ok\r\n", "250 ok\r\n", "250 ok\r\n",
"354 data\r\n", "250 queued\r\n", "221 bye\r\n",
];
}
private function mailer(FakeSmtpTransport $t): SmtpMailer
{
return new SmtpMailer(
new SmtpClient($t),
'smtp-relay.brevo.com',
587,
'login@smtp-brevo.com',
'secret',
'noreply@a3n.fr',
'Wakdo',
);
}
public function testBuildsAndSendsResetMessage(): void
{
$t = new FakeSmtpTransport($this->happyReplies());
$this->mailer($t)->sendPasswordReset('client@example.fr', 'https://corentin-wakdo-admin.stark.a3n.fr/reset_password?token=abc');
$sent = $t->written();
self::assertStringContainsString('From: Wakdo <noreply@a3n.fr>', $sent);
self::assertStringContainsString('To: <client@example.fr>', $sent);
self::assertStringContainsString('Subject: Reinitialisation de votre mot de passe Wakdo', $sent);
self::assertStringContainsString('Content-Type: text/plain; charset=UTF-8', $sent);
self::assertStringContainsString('https://corentin-wakdo-admin.stark.a3n.fr/reset_password?token=abc', $sent);
// L'enveloppe SMTP doit porter l'expediteur et le destinataire reels.
self::assertStringContainsString('MAIL FROM:<noreply@a3n.fr>', $sent);
self::assertStringContainsString('RCPT TO:<client@example.fr>', $sent);
}
public function testRejectsInvalidRecipient(): void
{
$t = new FakeSmtpTransport($this->happyReplies());
$this->expectException(\RuntimeException::class);
$this->mailer($t)->sendPasswordReset("victim@x.fr\r\nBcc: evil@x.com", 'https://x/reset?token=t');
}
public function testHeaderAndBodySeparatedByBlankLine(): void
{
$t = new FakeSmtpTransport($this->happyReplies());
$this->mailer($t)->sendPasswordReset('c@e.fr', 'https://x/reset?token=t');
// En-tetes et corps separes par une ligne vide (CRLF CRLF).
self::assertStringContainsString("Content-Transfer-Encoding: 8bit\r\n\r\nBonjour,", $t->written());
}
}

View file

@ -115,7 +115,7 @@ final class CatalogueControllerTest extends TestCase
$product = $payload['data'][0];
self::assertSame(
['id', 'category_id', 'name', 'description', 'price_cents', 'image_path', 'display_order', 'maxi_variant_name', 'sizes'],
['id', 'category_id', 'name', 'description', 'price_cents', 'image_path', 'display_order', 'maxi_variant_name', 'sizes', 'is_orderable'],
array_keys($product),
);
self::assertSame(12, $product['id']);
@ -125,6 +125,41 @@ final class CatalogueControllerTest extends TestCase
self::assertArrayNotHasKey('is_available', $product); // toujours dispo ici -> non expose
self::assertNull($product['maxi_variant_name']); // pas de variante -> null
self::assertSame([], $product['sizes']); // produit mono-taille -> sizes vide
self::assertTrue($product['is_orderable']); // aucune rupture -> commandable
}
public function testProductsMarksAutoUnavailableProductAsNotOrderable(): void
{
// RG-T21 : deux produits listes (is_available=1), mais l'un (id 12) est en
// rupture calculee par le stock -> is_orderable false ; l'autre (id 13) true.
$db = new FakeCatalogueDatabase();
$db->productsRows = [
['id' => '12', 'category_id' => '3', 'name' => 'Cheeseburger', 'description' => null, 'price_cents' => '890', 'image_path' => null, 'display_order' => '1', 'maxi_variant_name' => null],
['id' => '13', 'category_id' => '3', 'name' => 'Hamburger', 'description' => null, 'price_cents' => '790', 'image_path' => null, 'display_order' => '2', 'maxi_variant_name' => null],
];
$db->autoUnavailableRows = [['product_id' => '12']]; // 12 en rupture
$response = $this->controller($db, '/api/products')->products();
self::assertSame(200, $response->status());
$data = $this->decode($response->body())['data'];
self::assertFalse($data[0]['is_orderable']); // 12 en rupture
self::assertTrue($data[1]['is_orderable']); // 13 commandable
}
public function testProductDetailAutoUnavailableIsNotOrderable(): void
{
$db = new FakeCatalogueDatabase();
$db->productRow = [
'id' => '12', 'category_id' => '3', 'name' => 'Cheeseburger',
'description' => null, 'price_cents' => '890', 'image_path' => null, 'display_order' => '1',
];
$db->autoUnavailableRows = [['product_id' => '12']];
$response = $this->controller($db, '/api/products/12')->product(['id' => '12']);
self::assertSame(200, $response->status());
self::assertFalse($this->decode($response->body())['data']['is_orderable']);
}
public function testProductsListExposesMaxiVariantName(): void
@ -291,18 +326,52 @@ final class CatalogueControllerTest extends TestCase
$menu = $payload['data'][0];
self::assertSame(
['id', 'category_id', 'burger_product_id', 'name', 'description', 'price_normal_cents', 'price_maxi_cents', 'image_path', 'display_order'],
['id', 'category_id', 'burger_product_id', 'name', 'description', 'price_normal_cents', 'price_maxi_cents', 'image_path', 'display_order', 'is_orderable'],
array_keys($menu),
);
self::assertSame(1, $menu['id']);
self::assertSame(5, $menu['burger_product_id']);
self::assertSame(990, $menu['price_normal_cents']);
self::assertSame(1190, $menu['price_maxi_cents']);
self::assertTrue($menu['is_orderable']); // burger non en rupture
self::assertArrayNotHasKey('slots', $menu); // liste legere : pas de slots
self::assertArrayNotHasKey('is_available', $menu); // toujours dispo ici
self::assertArrayNotHasKey('vat_rate', $menu);
}
public function testMenuNotOrderableWhenBurgerAutoUnavailable(): void
{
// RG-T21 (granularite burger seul) : le burger impose (id 5) est en rupture
// calculee -> le menu n'est plus commandable, meme s'il est is_available=1.
$db = new FakeCatalogueDatabase();
$db->menusRows = [
['id' => '1', 'category_id' => '1', 'burger_product_id' => '5', 'name' => 'Menu Big', 'description' => null, 'price_normal_cents' => '990', 'price_maxi_cents' => '1190', 'image_path' => null, 'display_order' => '1'],
];
$db->autoUnavailableRows = [['product_id' => '5']]; // burger en rupture
$response = $this->controller($db, '/api/menus')->menus();
self::assertSame(200, $response->status());
self::assertFalse($this->decode($response->body())['data'][0]['is_orderable']);
}
public function testMenuDetailNotOrderableWhenBurgerAutoUnavailable(): void
{
$db = new FakeCatalogueDatabase();
$db->menuRow = [
'id' => '1', 'category_id' => '1', 'burger_product_id' => '5', 'name' => 'Menu Big',
'description' => null, 'price_normal_cents' => '990', 'price_maxi_cents' => '1190',
'image_path' => null, 'display_order' => '1',
];
$db->menuSlotRows = [];
$db->autoUnavailableRows = [['product_id' => '5']];
$response = $this->controller($db, '/api/menus/1')->menu(['id' => '1']);
self::assertSame(200, $response->status());
self::assertFalse($this->decode($response->body())['data']['is_orderable']);
}
public function testMenuDetailReturnsDataWithSlots(): void
{
$db = new FakeCatalogueDatabase();

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Controllers;
use PHPUnit\Framework\TestCase;
use App\Controllers\HealthController;
use App\Core\Config;
use App\Core\Database;
use App\Core\Request;
/**
* Sous-classe de test : pointe le fichier VERSION sur une fixture temporaire,
* pour couvrir l'exposition de la version deployee sans dependre d'un deploiement
* reel (le fichier est ecrit par scripts/deploy.sh sur l'hote, jamais en test).
*/
final class TestHealthController extends HealthController
{
public string $versionPath = '';
protected function versionFilePath(): string
{
return $this->versionPath;
}
}
/**
* La sonde expose la version deployee (SHA + date) pour prouver le CD : apres un
* deploiement, GET /api/health doit refleter le nouveau commit. Le test n'a pas de
* base : l'appel DB echoue et degrade le statut, mais les champs version restent
* presents (ils sont independants de la BDD).
*/
final class HealthControllerTest extends TestCase
{
private function controller(string $versionPath): TestHealthController
{
$request = new Request('GET', '/api/health', [], [], '', '203.0.113.5');
$c = new TestHealthController($request, new Config(), new Database(new Config()));
$c->versionPath = $versionPath;
return $c;
}
public function testExposesDeployedVersionWhenFilePresent(): void
{
$fixture = tempnam(sys_get_temp_dir(), 'wakdo_version_');
file_put_contents($fixture, "3dee190 2026-06-23T14:02:11+02:00\n");
try {
$body = $this->controller($fixture)->index()->body();
$payload = json_decode($body, true);
self::assertSame('3dee190', $payload['version']);
self::assertSame('2026-06-23T14:02:11+02:00', $payload['deployed_at']);
} finally {
@unlink($fixture);
}
}
public function testVersionNullWhenFileAbsent(): void
{
$missing = sys_get_temp_dir() . '/wakdo_version_does_not_exist_' . getmypid();
@unlink($missing);
$body = $this->controller($missing)->index()->body();
$payload = json_decode($body, true);
self::assertNull($payload['version']);
self::assertNull($payload['deployed_at']);
}
}

View file

@ -121,6 +121,86 @@ final class OrderRepositoryTest extends TestCase
self::assertSame(8, $sel['slot']);
}
public function testMenuMaxiSwapsDrinkSelectionToLargeVariant(): void
{
// Au format maxi, la boisson fontaine Coca Cola (variante = Coca Cola 50cl,
// id 15) doit etre persistee comme la 50 cl : meme mecanique que l'accompagnement
// Grande Frite (maxi_variant_product_id), pour que le stock decremente la 50 cl
// et que le snapshot reflete "Coca Cola 50cl". Aucune garde sur le slot_type.
$db = new FakeOrderDatabase();
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 600, 'vat_rate' => 100, 'is_available' => 1];
$db->products[14] = ['id' => 14, 'name' => 'Coca Cola', 'price_cents' => 190, 'vat_rate' => 100, 'is_available' => 1, 'maxi_variant_product_id' => 15];
$db->products[15] = ['id' => 15, 'name' => 'Coca Cola 50cl', 'price_cents' => 240, 'vat_rate' => 100, 'is_available' => 1, 'maxi_variant_product_id' => null];
$db->slotRows[5] = [['id' => 9, 'name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 1, 'product_id' => 14]];
$this->repo($db)->createPending([
'service_mode' => 'takeaway',
'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'maxi',
'selections' => [['menu_slot_id' => 9, 'product_id' => 14]]]], // borne envoie la 30 cl
]);
$sel = $db->firstWrite('INSERT INTO order_item_selection');
self::assertSame(15, $sel['pid']); // swap -> Coca Cola 50cl
self::assertSame('Coca Cola 50cl', $sel['label']);
self::assertSame(9, $sel['slot']);
}
public function testMenuMaxiKeepsBottledDrinkWithoutVariant(): void
{
// Une boisson en bouteille (Eau) n'a pas de variante 50 cl : meme en Maxi la
// selection reste l'Eau de base (degradation gracieuse, modele fast-food).
$db = new FakeOrderDatabase();
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 600, 'vat_rate' => 100, 'is_available' => 1];
$db->products[16] = ['id' => 16, 'name' => 'Eau', 'price_cents' => 150, 'vat_rate' => 100, 'is_available' => 1, 'maxi_variant_product_id' => null];
$db->slotRows[5] = [['id' => 9, 'name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 1, 'product_id' => 16]];
$this->repo($db)->createPending([
'service_mode' => 'takeaway',
'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'maxi',
'selections' => [['menu_slot_id' => 9, 'product_id' => 16]]]],
]);
$sel = $db->firstWrite('INSERT INTO order_item_selection');
self::assertSame(16, $sel['pid']); // pas de variante -> reste l'Eau
self::assertSame('Eau', $sel['label']);
}
public function testProductInStockRuptureRejectedAtOrderCreation(): void
{
// RG-T21 : un produit liste (is_available=1) mais en rupture calculee par le
// stock est REFUSE a la creation de commande (garde serveur load-bearing, pas
// seulement grise sur la borne). Couvre le bypass URL directe / repli sans-JS.
$db = new FakeOrderDatabase();
$db->products[12] = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1];
$db->autoUnavailableRows = [['product_id' => 12]];
$this->expectException(OrderValidationException::class);
$this->expectExceptionMessage('PRODUCT_UNAVAILABLE');
$this->repo($db)->createPending([
'service_mode' => 'takeaway',
'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]],
]);
}
public function testMenuRejectedAtOrderWhenBurgerInStockRupture(): void
{
// RG-T21 (granularite burger seul) : le burger impose en rupture calculee rend
// le menu non commandable cote serveur, meme is_available=1.
$db = new FakeOrderDatabase();
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 600, 'vat_rate' => 100, 'is_available' => 1];
$db->autoUnavailableRows = [['product_id' => 12]];
$this->expectException(OrderValidationException::class);
$this->expectExceptionMessage('MENU_UNAVAILABLE');
$this->repo($db)->createPending([
'service_mode' => 'takeaway',
'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'normal', 'selections' => []]],
]);
}
public function testMenuNormalKeepsBaseSideSelection(): void
{
// Format normal : aucune substitution, l'accompagnement reste la Moyenne

View file

@ -60,6 +60,17 @@ test('buildOrderItem: menu normal vs maxi (format + selections)', () => {
assert.equal(maxi.format, 'maxi');
});
test('buildOrderItem: format explicite prime sur l inference (maxi meme si supplement 0)', () => {
// Le choix utilisateur est transporte dans cartItem.format ; il ne doit PAS etre
// re-devine du prix (un menu maxi == normal serait sinon envoye en normal).
const explicit = { id: 1, type: 'menu', quantite: 1, supplement_cents: 0, format: 'maxi',
composition: { boisson: { id: 14 } } };
assert.equal(buildOrderItem(explicit, { 1: slots() }).format, 'maxi');
// Repli historique : un panier serialise sans champ format infere depuis supplement.
const legacy = { id: 1, type: 'menu', quantite: 1, supplement_cents: 150, composition: {} };
assert.equal(buildOrderItem(legacy, { 1: slots() }).format, 'maxi');
});
/* --- buildOrderPayload --------------------------------------------------- */
test('buildOrderPayload: dine_in inclut service_tag ; takeaway l omet', () => {

View file

@ -71,6 +71,7 @@ test('buildMenuCartItem Normal: prix normal, pas de supplement, taille N, compos
assert.equal(item.type, 'menu');
assert.equal(item.prix_cents, 880);
assert.equal(item.supplement_cents, 0);
assert.equal(item.format, 'normal'); // format explicite transporte
assert.equal(item.composition.burger.libelle, 'Le 280');
// Normal : l'accompagnement garde son nom de base (pas la variante Maxi).
assert.deepEqual(item.composition.accompagnement, { id: 22, libelle: 'Moyenne Frite', taille: 'N' });
@ -83,6 +84,7 @@ test('buildMenuCartItem Maxi: supplement = maxi - normal, taille G sur side/drin
const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } });
assert.equal(item.prix_cents, 880);
assert.equal(item.supplement_cents, 150); // 1030 - 880
assert.equal(item.format, 'maxi'); // format explicite transporte
assert.equal(item.composition.accompagnement.taille, 'G');
assert.equal(item.composition.boisson.taille, 'G');
});
@ -91,10 +93,21 @@ test('buildMenuCartItem Maxi: l accompagnement prend sa variante (Grande Frite),
const m = buildComposerSteps(detail(), byId());
const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } });
assert.equal(item.composition.accompagnement.libelle, 'Grande Frite'); // pas "Moyenne Frite"
// Boisson sans maxiNom : garde son nom de base meme en Maxi (le Maxi ne l agrandit pas).
// Boisson sans maxiNom : garde son nom de base meme en Maxi (cas bouteille).
assert.equal(item.composition.boisson.libelle, 'Coca');
});
test('buildMenuCartItem Maxi: la boisson AVEC variante (50cl) prend son nom agrandi', () => {
// Apres le seed 0006, une boisson fontaine porte maxiNom (ex. "Coca Cola 50cl") :
// en Maxi, le libelle et la taille refletent la grande boisson (meme regle que
// l'accompagnement). Aucune logique borne specifique : maxiNom suffit.
const byIdDrinkVariant = { ...byId(), 14: { id: 14, nom: 'Coca Cola', prix: 0, image: 'c.png', type: 'produit', maxiNom: 'Coca Cola 50cl' } };
const m = buildComposerSteps(detail(), byIdDrinkVariant);
const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } });
assert.equal(item.composition.boisson.libelle, 'Coca Cola 50cl');
assert.equal(item.composition.boisson.taille, 'G');
});
test('buildMenuCartItem Normal: l accompagnement garde "Moyenne Frite" (pas de variante)', () => {
const m = buildComposerSteps(detail(), byId());
const item = buildMenuCartItem(menu, m, { size: 'N', selections: { 1: 14, 16: 22, 31: 47 } });

View file

@ -69,7 +69,8 @@ test('loadProducts groupe les produits par slug a la forme borne (type produit)'
assert.deepEqual(data.burgers, [
// sizes (R4) : tableau vide par defaut quand l'API n'en renvoie pas.
// maxiNom : null par defaut quand l'API n'envoie pas maxi_variant_name.
{ id: 10, nom: 'Big Mac', prix: 600, image: 'assets/images/produits/burgers/bigmac.png', type: 'produit', maxiNom: null, sizes: [] },
// commandable : true par defaut quand l'API n'envoie pas is_orderable.
{ id: 10, nom: 'Big Mac', prix: 600, image: 'assets/images/produits/burgers/bigmac.png', type: 'produit', maxiNom: null, sizes: [], commandable: true },
]);
});
@ -100,10 +101,21 @@ test('loadProducts glisse les menus sous la cle menus (type menu, prix = price_n
const data = await loadProducts();
assert.deepEqual(data.menus, [
{ id: 1, nom: 'Menu Big Mac', prix: 800, image: 'assets/images/produits/burgers/bigmac.png', type: 'menu' },
{ id: 1, nom: 'Menu Big Mac', prix: 800, image: 'assets/images/produits/burgers/bigmac.png', type: 'menu', commandable: true },
]);
});
test('loadProducts: is_orderable=false -> commandable=false (rupture RG-T21)', async () => {
const fx = fixtures();
fx['/api/products'].data[0].is_orderable = false;
fx['/api/menus'].data[0].is_orderable = false;
const { loadProducts } = await freshData(fx);
const data = await loadProducts();
assert.equal(data.burgers[0].commandable, false);
assert.equal(data.menus[0].commandable, false);
});
test('loadProducts consomme bien les trois endpoints /api/*', async () => {
const calls = [];
const { loadProducts } = await freshData(fixtures(), calls);