chore(cleanup): remove orphan code + archive disabled workflows + .playwright-mcp

Triple cleanup, landed together because they share the same cleanup
branch intent and touch non-overlapping trees.

1. 38× tracked .playwright-mcp/*.yml stage-deleted
   MCP session recordings that had been inadvertently committed.
   .gitignore already covers .playwright-mcp/ (post-audit J2 block
   added in d12b901de). Working tree copies removed separately.

2. 19× disabled CI workflows moved to docs/archive/workflows/
   Legacy .yml.disabled files in .github/workflows/ were 1676 LOC of
   dead config (backend-ci, cd, staging-validation, accessibility,
   chromatic, visual-regression, storybook-audit, contract-testing,
   zap-dast, container-scan, semgrep, sast, mutation-testing,
   rust-mutation, load-test-nightly, flaky-report, openapi-lint,
   commitlint, performance). Preserved in docs/archive/workflows/
   for historical reference; `.github/workflows/` now only lists the
   5 actually-running pipelines.

3. Orphan code removed (0 consumers confirmed via grep)
   - veza-backend-api/internal/repository/user_repository.go
     In-memory UserRepository mock, never imported anywhere.
   - proto/chat/chat.proto
     Chat server Rust deleted 2026-02-22 (commit 279a10d31); proto
     file was orphan spec. Chat lives 100% in Go backend now.
   - veza-common/src/types/chat.rs (Conversation, Message, MessageType,
     Attachment, Reaction)
   - veza-common/src/types/websocket.rs (WebSocketMessage,
     PresenceStatus, CallType — depended on chat::MessageType)
   - veza-common/src/types/mod.rs updated: removed `pub mod chat;`,
     `pub mod websocket;`, and their re-exports.
   Only `veza_common::logging` is consumed by veza-stream-server
   (verified with `grep -r "veza_common::"`). `cargo check` on
   veza-common passes post-removal.

Refs: AUDIT_REPORT.md §8.2 "Code mort / orphelin" + §9.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
senke 2026-04-20 20:33:40 +02:00
parent 4310dbb734
commit 172581ff02
48 changed files with 2747 additions and 640 deletions

27
.commitlintrc.json Normal file
View file

@ -0,0 +1,27 @@
{
"extends": ["@commitlint/config-conventional"],
"rules": {
"type-enum": [
2,
"always",
[
"feat",
"fix",
"docs",
"style",
"refactor",
"perf",
"test",
"build",
"ci",
"chore",
"revert",
"security"
]
],
"subject-case": [0],
"header-max-length": [2, "always", 120],
"body-max-line-length": [1, "always", 200],
"footer-max-line-length": [0]
}
}

3
.husky/commit-msg Executable file
View file

@ -0,0 +1,3 @@
#!/usr/bin/env sh
npx --no -- commitlint --edit "$1"

35
.husky/pre-push Executable file
View file

@ -0,0 +1,35 @@
#!/usr/bin/env sh
# ============================================================================
# Veza pre-push hook — CRITICAL E2E SMOKE
# ============================================================================
# Runs only @critical Playwright tests before push (~2-3min).
# SKIP_E2E=1 git push ... # bypass for quick iterations
# ============================================================================
set -e
REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
if [ -n "$SKIP_E2E" ]; then
echo "${YELLOW}▶ SKIP_E2E=1 — skipping critical E2E smoke${NC}"
exit 0
fi
echo "${YELLOW}▶ Running critical E2E smoke tests (Playwright @critical)...${NC}"
echo "${YELLOW} Set SKIP_E2E=1 to bypass (not recommended for shared branches)${NC}"
npm run e2e:critical 2>&1 || {
echo "${RED}✗ Critical E2E tests failed — push blocked${NC}"
echo "${YELLOW} Tip: run 'npm run e2e:critical' locally to debug${NC}"
echo "${YELLOW} Tip: set SKIP_E2E=1 to bypass if you know what you're doing${NC}"
exit 1
}
echo "${GREEN}✓ Critical E2E smoke passed — push allowed${NC}"

15
.pa11yci.json Normal file
View file

@ -0,0 +1,15 @@
{
"defaults": {
"standard": "WCAG2AA",
"timeout": 30000,
"wait": 3000,
"chromeLaunchConfig": {
"args": ["--no-sandbox"]
}
},
"urls": [
"http://localhost:5174/login",
"http://localhost:5174/register",
"http://localhost:5174/discover"
]
}

11
.semgrepignore Normal file
View file

@ -0,0 +1,11 @@
node_modules/
.git/
dist/
storybook-static/
coverage/
*.test.ts
*.test.tsx
*.spec.ts
*_test.go
tests/
loadtests/

2
.zap/rules.tsv Normal file
View file

@ -0,0 +1,2 @@
10011 IGNORE (Cookie Without Secure Flag - dev only)
10054 IGNORE (Cookie Without SameSite Attribute - dev only)
1 10011 IGNORE (Cookie Without Secure Flag - dev only)
2 10054 IGNORE (Cookie Without SameSite Attribute - dev only)

648
AUDIT_REPORT.md Normal file
View file

@ -0,0 +1,648 @@
# AUDIT_REPORT v2 — monorepo Veza
> **Date** : 2026-04-20
> **Branche** : `main` (HEAD = `89a52944e`, `v1.0.7-rc1`)
> **Auditeur** : Claude Code (Opus 4.7 — mode autonome, /effort max, /plan)
> **Méthode** : 5 agents Explore en parallèle (frontend, backend Go, Rust stream, infra/DevOps, dette transverse) + mesures macro directes + lecture `docs/audit-2026-04/v107-plan.md` + `CHANGELOG.md` v1.0.5 → v1.0.7-rc1.
> **Supersede** : [v1 du 2026-04-14](#annexe-diff-v1-v2) (HEAD `45662aad1`, v1.0.0-mvp-24). Depuis : v1.0.4 → v1.0.5 → v1.0.5.1 → v1.0.6 → v1.0.6.1 → v1.0.6.2 → v1.0.7-rc1. 50+ commits. Le v1 est **obsolète** : son "chemin critique v1.0.5 public-ready" a été réalisé intégralement, mais sa liste de hygiène repo (binaires, screenshots, .git 2.3 GB) est **restée en état**.
> **Ton** : brutal, pas de langue de bois. Citations `fichier:ligne`.
---
## 0. TL;DR — ce que je retiens en 12 lignes
1. **Plomberie produit : solide.** v1.0.5 → v1.0.7-rc1 a fermé tout le "chemin critique" fonctionnel : register/verify réels, player fallback `/stream`, refund reverse-charge Hyperswitch, reconciliation sweep, Stripe Connect reversal worker, ledger-health Prometheus gauges, maintenance mode persisté, chat multi-instance avec alarme loud. 50+ commits, **18 findings v1 résolus**. Détail : [FUNCTIONAL_AUDIT.md](FUNCTIONAL_AUDIT.md).
2. **Hygiène repo : catastrophique.** `.git` = **2.3 GB** (inchangé depuis v1). Binaire `api` de **99 MB** encore à la racine (tracked, ELF). 44 fichiers audio `.mp3/.wav` encore dans `veza-backend-api/uploads/`. 48 screenshots PNG à la racine (`dashboard-*.png`, `login-*.png`, `design-system-*.png`, `forgot-password-*.png`). 36 `.playwright-mcp/*.yml` debris de sessions MCP. `CLAUDE_CONTEXT.txt` = **977 KB** à la racine.
3. **`CLAUDE.md` globalement juste** (v1.0.4, 2026-04-14) mais Vite annoncé "5" → réellement **Vite 7.1.5** (`apps/web/package.json`). Axios "déprécié en dev" → réellement `1.13.5` moderne. `docs/ENV_VARIABLES.md` introuvable alors que CLAUDE.md dit "à maintenir".
4. **Frontend** : 1984 fichiers TS/TSX. **36 features** modulaires. Router propre (27 routes top-level, 54 lazy). `src/types/generated/api.ts` = **6550 lignes, régénéré aujourd'hui** — OpenAPI typegen a démarré. **282 occurrences `any`** (dont `services/api/auth.ts:85-100` triple cast token fallback). **6 `console.log` en prod** (checkbox, switch, slider, AdvancedFilters, Onboarding, useLongRunningOperation). 11 composants UI orphelins (`hover-card/*`, `dropdown-menu/*`, `optimized-image/*`). 3.5 MB de dead reports (`e2e-results.json` 3.4 MB, `lint_comprehensive.json` 793 KB, `ts_errors.log` 29 KB).
5. **Backend Go** : 877 fichiers `.go`, **197K LOC**. 27 fichiers routes, 135 handlers, 226 services, 81 modèles, **160 migrations** (jusqu'à `983_`), 17 workers, 11 jobs. **Transactions manquantes** sur paths critiques (marketplace `service.go:1050+`, subscription). **31 instances `context.Background()` dans handlers** → timeout middleware défait. 3 binaires trackés (`api`, `main`, `veza-api`). **Duplicate `RespondWithAppError`** (`response/response.go:101` + `handlers/error_response.go:12`).
6. **Rust stream server** : Axum 0.8 + Tokio 1.35 + Symphonia. HLS ✅ réel, HTTP Range 206 ✅, WebSocket 1047 LOC ✅, adaptive bitrate 515 LOC ✅. **DASH commenté** (`streaming/protocols/mod.rs:4`). **WebRTC commenté** (`Cargo.toml:62`). **`#![allow(dead_code)]` global** au `lib.rs:5` — camoufle les stubs. 0 `unsafe` (engagement CLAUDE.md tenu). **`proto/chat/chat.proto` orphelin** depuis suppression chat Rust (2026-02-22). `veza-common/src/chat/*` types orphelins.
7. **Chat server Rust** : **confirmé absent** (commit `05d02386d`, 2026-02-22). Zéro référence dans k8s (bon). **`proto/chat/*.proto` reste comme spec historique** — à déplacer en `docs/archive/` ou supprimer.
8. **Desktop Electron** : **confirmé absent**. Jamais implémenté. Fossile des docs anciennes.
9. **Docker** : 6 compose files (dev/prod/staging/test/root/`infra/lab.yml` DEPRECATED Feb 2026). **MinIO pinné `:latest` dans 4 composes** → supply-chain risk. ES 8.11.0 uniquement en dev (orphelin ? backend utilise Postgres FTS). Healthchecks partout mais intervals incohérents (5s→30s). **3 variants Dockerfile par service** (base + .dev + .production) — multi-stage, non-root user `app` (uid 1001), `-w -s` stripped. ⚠️ stream-server Dockerfile.production expose `8082` mais `docker-compose.prod.yml:284` healthcheck attend `3001`**mismatch**.
10. **CI/CD** : 5 workflows actifs (`ci.yml` consolidé + `frontend-ci.yml` + `security-scan.yml` gitleaks + `trivy-fs.yml` + `go-fuzz.yml`). **19 workflows disabled, 1676 LOC mort** (`backend-ci.yml.disabled`, `cd.yml.disabled`, `staging-validation.yml.disabled`, `accessibility.yml.disabled`, etc.). E2E **pas déclenché en CI** alors que Playwright existe. Tests integration skipped (`VEZA_SKIP_INTEGRATION=1`) faute de Docker socket.
11. **Sécurité** : JWT RS256 prod / HS256 dev ✅. OAuth (Google/GitHub/Discord/Spotify) ✅. 2FA TOTP ✅. CORS strict en prod ✅. gitleaks + govulncheck + trivy en CI ✅. **Absents** : CSP header, X-Frame-Options (0 grep hit). **.env committé** (`/veza-backend-api/.env`, `-rw-r--r--`). **TLS certs committés** : `/docker/haproxy/certs/veza.pem`, `/config/ssl/{cert,key,veza}.pem`**rotate + BFG needed**.
12. **Verdict monorepo** : **Moyen-Haute dette sur l'hygiène, Faible dette sur le code applicatif**. Le produit fonctionne, la plomberie monétaire est auditée, la sécurité applicative est solide. Mais les items "cleanup" de l'audit v1 n'ont **pas été traités** : binaires trackés, .git 2.3 GB, screenshots racine, .playwright-mcp debris, CLAUDE_CONTEXT.txt 977 KB, 19 workflows disabled, .env/certs committed. **~1 jour de cleanup brutal reste à faire** avant le tag v1.0.7 final.
---
## 1. État des lieux — mesures macro directes
### 1.1 Taille & fichiers
| Mesure | v1 (14-04) | v2 (20-04) | Delta |
| ------------------------- | ------------ | ------------- | -------------------------------------- |
| `.git` (du -sh) | 2.3 GB | **2.3 GB** | 0 (pas de `git filter-repo` fait) |
| Fichiers trackés | 6425 | **6313** | 112 (quelques cleanups ponctuels) |
| Binaires ELF racine | 3 (api/main/veza-api) | **1 (`api` 99 MB)** | 2 supprimés mais 1 persiste |
| Screenshots racine | 54 | **48** | 6 |
| `.md` total repo | inconnu | **435** (18 active + 417 archive) | — |
| `.playwright-mcp/*.yml` | — | **36 (untracked)** | NEW debris |
| `CLAUDE_CONTEXT.txt` | — | **977 KB** racine | NEW artifact de session |
| `output.txt` racine | — | **27 KB** | NEW |
### 1.2 Ce qui n'existe PAS (contrairement à certaines docs)
| Objet | Status | Preuve |
| ---------------------------------- | :--------------: | ------------------------------------------------------------------------------------------------ |
| `veza-chat-server/` | ❌ absent | `ls /home/senke/git/talas/veza/veza-chat-server` → no such dir. Commit `05d02386d` (2026-02-22). |
| `apps/desktop/` (Electron) | ❌ absent | Jamais implémenté. |
| `backend/` racine | ❌ absent | C'est `veza-backend-api/`. |
| `frontend/` racine | ❌ absent | C'est `apps/web/`. |
| `ORIGIN/` racine | ❌ absent | C'est `veza-docs/ORIGIN/`. |
| `proto/chat/chat.proto` utilisé | ❌ orphelin | 0 import dans `veza-stream-server/src/`. Chat 100% Go depuis v0.502. |
| Runbooks k8s mentionnant chat Rust | ❌ clean (bonne) | Grep `veza-chat-server` dans `k8s/` = 0 hit. |
| **Binaire `api` 99 MB racine** | ⚠️ **présent** | `-rwxr-xr-x 1 senke senke 99515104 Mar 24 15:40 api`. **À supprimer.** |
---
## 2. Architecture & stack — mise à jour exacte
### 2.1 Arborescence réelle
```
veza/ (2.3 GB .git, 6313 fichiers trackés)
├── apps/web/ # React 18.2 + Vite 7.1.5 + TS 5.9.3 + Zustand 4.5 + React Query 5.17
│ └── src/ (1984 fichiers TS/TSX)
│ ├── features/ (36 feature folders)
│ ├── components/ui/ (255 fichiers — design system)
│ ├── services/ (73 fichiers)
│ ├── types/generated/ (api.ts 6550 lignes, régénéré aujourd'hui)
│ └── router/routeConfig.tsx (184 lignes, 27 routes top-level, 54 lazy)
├── veza-backend-api/ # Go 1.25.0 + Gin + GORM + Postgres + Redis + RabbitMQ
│ ├── cmd/api/main.go (orchestration wiring)
│ ├── cmd/{migrate_tool,backup,generate-config-docs,tools/*} (~6 binaires)
│ ├── internal/ (877 fichiers .go, 197K LOC)
│ │ ├── api/ (27 routes_*.go)
│ │ ├── api/handlers/ (3 fichiers DEPRECATED — chat, rbac)
│ │ ├── handlers/ (135 fichiers — source active)
│ │ ├── services/ (226 fichiers, 64K LOC)
│ │ ├── core/*/ (9 services feature-scoped)
│ │ ├── models/ (81 fichiers, 44K LOC)
│ │ ├── migrations/ (160 .sql, jusqu'à 983_)
│ │ ├── workers/ (17) + jobs/ (11)
│ │ ├── middleware/ (~30)
│ │ ├── repositories/ (18 GORM-based)
│ │ └── repository/ (1 ORPHELIN in-memory mock)
│ ├── docs/swagger.{json,yaml} (v1.2.0, 2026-03-03)
│ ├── uploads/ (44 .mp3/.wav TRACKÉS !)
│ └── {api,main,veza-api} (3 binaires ELF trackés dans CLAUDE.md .gitignore mais présents)
├── veza-stream-server/ # Rust 2021 + Axum 0.8 + Tokio 1.35 + Symphonia 0.5 + sqlx 0.8 + tonic 0.11
│ └── src/
│ ├── streaming/ (HLS réel, WebSocket 1047 LOC, adaptive 515 LOC, DASH stub commenté)
│ ├── audio/ (Symphonia + LAME native; opus/webrtc/fdkaac commentés)
│ ├── core/ (StreamManager 10k+ concurrents, sync engine 1920 LOC)
│ ├── auth/ (JWT HMAC-SHA256, revocation Redis+in-mem fallback, 825 LOC)
│ ├── grpc/ (Stream+Auth+Events — generated 21845 LOC auto)
│ ├── transcoding/ (queue job engine 94 LOC — ALPHA)
│ ├── event_bus.rs (RabbitMQ degraded mode, 248 LOC)
│ └── lib.rs:5 #![allow(dead_code)] GLOBAL — camoufle les stubs
├── veza-common/ # Rust types partagés
│ └── src/{chat,ws,files,track,user,playlist,media,api}.rs
│ └── chat.rs, track.rs, user.rs, etc. — ORPHELINS depuis suppression chat Rust
├── packages/design-system/ # Tokens design (unique package workspace)
├── proto/
│ ├── common/auth.proto ✅ utilisé par stream-server + backend
│ ├── stream/stream.proto ✅ utilisé par stream-server
│ └── chat/chat.proto ❌ ORPHELIN (chat en Go depuis v0.502)
├── docs/
│ ├── audit-2026-04/ (NEW : axis-1-correctness.md + v107-plan.md)
│ ├── archive/ (278 fichiers .md historique)
│ └── (API_REFERENCE, ONBOARDING, PROJECT_STATE, FEATURE_STATUS, etc.)
├── veza-docs/ # Docusaurus séparé
│ ├── docs/{current,vision}/
│ └── ORIGIN/ (22 fichiers phase-0 FOSSILE, jamais touchée post-launch)
├── k8s/ # ~30-40 manifests + 5 runbooks disaster-recovery
├── config/ # alertmanager, grafana, haproxy, prometheus, incus, ssl/* (.pem TRACKÉS)
├── infra/ # nginx-rtmp + docker-compose.lab.yml (DEPRECATED)
├── docker/ # haproxy/certs/veza.pem (TRACKÉ, sensible)
├── tests/e2e/ # Playwright — SKIPPED_TESTS.md liste les flakies
├── .github/workflows/ # 5 actifs + 19 .disabled (1676 LOC mort)
├── .husky/ # pre-commit + pre-push + commit-msg (untracked mais fonctionnels)
└── {docker-compose*.yml} # 6 files (dev/prod/staging/test/root/env.example)
```
### 2.2 Stack — versions actuelles
| Composant | Doc (CLAUDE.md) | Réel (code) | Écart ? |
| -------------- | --------------- | ----------------- | ----------------- |
| Go | 1.25 | **1.25.0** (go.mod) | ✅ OK |
| React | 18.2 | 18.2.0 | ✅ OK |
| Vite | **5** | **7.1.5** | ❌ CLAUDE.md obsolète |
| TypeScript | 5.9.3 | 5.9.3 | ✅ OK |
| Zustand | — | 4.5.0 | N/A |
| React Query | 5 | 5.17.0 | ✅ OK |
| Tailwind | — | **4.0.0** | ✅ récent |
| date-fns | 4 | 4.1.0 | ✅ OK |
| Axios | non mentionné | 1.13.5 | ✅ moderne |
| jwt-go | v5 | v5.3.0 | ✅ OK |
| gorm | — | v1.30.0 | ✅ OK |
| gin | — | v1.11.0 | ✅ OK |
| redis-go | — | v9.16.0 | ✅ OK |
| Rust edition | 2021 | 2021 | ✅ OK |
| Axum | 0.8 | 0.8 | ✅ OK |
| Tokio | 1.35 | 1.35 | ✅ OK |
| Symphonia | 0.5 | 0.5 | ✅ OK |
| sqlx | 0.8 | 0.8 | ✅ OK |
| tonic | — | 0.11 | ✅ récent |
| Postgres | 16 | 16-alpine (pinned)| ✅ OK |
| Redis | 7 | 7-alpine (pinned) | ✅ OK |
| ES | 8.11.0 | 8.11.0 (dev only) | ⚠️ orphelin prod |
| RabbitMQ | 3 | 3 (pinned) | ✅ OK |
| ClamAV | 1.4 | 1.4 (pinned) | ✅ OK |
| MinIO | — | **`:latest`** (4×)| ❌ supply-chain |
| Hyperswitch | 2026.03.11.0 | 2026.03.11.0 | ✅ OK |
**À corriger dans CLAUDE.md v1.0.5** : Vite 5 → Vite 7.1.5. Ajouter ligne MinIO.
---
## 3. Frontend (`apps/web/`)
### 3.1 Architecture & routes
- **36 feature folders** (`src/features/`) — les plus gros : `playlists/` (182), `tracks/` (181), `auth/` (100), `player/` (94), `chat/` (67).
- **Router** (`src/router/routeConfig.tsx:1-184`) — 27 routes top-level, **54 composants lazy**. **Zéro route "Coming Soon"/placeholder**. Tous les paths mènent à un composant réel.
- **OpenAPI typegen enclenché** : `src/types/generated/api.ts` = **6550 lignes, régénéré 2026-04-19 00:57:21**. La migration "kill hand-written services" prévue post-v1.0.4 a démarré. Script `apps/web/scripts/generate-types.sh` wiré en pre-commit.
### 3.2 Composants & design system
- `src/components/ui/` : **255 fichiers**. Untracked : `testids.ts` (NEW, probablement wiring E2E).
- **Composants orphelins identifiés** (0-1 imports — candidates suppression) :
- `components/ui/optimized-image/OptimizedImageSkeleton.tsx` (0)
- `components/ui/optimized-image/ResponsiveImage.tsx` (0)
- `components/ui/hover-card/*` (3 fichiers, 0 imports — arbre mort)
- `components/ui/dropdown-menu/*` (7 fichiers, 0-1 imports — probablement remplacé par Radix)
- Total : **~11 fichiers orphelins dans le DS**.
### 3.3 State & services
- **Zustand** : 5 stores principaux (`authStore`, `chatStore`, `playerStore`, `queueSessionStore`, `cartStore`) — tous utilisés.
- **React Query** : **seulement 9 fichiers** utilisent `useQuery/useMutation`. `queryKey` ad-hoc (hardcoded, dynamic, constants mélangés). **Pas de factory centralisée** → cache invalidation fragile.
- **Services** (73 fichiers) :
- Top 4 monolithes : `services/api/auth.ts:553` (token+login+register+2FA), `services/adminService.ts:474` (7+ endpoints), `services/analyticsService.ts:472`, `services/marketplaceService.ts:351`.
- **Anti-pattern critique** : `services/api/auth.ts:85-100` fait 3 fallback `const rd = response.data as any` pour parser les tokens. **Pas de validation Zod.**
### 3.4 Tests
- **286 fichiers `.test.ts(x)`** (Vitest).
- **1 test skipped** : `features/auth/pages/ResetPasswordPage.test.tsx` (async timing).
- **E2E** (racine `tests/e2e/`) : Playwright présent, **SKIPPED_TESTS.md documente les flakies** (v107-e2e-04/05/06/08/09 à vérifier en staging).
- Tests E2E **PAS déclenchés en CI** (Playwright absent de `.github/workflows/ci.yml`).
### 3.5 Dette frontend
| Dette | Count | Sévérité |
| ---------------------------------- | :---: | :------: |
| `TODO/FIXME/HACK` | 1 | ✅ top |
| `console.log` en production | 6 fichiers (checkbox, switch, slider, AdvancedFilters, Onboarding, useLongRunningOperation) | 🔴 |
| `any` types | 282 | 🔴 |
| `@ts-ignore` / `@ts-expect-error` | 6 fichiers | 🟡 |
| Fichiers >500 LOC (non-gen) | ~8 | 🟡 |
| Composants V2/V3/_old/_new | 0 | ✅ |
| `src/types/v2-v3-types.ts` | présent (mentionné CLAUDE.md) | 🟡 |
### 3.6 Artefacts morts à la racine de `apps/web/`
| Fichier | Taille | Date (mtime) | Status |
| ---------------------------- | ------ | ------------ | ----------------- |
| `e2e-results.json` | 3.4 MB | Mar 15 | 🔴 obsolète |
| `lint_comprehensive.json` | 793 KB | Jan 7 | 🔴 obsolète |
| `e2e-results.json` (2) | 241 KB | Jan 7 | 🔴 doublon |
| `ts_errors.log` | 29 KB | Dec 12 | 🔴 2+ mois stale |
| `storybook-roadmap.json` | 8.5 KB | Mar 6 | 🟡 |
| `AUDIT_ISSUES.json` | 19 KB | Dec 17 | 🔴 |
| `audit.log`, `debug-storybook.log` | 8.5 KB | Feb/Mar | 🟡 |
**~3.5 MB de reports morts** au bord du frontend. CLAUDE.md §règles 11 interdit ces fichiers en git (ils sont ignorés via `.gitignore` mais traînent en untracked).
---
## 4. Backend Go (`veza-backend-api/`)
### 4.1 Structure
- **877 fichiers .go** dans `internal/`
- **27 fichiers `routes_*.go`** (1 est un test)
- **135 handlers actifs** dans `internal/handlers/`
- **3 fichiers dans `internal/api/handlers/`** — confirmés DEPRECATED (chat + RBAC, à purger après confirmation aucun import)
- **226 services** (`internal/services/`) + **9 core services** (`internal/core/*/service.go`)
- **81 modèles** (`internal/models/`, 44K LOC) — pattern GORM + soft-delete
- **160 migrations SQL** (jusqu'à `983_hyperswitch_webhook_log.sql`)
- **17 workers** + **11 jobs**
- **~30 middlewares**
### 4.2 Routes & handlers
Handlers complets par domaine, **zéro endpoint retournant 501 ou vide**. Zéro double wiring.
Top routes par taille : `routes_core.go:512` (20+ routes), `routes_auth.go:245` (14+ routes, 2FA/OAuth inclus), `routes_tracks.go:240` (18+), `routes_users.go:296` (17+), `routes_marketplace.go:174` (15+), `routes_webhooks.go:205` (5+ ; raw payload audit).
### 4.3 Auth
| Aspect | Status | Preuve |
| -------------------- | :----: | ---------------------------------------------------------------------------------------------------- |
| JWT RS256 prod | ✅ | `services/jwt_service.go:17-81`, keys depuis env. |
| HS256 dev fallback | ✅ | Idem, 32+ char secret exigé. |
| Refresh 7j / Access 5min | ✅ | Configurés. |
| 2FA TOTP + backup codes | ✅ | `handlers/two_factor_handler.go:171` (actif). `api/handlers/` vide de 2FA — deprecated purgé. |
| OAuth 4 providers | ✅ | `routes_auth.go:122-176` (Google, GitHub, Discord, Spotify). State encrypté via CryptoService. |
| Rate limiting multi-couche | ✅ + 🟡 | DDoS global 1000 req/s ✅, endpoint-specific ✅, API key ✅, **`UserRateLimiter` configuré mais pas wiré aux routes**. |
| CSRF | ✅ | Middleware actif (e2e confirmé `tests/e2e/45-playlists-deep.spec.ts`). Disabled dev/staging (`router.go:133`). |
| Security headers | 🟡 | SecurityHeaders middleware présent (`router.go:204`). **CSP / X-Frame-Options pas vus en grep**. À vérifier. |
### 4.4 Modèles, DB, transactions
- Migrations auto-appliquées au démarrage (`database.go:234-256`). Boot fail si erreur SQL.
- Repositories : 18 GORM-direct, pattern inline (pas d'interface). **Plus** `internal/repository/` (1 fichier in-memory mock UserRepository) **ORPHELIN** — à supprimer.
- **Transactions insuffisantes**`db.Transaction()` usage = **8×**, `tx.Create/Save/Delete` manuel = **37×**. Chemins critiques (marketplace `core/marketplace/service.go:1050+`, subscription) ne sont **pas dans des transactions explicites**. Risque data corruption si une étape échoue au milieu.
### 4.5 Services & context
- Architecture dual-layer `core/` + `services/` **incohérente** : certaines features ont `core/service.go`, d'autres `services/*.go`, sans règle claire. Ex. track publication en `core/track/` mais search indexing en `services/track_search_service.go`, les deux appelés depuis un même handler.
- Context propagation : 558 usages propres dans services, **mais 31 `context.Background()` dans `handlers/`** → défait le timeout middleware. Fix grep+sed 1 jour.
- **Pas de `services_init.go`** : services instantiés inline dans `routes_*.go`. Re-créés par request-group. Non-singletons.
### 4.6 Workers & jobs
- **Actifs lancés par `cmd/api/main.go`** : JobWorker, TransferRetry, StripeReversal, Reconciliation, CloudBackup, GearWarranty, NotifDigest, HardDelete, OrphanTracksCleanup, LedgerHealthSampler.
- **Jobs définis mais jamais schedulés** : `SchedulePasswordResetCleanupJob`, `CleanupExpiredSessions`, `CleanupVerificationTokens`, `CleanupHyperswitchWebhookLog` — ~4 cleanup jobs **dead code**. Soit les brancher soit les supprimer.
### 4.7 Tests
- **364 fichiers `*_test.go`**. `coverage_v1.out` (Mar 3) indique ~60-70%.
- Integration tests skippables via config — mais **pas de variable `VEZA_SKIP_INTEGRATION` trouvée en grep** (CLAUDE.md la mentionne — à vérifier si elle existe réellement ou si c'est un fossile doc).
- E2E Playwright n'entre jamais en CI.
### 4.8 Validation & errors
- `internal/validators/` — wrapper `go-playground/validator/v10`
- `internal/errors/``AppError{Code,Message,Err,Details,Context}`
- **PROBLÈME** : `RespondWithAppError` défini **2 fois** (`response/response.go:101` + `handlers/error_response.go:12`). Duplication à consolider.
- Wrapped errors : 349 usages `errors.Is/As/Unwrap` — bon pattern.
### 4.9 Config
- **99 env vars lues** dans `config/config.go` (1087 LOC)
- **`Config.Validate()`** :
- ✅ Refuse prod si `HYPERSWITCH_ENABLED=false` (`config.go:908-910`, fail-closed).
- ✅ Refuse prod sans DATABASE_URL, JWT keys, CORS origins.
- ❌ **Pas de check `APP_ENV ∈ {dev,staging,prod}`** — silencieusement default dev.
- ❌ **Pas de check `UPLOAD_DIR` exists** — boot success même si dir manquant.
- **`.env.template` 190 lignes** vs 263 `os.Getenv` appels code → drift potentiel (~70 vars documentées vs 99 utilisées).
### 4.10 Dette backend — récap
| Dette | Sévérité | Effort | Preuve |
| ------------------------------------------- | :-------: | :----: | ------------------------------------------------------------- |
| Transactions manquantes marketplace/subs | 🔴 | M (3j) | `core/marketplace/service.go:1050+` |
| 31× `context.Background()` dans handlers | 🔴 | S (1j) | Grep handlers |
| Binaires racine `api` (99MB) + 44 .mp3 | 🔴 | XS (1h)| `git rm --cached` + BFG |
| `RespondWithAppError` dupliqué | 🟡 | S (1j) | `response/response.go:101` + `handlers/error_response.go:12` |
| `internal/repository/` orphelin | 🟡 | XS | Delete dir |
| 4 cleanup jobs jamais schedulés | 🟡 | S | Brancher ou supprimer |
| `UserRateLimiter` configuré non wiré | 🟡 | S | Wire en middleware chain |
| Écart `.env.template` vs code (29 vars) | 🟠 | S | Sync |
| Services re-instantiés par request-group | 🟠 | M | `services_init.go` + singleton pattern |
| Architecture core/+services/ incohérente | 🟠 | L | Document la règle OU unifier |
---
## 5. Rust stream server (`veza-stream-server/`)
### 5.1 Modules
Production-ready : `streaming/` (HLS réel, Range 206, WS 1047 LOC, adaptive 515 LOC), `audio/` (Symphonia native, compression 708 LOC, effects SIMD), `core/` (StreamManager 10k+ concurrents, sync engine NTP-like 1920 LOC), `auth/` (JWT HMAC-SHA256 + revocation Redis-or-in-mem 825 LOC), `cache/` (LRU audio), `event_bus.rs` (RabbitMQ degraded mode).
Alpha / partiel : `transcoding/engine.rs` (94 LOC, job queue priority-based mais **zéro test d'intégration, zéro tracking live**), `grpc/` (461 LOC business + 21845 LOC généré).
**Stub / absent** :
- `streaming/protocols/mod.rs:4``// pub mod dash;` **commenté**.
- `Cargo.toml:62``// webrtc = "0.7"` **commenté** (deps natives manquantes).
### 5.2 Audio codecs
Symphonia couvre MP3, FLAC, Vorbis, AAC **natifs**. LAME MP3 via `minimp3 0.5` (natif). **Commentés** : `opus 0.3` (cmake), `lame 0.1`, `fdkaac 0.7` (non sur crates.io).
### 5.3 gRPC & protos
`StreamService`, `AuthService`, `EventsService` (3 services). Utilise `proto/common/auth.proto` + `proto/stream/stream.proto`. **`proto/chat/chat.proto` = 0 import** → orphelin depuis suppression chat Rust.
### 5.4 Dette Rust
| Dette | Sévérité | Preuve |
| ----------------------------------------------- | :------: | ---------------------------------------------------------------- |
| `#![allow(dead_code)]` global dans `lib.rs:5` | 🔴 | Masque tous les stubs. Devrait être granulaire par module. |
| 10× `unwrap()` sur broadcast channels | 🔴 | `core/sync.rs:1037-1110`. Panic si receiver drop. `.expect()` + contexte. |
| `proto/chat/chat.proto` orphelin | 🟡 | À archiver/supprimer. |
| `veza-common` chat types orphelins | 🟡 | ~60 LOC dead. Audit grep `use veza_common::chat` → 0 hit. |
| `transcoding/` zéro tests intégration | 🟡 | `engine.rs:36-62`. |
| 26× `println!/dbg!` | 🟡 | Devrait utiliser `tracing::`. |
| Deps inutilisées (`daemonize`, `notify`) | 🟠 | `Cargo.toml:139, 116`. |
**0 `unsafe`** ✅ (engagement CLAUDE.md tenu).
---
## 6. Infrastructure & DevOps
### 6.1 Docker Compose (6 fichiers)
| Fichier | Rôle | État |
| ---------------------------- | --------------------------------- | ------------------------------------------ |
| `docker-compose.yml` | Dev full-stack avec profiles | ✅ Actif |
| `docker-compose.dev.yml` | Infra-only (209 LOC) | ✅ Actif (MailHog + ES 8.11.0 ici uniquement)|
| `docker-compose.prod.yml` | Blue-green, HAProxy, Alertmanager (464 LOC) | ✅ Actif (Mar 12) |
| `docker-compose.staging.yml` | Caddy (202 LOC) | ✅ Actif (Mar 2) |
| `docker-compose.test.yml` | tmpfs CI (64 LOC) | ✅ Actif |
| `infra/docker-compose.lab.yml` | DEPRECATED Feb 2026 | 🔴 À supprimer |
**Pinning** :
- ✅ Postgres 16-alpine, Redis 7-alpine, RabbitMQ 3, ClamAV 1.4, Hyperswitch 2026.03.11.0.
- ❌ **MinIO `:latest`** dans 4 composes → supply-chain attack vector.
**Services orphelins en dev-only** :
- ES 8.11.0 uniquement `docker-compose.dev.yml:171-204` (34 LOC) — **le backend utilise Postgres FTS, pas ES** (`fulltext_search_service.go`). ES ne sert qu'au hard-delete worker (GDPR cleanup), optionnel. À documenter ou retirer.
### 6.2 Dockerfiles
- Backend : `Dockerfile` + `Dockerfile.production` (Go 1.24-alpine, multi-stage, non-root uid 1001, `-w -s`). ⚠️ **CLAUDE.md dit Go 1.25, Dockerfile sur 1.24** — bumper.
- Stream : `Dockerfile` + `Dockerfile.production` (rust:1.84-alpine). ⚠️ **Mismatch port** : Dockerfile.production expose `8082` mais `docker-compose.prod.yml:284` healthcheck attend `3001`**le Dockerfile n'est pas utilisé en prod** (sans doute l'image vient d'ailleurs).
- Web : `Dockerfile` + `Dockerfile.dev` + `Dockerfile.production` (node:20-alpine → nginx:1.27-alpine).
### 6.3 CI/CD
**Workflows actifs (5)** :
1. `ci.yml` (consolidé, ~15min) — backend Go (test, lint, vet, govulncheck), frontend (lint, tsc, build, vitest), rust (build, test, clippy, audit).
2. `frontend-ci.yml` (55 LOC) — path-triggered React-only, bundle-size gate, npm audit.
3. `security-scan.yml` — gitleaks v8.21.2 secret scan.
4. `trivy-fs.yml` — Trivy filesystem scan (HIGH+CRITICAL exit=1).
5. `go-fuzz.yml` — Nightly fuzz 60s, corpus upload.
**Workflows disabled (19 fichiers, 1676 LOC mort)** :
`backend-ci.yml.disabled`, `cd.yml.disabled`, `staging-validation.yml.disabled`, `accessibility.yml.disabled`, `chromatic.yml.disabled`, `visual-regression.yml.disabled`, `storybook-audit.yml.disabled`, `contract-testing.yml.disabled`, `zap-dast.yml.disabled`, `container-scan.yml.disabled`, `semgrep.yml.disabled`, `sast.yml.disabled`, `mutation-testing.yml.disabled`, `rust-mutation.yml.disabled`, `load-test-nightly.yml.disabled`, `flaky-report.yml.disabled`, `openapi-lint.yml.disabled`, `commitlint.yml.disabled`, `performance.yml.disabled`.
**→ 1676 lignes de workflow mort. Soit réactiver ce qui fait sens (SAST, DAST, openapi-lint), soit archiver dans `docs/archive/workflows/` pour ne pas polluer `.github/workflows/`.**
**Gaps CI** :
- E2E Playwright pas déclenché (pourtant `tests/e2e/` existe, `SKIPPED_TESTS.md` documente les flakies).
- Integration tests Go skipped (`VEZA_SKIP_INTEGRATION=1` faute de Docker socket sur runner).
### 6.4 K8s
- ~30-40 manifests, structure propre (`autoscaling/`, `backends/`, `backups/`, `cdn/`, `disaster-recovery/`, `environments/{prod,staging,dev}`, `secrets/`).
- **5 runbooks** : cluster-failover, database-failover, data-restore, rollback-procedure, security-incident.
- ✅ **Zéro référence à `veza-chat-server`** dans `k8s/` (grep clean — l'audit v1 disait qu'il y avait 7+ runbooks outdated ; **corrigé**).
### 6.5 Secrets & sécurité
| Item | État | Action |
| --------------------------------------------- | :------: | -------------------------------------------------------------------- |
| `/docker/haproxy/certs/veza.pem` | 🔴 TRACKED | BFG + rotate cert + move to K8s Secret |
| `/config/ssl/{cert,key,veza}.pem` | 🔴 TRACKED | Idem |
| `veza-backend-api/.env` | 🔴 TRACKED | `git rm --cached`, rotate JWT/DB secrets dev, relire `.gitignore` |
| `veza-backend-api/.env.production.example` | 🟢 OK | Template |
| Hardcoded secrets en code (`sk_live_`, `AKIA`)| ✅ absent | Grep clean |
| gitleaks en CI | ✅ | `security-scan.yml` |
| govulncheck | ✅ | `ci.yml` |
| CSP header | 🟡 | Grep 0 hit. **À implémenter.** |
| X-Frame-Options | 🟡 | Idem |
### 6.6 Observability
- Prometheus : **5 gauges ledger-health** déployées en v1.0.7 (`ledger_metrics.go`), **+ counter/histogram reconciler**. Alertmanager `config/alertmanager/ledger.yml` avec 3 règles (VezaOrphanRefundRows, VezaStuckOrdersPending, VezaReconcilerStale). Grafana dashboard `config/grafana/dashboards/ledger-health.json`.
- Logs : JSON structuré confirmé (`level`, `time`, `msg`, `request_id`, `user_id`).
- **Gap** : `/metrics` endpoint global backend pas vu (à confirmer — il existe probablement via middleware Sentry/Prometheus, mais pas en grep direct).
- Sentry : optionnel via env (`SENTRY_DSN`, `SENTRY_SAMPLE_RATE_*`).
---
## 7. Documentation
### 7.1 Racine du repo
| Fichier | Taille | Date | Verdict |
| ------------------------------- | ------ | ---------- | ---------------------------------------------------------------------- |
| `CLAUDE.md` | 22 KB | 2026-04-14 | ✅ Autorité. Petite dérive : Vite 5 → 7.1.5 à corriger. |
| `CHANGELOG.md` | 87 KB | 2026-04-19 | ✅ À jour (v0.201 → v1.0.7-rc1). |
| `README.md` | 2.8 KB | — | ✅ Minimal OK. |
| `CONTRIBUTING.md` | 2.7 KB | 2026-02-27 | ✅ OK. |
| `VERSION` | — | — | `1.0.7-rc1` ✅ aligné. |
| `VEZA_VERSIONS_ROADMAP.md` | 69 KB | — | ⚠️ Historique v0.9xx, peu utile post-launch. Archive. |
| `RELEASE_NOTES_V1.md` | 4.7 KB | — | ✅ OK. |
| `AUDIT_REPORT.md` | 57 KB | 2026-04-14 | 🔄 **Ce fichier — v2 remplace v1**. |
| `FUNCTIONAL_AUDIT.md` | 43 KB | 2026-04-19 | ✅ v2 à jour. |
| `UI_CONTEXT_SUMMARY.md` | 6 KB | — | 🟠 Session artifact, devrait être archivé selon CLAUDE.md §12. |
| `CLAUDE_CONTEXT.txt` | 977 KB | 2026-04-18 | 🔴 ÉNORME session dump. Archive ou supprime. |
| `output.txt` | 27 KB | 2026-04-18 | 🔴 Debris. |
| `generate_page_fix_prompts.sh` | 42 KB | Mar 26 | 🟡 Script généré, probablement obsolète. |
| `build-archive.log` | 974 B | Mar 25 | 🟡 Log. |
**48 screenshots PNG racine** (`dashboard-*.png`, `login-*.png`, `design-system-*.png`, `forgot-password-*.png`) — **à déplacer dans `docs/screenshots/` ou supprimer**.
### 7.2 `docs/` (18 actifs + 417 archive = 435 .md)
**Actifs** :
- `docs/API_REFERENCE.md` (1022 LOC) — **manuel**, pas de typegen. Écart flag vs routes Go. Migration vers OpenAPI typegen backend = priorité.
- `docs/ONBOARDING.md`, `docs/PROJECT_STATE.md`, `docs/FEATURE_STATUS.md` — à cross-checker avec code v1.0.7 (non fait ici).
- `docs/ENV_VARIABLES.md`**introuvable en `ls docs/`** alors que CLAUDE.md dit "à maintenir". Soit créé soit manque.
- `docs/audit-2026-04/`**NOUVEAU, très utile** : `axis-1-correctness.md` + `v107-plan.md` — trace des findings et du plan v1.0.7.
- `docs/SECURITY_SCAN_RC1.md` / `docs/ASVS_CHECKLIST_v0.12.6.md` / `docs/PENTEST_REPORT_VEZA_v0.12.6.md`**refs v0.12.6, obsolètes** pour v1.0.7. Refaire ou archiver.
**Archive** (`docs/archive/` = 278 fichiers) : historique session 2026. Taille totale importante. Ne pose pas de problème immédiat.
### 7.3 `veza-docs/` (Docusaurus séparé)
- `veza-docs/docs/{current,vision}/` — doc cible.
- `veza-docs/ORIGIN/` (22 fichiers, ~70K lignes) — **phase-0, jamais touchée depuis launch**. Qualifiée "FOSSIL" par agent. Archive ou zip.
---
## 8. Dette technique transverse — catalogue
### 8.1 TODOs / FIXMEs (11 hits)
1. `tests/e2e/22-performance.spec.ts:8` — "Either add data-testid containers or rewrite test to use API mocking" (3 occurrences).
2. `tests/e2e/04-tracks.spec.ts` — "Corriger le bug dans FeedPage.tsx" (ouvert, P1).
3. `apps/web/src/features/auth/pages/ResetPasswordPage.test.tsx` — async timing flaky.
4. `veza-backend-api/internal/core/marketplace/service.go:1450` — "TODO v1.0.7: Stripe Connect reverse-transfer API" (**effectivement déjà landed en v1.0.7 item A+B** — TODO à supprimer).
5. `veza-backend-api/internal/core/subscription/service.go` — "TODO(v1.0.7-item-G): subscription pending_payment state" (in-flight, parked).
**Aucun TODO daté >6 mois.** Discipline correcte.
### 8.2 Code mort / orphelin
| Item | Action |
| ------------------------------------------------ | ------------------------------------------------ |
| `veza-backend-api/internal/api/handlers/` (3 fichiers) | Confirmer 0 import puis `git rm -r` |
| `veza-backend-api/internal/repository/` (in-mem mock) | `git rm -r` |
| `apps/web/src/components/ui/hover-card/*` (3) | Delete si confirmé 0 import |
| `apps/web/src/components/ui/dropdown-menu/*` (7) | Audit imports, delete si Radix les remplace |
| `apps/web/src/components/ui/optimized-image/{OptimizedImageSkeleton,ResponsiveImage}.tsx` | Delete |
| `apps/web/src/types/v2-v3-types.ts` | Auditer appelants, renommer ou delete |
| `proto/chat/chat.proto` | Archiver `docs/archive/proto-chat/` ou delete |
| `veza-common/src/chat.rs` + autres types chat | Audit `use veza_common::chat`, delete si 0 hit |
| 19 workflows `.disabled` | Archiver `docs/archive/workflows/` ou delete |
| 4 cleanup jobs jamais schedulés (pw-reset, sessions, verif, hyperswitch-log) | Brancher ou delete |
### 8.3 Binaires / artefacts trackés
| Item | Taille | Action |
| --------------------------------------------------- | ------ | ------------------------------------------------- |
| `api` (racine, ELF) | 99 MB | `git rm --cached api` + `.gitignore` |
| `veza-backend-api/{main,veza-api,seed,server}` | ~50 MB chacun | Idem (sont dans `.gitignore` mais encore tracked?) |
| `veza-backend-api/uploads/*.{mp3,wav}` (44 fichiers)| 12 MB | `git rm -r --cached uploads/` + move to git-lfs ou fixtures |
| `CLAUDE_CONTEXT.txt` (racine) | 977 KB | `git rm --cached` ou déplacer |
| `apps/web/e2e-results.json` (3.4 MB) | 3.4 MB | `.gitignore` + `rm` |
| 48 PNG racine (dashboard-*, login-*, design-system-*, forgot-password-*) | ~5 MB total | Move to `docs/screenshots/` ou delete |
| 36 `.playwright-mcp/*.yml` (untracked) | — | `rm -r .playwright-mcp/` |
### 8.4 Sécurité hors-code
| Item | Action |
| ----------------------------------------- | ------------------------------------------------------ |
| `/docker/haproxy/certs/veza.pem` tracked | BFG purge history + rotate cert + K8s Secret |
| `/config/ssl/*.pem` tracked | Idem |
| `veza-backend-api/.env` tracked | `git rm --cached`, rotate dev secrets, audit team |
| CSP header absent | Middleware `SecurityHeaders` — ajouter |
| X-Frame-Options absent | Idem |
### 8.5 Incohérences doc↔code
| Item | Delta |
| ---------------------------------------------- | -------------------------------------------------- |
| `CLAUDE.md` : Vite 5 | Réel Vite 7.1.5 — bumper doc |
| `CLAUDE.md` : ES 8.11.0 partout | Réel ES 8.11.0 dev-only |
| `CLAUDE.md` : Go 1.25 | go.mod 1.25.0 ✅ ; `veza-backend-api/Dockerfile` 1.24 — bumper |
| `docs/API_REFERENCE.md` manuel 1022 LOC | 135 handlers — risque drift. OpenAPI typegen backend recommandé. |
| `VEZA_VERSIONS_ROADMAP.md` v0.9xx | VERSION = 1.0.7-rc1 — archive le roadmap |
| `docs/ASVS_CHECKLIST_v0.12.6.md` etc | Version obsolète. Refaire sur v1.0.7 ou archiver. |
| `docs/ENV_VARIABLES.md` mentionné | Pas trouvé en `ls docs/`. Créer. |
### 8.6 Patterns abandonnés ou à mi-chemin
1. **OpenAPI typegen frontend** : démarré (`api.ts` 6550 LOC régénéré) mais les **73 services frontend restent hand-written**. Finir la migration (memory entry : "orval recommended").
2. **OpenAPI typegen backend** : `docs/API_REFERENCE.md` manuel. Swagger infra (`swaggo/swag`) présente mais pas pleinement exploitée.
3. **Repository pattern** : `repositories/` (GORM-direct, 18 fichiers) mixé avec `services/` qui requêtent `gormDB` direct. Pas d'interfaces. Pattern mi-chemin.
4. **Architecture `core/` + `services/`** : pas de règle claire. À unifier ou à documenter explicitement quelles features vont où.
5. **Transactions** : 8 usages vs 37 tx manuels. Pattern moitié-fait.
---
## 9. Top 15 priorités — impact / effort
Classement pour la suite (post-v1.0.7-rc1 → v1.0.7 final → v1.0.8).
| # | Priorité | Impact | Effort | Rationale |
| --- | -------------------------------------------------------------------------------- | :----: | :-----: | -------------------------------------------------------------------------- |
| 1 | **Supprimer `api` 99 MB + binaires Go trackés racine + `uploads/*.mp3`** | 🔴 CRIT | XS (1h) | Repo .git 2.3 GB. `git rm --cached` + BFG `--strip-blobs-bigger-than 10M`. |
| 2 | **Rotate TLS certs + supprimer `.pem` trackés + .env committed** | 🔴 CRIT | S (4h) | Prod-grade security hygiene. BFG + rotate + K8s Secrets. |
| 3 | **Transactions marketplace/subscription** | 🔴 CRIT | M (3j) | `core/marketplace/service.go:1050+` sans tx → data corruption possible. |
| 4 | **Context propagation : 31× `context.Background()` dans handlers** | 🔴 | S (1j) | Défait timeout middleware. Grep + sed fix. |
| 5 | **Ajouter CSP + X-Frame-Options headers** | 🔴 | S (1j) | OWASP A05. Middleware `SecurityHeaders` à enrichir. |
| 6 | **Pin MinIO `:latest` → tag daté** | 🔴 | XS (10min) | Supply-chain. 4 occurrences dans compose files. |
| 7 | **Nettoyer `.playwright-mcp/*.yml` + 48 PNG racine + `CLAUDE_CONTEXT.txt` + dead reports apps/web/** | 🟡 | S (2h) | Propreté. `rm -r` + update `.gitignore`. |
| 8 | **Terminer OpenAPI typegen** (frontend services + backend swaggo) | 🟡 | L (5j) | Memory entry, drift risk. `api.ts` 6550 LOC déjà là. |
| 9 | **Supprimer 19 workflows `.disabled` (1676 LOC mort) OU réactiver utiles (SAST, DAST, openapi-lint)** | 🟡 | S (4h) | Noise .github/. Archive or reactivate sélectivement. |
| 10 | **Consolider `RespondWithAppError` dupliqué** | 🟡 | S (1j) | `response/response.go:101` + `handlers/error_response.go:12`. |
| 11 | **Wirer `UserRateLimiter` configuré mais non appelé** | 🟡 | S (1j) | Rate limit par user manquant dans la chaîne. |
| 12 | **Supprimer `internal/repository/` (in-mem mock orphelin)** | 🟡 | XS | Dead code. |
| 13 | **Remove/archive `proto/chat/chat.proto` + `veza-common/src/chat.rs`** | 🟡 | XS | Orphelins depuis suppression chat Rust (Feb 2026). |
| 14 | **Ajouter E2E Playwright en CI** | 🟡 | M (3j) | Playwright existe, SKIPPED_TESTS.md documenté, mais pas trigger CI. |
| 15 | **`docs/ENV_VARIABLES.md` — créer si manque, sync avec code** | 🟠 | S (1j) | 99 env vars dans code vs 190 lignes template → drift. |
### 9.1 "À supprimer sans regret"
- `infra/docker-compose.lab.yml` (DEPRECATED Feb 2026)
- `scripts/align-8px-grid.py`, `auto_migrate_tailwind_colors*.py` (tailwind migration faite)
- 48 PNG racine
- 36 `.playwright-mcp/*.yml`
- 19 `.disabled` workflows
- Binaires Go trackés
- 44 fichiers audio `.mp3/.wav` dans `veza-backend-api/uploads/`
- `CLAUDE_CONTEXT.txt` racine
- `VEZA_VERSIONS_ROADMAP.md` (v0.9xx historique)
- `generate_page_fix_prompts.sh` racine (42 KB, Mar 26)
- `output.txt`, `build-archive.log` racine
- `apps/web/{e2e-results.json, lint_comprehensive.json, ts_errors.log, AUDIT_ISSUES.json}`
- `internal/repository/` (orphelin)
- `proto/chat/chat.proto` + types `veza-common/src/chat.rs`
- `apps/web/src/components/ui/{hover-card,dropdown-menu,optimized-image}/` orphelins
- `docs/ASVS_CHECKLIST_v0.12.6.md` + `docs/PENTEST_REPORT_VEZA_v0.12.6.md` (v0.12.6 obsolète)
### 9.2 "À finir avant de commencer quoi que ce soit de nouveau"
1. **Cleanup repo** (#1, #2, #7, #9 ci-dessus) — 1 jour brutal avant tag v1.0.7 final.
2. **Transactions manquantes** (#3) — 3 jours, critique monétaire.
3. **Context propagation** (#4) — 1 jour, simple grep+sed.
4. **Security headers** (#5) — 1 jour.
5. **OpenAPI typegen** (#8) — 1 semaine, mais une seule fois.
### 9.3 Chemin critique vers v1.0.7 final stable
**~5 jours d'ingénieur, sans feature** :
| Jour | Tâches |
| :--: | ---------------------------------------------------------------------------------------------- |
| J1 | Items #1, #2, #6, #7 — cleanup brutal + rotation secrets. BFG sur .git. Retag. |
| J2 | Item #4 (context) + #10 (dedupe AppError) + #12 (repository) + #13 (proto chat). |
| J3-4 | Item #3 — transactions marketplace/subscription + tests. |
| J5 | Item #5 (CSP/XFO) + #11 (rate limiter) + #15 (.env doc) + tag `v1.0.7`. |
Ensuite v1.0.8 : OpenAPI typegen (#8), E2E CI (#14), item G subscription `pending_payment` (parké dans v107-plan).
---
## 10. Verdict final
**Veza v1.0.7-rc1 : application solide, dépôt sale.**
- **Code applicatif** : mature, testé (286 tests front + 364 back), sécurisé (gitleaks/govulncheck/trivy, JWT RS256, 2FA, OAuth, CORS strict, CSRF, DDoS rate limit), plomberie monétaire auditée (ledger-health gauges, reconciliation, idempotency, reverse-charge).
- **Code infra** : 3 variants Dockerfile (dev/prod), K8s avec disaster recovery, 5 workflows CI actifs, 6 compose env, HAProxy blue-green.
- **Hygiène repo** : 2.3 GB `.git`, 99 MB binaire racine, 48 PNG racine, 36 YML debris, 44 audio trackés, 19 workflows morts, CLAUDE_CONTEXT 977 KB, TLS certs committés, `.env` committed. **Les items "hygiène" de l'audit v1 (14 avril) n'ont pas été traités** — l'équipe a fait le bon choix de prioriser la correction produit (v1.0.5 → v1.0.7-rc1), mais ces items doivent maintenant descendre sur la stack.
**En une phrase** : **le code est prêt pour v1.0.7 final, le dépôt n'est pas prêt pour la v1.0.7 final**. 1 jour de cleanup brutal résout 80% du problème.
---
## Annexe — diff v1 ↔ v2
| Thème | v1 (2026-04-14) | v2 (2026-04-20) |
| -------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------- |
| HEAD | `45662aad1` (v1.0.0-mvp-24-g45662aad1) | `89a52944e` (v1.0.7-rc1) |
| Finding "chemin critique v1.0.5 public-ready"| 6 items listés | **Tous les 6 traités** (v1.0.5 → v1.0.7-rc1, 50+ commits) |
| 🔴 Player/écoute audio | Bloqueur | Résolu — endpoint `/tracks/:id/stream` + Range bypass |
| 🔴 IsVerified hardcoded | Bloqueur | Résolu — `core/auth/service.go:200` `IsVerified: false` |
| 🟡 SMTP silent fail | Bloqueur | Résolu — schema unifié + MailHog default |
| 🟡 Marketplace dev bypass | Bloqueur | Résolu — fail-closed prod via `Config.Validate:908-910` |
| 🟡 Refund stub | Bloqueur | Résolu — 3-phase + idempotency + webhook reverse-charge |
| 🟡 Chat multi-instance silent | Bloqueur | Résolu — log ERROR loud `chat_pubsub.go:23-27` |
| 🟡 Maintenance mode in-memory | Bloqueur | Résolu — persisté `platform_settings` TTL 10s |
| 🔵 Reconciliation Hyperswitch | Absent | **Nouveau**`reconcile_hyperswitch.go:55-150` |
| 🔵 Webhook raw payload audit | Absent | **Nouveau**`webhook_log.go:34-80` + cleanup 90j |
| 🔵 Ledger-health metrics | Absent | **Nouveau** — 5 gauges + 3 alertes + Grafana |
| 🔵 Stripe Connect reversal async | Absent | **Nouveau**`reversal_worker.go:12-180` |
| 🔵 Self-service creator upgrade | Absent | **Nouveau**`POST /users/me/upgrade-creator` |
| Hygiène `.git` 2.3 GB | Bloqueur | **Non traité** |
| Hygiène binaires tracked | 3 binaires | 1 reste (`api` 99 MB racine) |
| Hygiène `uploads/*.mp3` 44 fichiers | Présent | **Non traité** |
| Hygiène 54 PNG racine | Présent | 48 restent |
| Runbooks k8s outdated (chat Rust) | 7+ runbooks | **0 référence** — clean |
| CLAUDE.md précis | Faux | **À jour** sauf Vite 5→7 |
| Site Docusaurus `ORIGIN/` | À réécrire | **22 fichiers FOSSILE encore** — à archiver |
| Workflows CI | `.github/workflows/*` non consolidé | Consolidé (`ci.yml`) + **19 disabled qui traînent** |
| `docs/audit-2026-04/` | Absent | **Nouveau** — axis-1-correctness + v107-plan |
**Score global** : v1 disait "Moyen-Haute dette". v2 : **Basse dette code / Haute dette hygiène**. L'équilibre a basculé sur l'hygiène repo.
---
*Généré par Claude Code Opus 4.7 (1M context, /effort max, /plan) — 5 agents Explore parallèles (frontend, backend Go, Rust stream, infra/DevOps, dette transverse) + mesures macro directes (du, ls, git ls-files) + lecture `CHANGELOG.md` v1.0.5→v1.0.7-rc1 + `docs/audit-2026-04/v107-plan.md`. Cross-référencé avec [FUNCTIONAL_AUDIT.md v2](FUNCTIONAL_AUDIT.md) pour les verdicts fonctionnels.*

274
FUNCTIONAL_AUDIT.md Normal file
View file

@ -0,0 +1,274 @@
# FUNCTIONAL_AUDIT v2 — Veza, ce qu'un utilisateur peut RÉELLEMENT faire
> **Date** : 2026-04-19
> **Branche** : `main` (HEAD = `89a52944e`, `v1.0.7-rc1`)
> **Auditeur** : Claude Code (Opus 4.7 — mode autonome, /effort max, /plan)
> **Méthode** : 5 agents Explore en parallèle + vérifications ponctuelles directes + relecture de `docs/audit-2026-04/v107-plan.md` et `CHANGELOG.md`. **Trace statique** (pas de runtime), comme v1.
> **Supersede** : [v1 du 2026-04-16](#6-diff-vs-audit-v1-2026-04-16). La v1 listait 1 🔴 + 9 🟡. Entre le 16 et aujourd'hui, v1.0.5 → v1.0.7-rc1 ont shippé (50+ commits, la majorité ciblant exactement les findings v1).
> **Ton** : brutal, sans langue de bois. Citations `fichier:ligne`.
---
## 0. Résumé en 5 lignes
1. **Le bloqueur `🔴 Player` de la v1 est résolu.** Un endpoint direct `/api/v1/tracks/:id/stream` avec support Range (`routes_tracks.go:118-120`) sert l'audio sans HLS. Le middleware bypass cache (`response_cache.go:87-104`, commit `b875efcff`) permet le range-request. Le player frontend tombe automatiquement sur `/stream` si HLS échoue (`playerService.ts:280-293`). `HLS_STREAMING=false` reste le default (`config.go:355`) **mais ce n'est plus un blocker** : l'audio sort.
2. **Inscription / vérification email : cassée en v1, corrigée.** `IsVerified: false` (`core/auth/service.go:200`), `VerifyEmail` endpoint réellement vivant, login gate 403 sur unverified (`service.go:527`), MailHog branché par défaut dans `docker-compose.dev.yml`, SMTP env schema unifié (commit `066144352`). Tout le parcours register → mail → click → login fonctionne.
3. **Paiements solidifiés de façon massive.** Refund fait **reverse-charge Hyperswitch avec idempotency-key** (`service.go:1297-1436`). Reconciliation worker sweep les stuck orders/refunds/orphans (`reconcile_hyperswitch.go:55-150`). Webhook raw payload audit (`webhook_log.go`). 5 gauges Prometheus ledger-health + 3 alert rules. **Dev bypass persiste** (simulated payment si `HYPERSWITCH_ENABLED=false`, `service.go:550-586`) **mais `Config.Validate` refuse de booter en prod** sans Hyperswitch (`config.go:908-910`). Fail-closed en prod, fail-open en dev.
4. **Points rugueux restants** : (a) **WebRTC 1:1 sans STUN/TURN** — signaling ✅ mais NAT traversal HS en prod ; (b) **Stockage local disque only** — le code S3/MinIO existe mais n'est pas wiré dans l'upload path ; (c) **HLS toujours off par défaut** → pas d'adaptive bitrate out-of-the-box ; (d) **Transcoding dual-trigger** (gRPC Rust + RabbitMQ) — redondance non documentée.
5. **Verdict** : Veza v1.0.7-rc1 est prêt pour une **démo publique contrôlée** (un seul pod, infra dev, Hyperswitch sandbox). Pour un **déploiement prod multi-pod avec utilisateurs réels** il manque : MinIO wiré, STUN/TURN pour les calls, et la documentation d'exploitation des gauges ledger-health. La surface "un utilisateur lambda peut register → verify → upload → play → acheter → rembourser" est **entièrement opérationnelle**.
---
## 1. Tableau des features — verdict réel au 2026-04-19
Légende : **✅ COMPLET** câblé de bout-en-bout · **🟡 PARTIEL** gotchas exploitables · **🔴 FAÇADE** UI sans backend réel · **⚫ ABSENT**.
| # | Feature | Verdict | v1 | Détail + citation |
| --- | ---------------------------------------------------------------- | :-----: | :-: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Register / Login / JWT / Refresh | ✅ | 🟡 | `IsVerified: false` (`core/auth/service.go:200`). Login 403 si unverified (`service.go:527`). JWT RS256 prod / HS256 dev. |
| 2 | Verify email | ✅ | 🔴 | `POST /auth/verify-email` actif (`routes_auth.go:103-107`). Token généré + stocké en DB, email envoyé via MailHog par défaut. |
| 3 | Forgot / Reset password | ✅ | 🟡 | `password_reset_handler.go:67-250`. Token en DB avec expiry, invalide toutes les sessions à l'usage. |
| 4 | 2FA TOTP | ✅ | ✅ | `internal/handlers/two_factor_handler.go:171`. Obligatoire pour admin. |
| 5 | OAuth (Google/GitHub/Discord/Spotify) | ✅ | ✅ | `routes_auth.go:122-176`. |
| 6 | Profils utilisateur + slug / username | ✅ | ✅ | `profile_handler.go:102`. |
| 7 | Upload de tracks | 🟡 | 🟡 | ClamAV sync ✅ (fail-secure par défaut, `upload_validator.go:87-88`). **Stockage local disque** (`track_upload_handler.go:376`). Dual trigger transcoding (gRPC + RabbitMQ) non doc. |
| 8 | CRUD Tracks / Library | ✅ | ✅ | List / filtres / pagination réels. Library filtrée sur `status=Completed`. |
| 9 | **Player + Queue + écoute audio** | ✅ | 🔴 | **🔴 → ✅** : `/tracks/:id/stream` avec Range (`routes_tracks.go:118-120`, `track_hls_handler.go:266`). Cache bypass wiré (`response_cache.go:87-104`). HLS optionnel, off par défaut. |
| 10 | Playlists (CRUD + share par token) | ✅ | ✅ | `playlist_handler.go:43`. |
| 11 | Queue collaborative (host-authority) | ✅ | ✅ | `queue_handler.go`. |
| 12 | Chat WebSocket (messages, typing, reactions, attachments) | ✅ | 🟡 | DB persist avant broadcast (`handler_messages.go:91-113`). 12 features wirées (edit/delete/typing/read/delivered/reactions/attachments/search/convos/channel/DM/calls). |
| 13 | Chat multi-instance | ✅ | 🟡 | **🟡 → ✅** : Redis pubsub + fallback in-memory **avec log ERROR loud** (`chat_pubsub.go:23-27, 48`). Plus de silent fail. |
| 14 | WebRTC 1:1 calls | 🟡 | 🟡 | Signaling ✅ (`handler.go:89-98`). **STUN/TURN absent** — pas d'env var, pas de grep hit. NAT symétrique = call HS. |
| 15 | Co-listening (listen-together) | ✅ | ✅ | `colistening/hub.go:104-148`, host-authority, keepalive 30s. |
| 16 | **Livestream (RTMP ingest)** | ✅ | 🟡 | **🟡 → ✅** : `/api/v1/live/health` (`live_health_handler.go:78-96`) + banner UI (`useLiveHealth.ts:41-61`, commit `64fa0c9ac`). Plus de silent OBS fail. |
| 17 | Livestream viewer playback | ✅ | ✅ | HLS via nginx-rtmp (`live_stream_callback.go:66`). URL dans `streamURL`. |
| 18 | Dashboard | ✅ | ✅ | `/api/v1/dashboard`. |
| 19 | Recherche (unifiée + tracks) | ✅ | ✅ | `search_handlers.go:41` — ES puis fallback Postgres LIKE + pg_trgm. |
| 20 | Social / Feed / Posts / Groups | ✅ | ✅ | `social.go:161`, chronologique. |
| 21 | Discover (genres/tags déclaratifs) | ✅ | ✅ | `discover.go:49-63`. |
| 22 | Presence + rich presence | ✅ | ✅ | `presence_handler.go:30-46`. |
| 23 | Notifications + Web Push | ✅ | ✅ | `notification_handlers.go:197`. |
| 24 | **Marketplace + checkout** | ✅ | 🟡 | Hyperswitch wiré (`service.go:522-548`). **Simulated payment si dev** (`:550-586`) **mais `Config.Validate` refuse prod sans Hyperswitch** (`config.go:908-910`). Cart côté server ✅. |
| 25 | **Refund (reverse-charge)** | ✅ | 🟡 | **🟡 → ✅** : 3 phases avec idempotency-key `refund.ID` (`service.go:1297-1436`, commits `4f15cfbd9` `959031667`). Webhook handler wiré. |
| 26 | Hyperswitch reconciliation sweep | ✅ | ⚫ | **⚫ → ✅** (nouveauté v1.0.7) : `reconcile_hyperswitch.go:55-150` couvre stuck orders/refunds/orphans, 10 tests green. |
| 27 | Webhook raw payload audit log | ✅ | ⚫ | **⚫ → ✅** (v1.0.7) : `webhook_log.go:34-80` + cleanup 90j (`cleanup_hyperswitch_webhook_log.go`). |
| 28 | Ledger-health metrics + alerts | ✅ | ⚫ | **⚫ → ✅** (v1.0.7 item F) : 5 gauges Prometheus + 3 alert rules Alertmanager + dashboard Grafana. |
| 29 | Seller dashboard + Stripe Connect payout | ✅ | ✅ | `sell_handler.go`, transfer auto post-webhook. |
| 30 | **Stripe Connect reversal (async)** | ✅ | 🟡 | **🟡 → ✅** (v1.0.7 items A+B) : `reversal_worker.go:12-180`, state machine `reversal_pending`, `stripe_transfer_id` persisté, exp. backoff 1m→1h. |
| 31 | Reviews / Factures | ✅ | ✅ | DB + handlers wirés. |
| 32 | Subscription plans | ✅ | 🟡 | **🟡 → ✅** (v1.0.6.2 hotfix `d31f5733d`) : `hasEffectivePayment()` gate (`subscription/service.go:140-155`). Plus de bypass. |
| 33 | Distribution plateformes externes | ✅ | ✅ | `distribution_handler.go:32-62`. |
| 34 | Formation / Education | ✅ | ✅ | `education_handler.go:33` — DB-backed. |
| 35 | Support tickets | ✅ | ✅ | `support_handler.go:54-100`. |
| 36 | Developer portal (API keys + webhooks) | ✅ | ✅ | `routes_developer.go:11`. |
| 37 | Analytics (creator stats) | ✅ | ✅ | `playback_analytics_handler.go`, CSV/JSON export. |
| 38 | Admin — dashboard / users / modération / flags / audit | ✅ | 🟡 | `admin/handler.go:43-54`. **Maintenance mode 🟡 → ✅** via `platform_settings` + TTL 10s (`middleware/maintenance.go:16-100`, commit `3a95e38fd`). |
| 39 | Admin — transfers (v0.701) | ✅ | ✅ | `admin_transfer_handler.go:36-91`. |
| 40 | Self-service creator role upgrade | ✅ | ⚫ | **⚫ → ✅** (commit `c32278dc1`) : `POST /users/me/upgrade-creator` gate email-verified, idempotent. |
| 41 | Upload-size SSOT | ✅ | ⚫ | **⚫ → ✅** (commit `5848c2e40`) : `config/upload_limits.go` + `GET /api/v1/upload/limits` consommé par `useUploadLimits` côté web. |
| 42 | Tag suggestions | ✅ | ✅ | `tag_handler.go:15-32`. |
| 43 | PWA (install + service worker + wake lock) | ✅ | ✅ | `components/pwa/`, v0.801. |
| 44 | Orphan tracks cleanup | ✅ | ⚫ | **⚫ → ✅** (commit `553026728`) : `jobs/cleanup_orphan_tracks.go`, hourly, flip `processing`→`failed` si fichier disque manquant. |
| 45 | Stem upload & sharing (F482) | ✅ | ✅ | `routes_tracks.go:185-189`, ownership guard. |
**Score** : 43 ✅ / 2 🟡 / 0 🔴 / 0 ⚫. La seule 🔴 de la v1 (Player/écoute audio) est résolue.
**Les 2 🟡 restants** : **Upload** (stockage local disque → pas prêt pour production scale) et **WebRTC 1:1** (pas de STUN/TURN → NAT traversal HS).
---
## 2. Les 6 parcours — étape par étape
### Parcours 1 — Écouter de la musique
**Verdict : ✅ OPÉRATIONNEL.** Le bloqueur v1 est résolu — le fallback direct stream existe.
| # | Étape | Verdict | Preuve |
| --- | ------------------------ | :-----: | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Créer un compte | ✅ | `POST /auth/register``core/auth/service.go:104-469`. `IsVerified: false` (`:200`), token en DB. |
| 2 | Recevoir l'email | ✅ | MailHog par défaut dans `docker-compose.dev.yml:114-130`. UI sur port 8025. Prod : 500 hard si SMTP down (`service.go:387`). |
| 3 | Cliquer le lien verify | ✅ | `POST /auth/verify-email?token=X``core/auth/service.go:747-765` check token + flip `is_verified=true`. |
| 4 | Se connecter | ✅ | `POST /auth/login` → 403 Forbidden si `!IsVerified` (`service.go:527`). Lockout après 5 tentatives / 15 min. |
| 5 | Chercher un morceau | ✅ | `GET /api/v1/search``search_handlers.go:41`, ES ou fallback Postgres tsvector. |
| 6 | Lancer la lecture | ✅ | Player React tente HLS d'abord (`playerService.ts:283-293`), fallback direct `/stream`. |
| 7 | **Le son sort ?** | ✅ | `GET /tracks/:id/stream` avec `http.ServeContent` (`track_hls_handler.go:266`), Range supporté, cache bypass wiré (`response_cache.go:87-104`). |
**Piège dev** : si on upload un fichier mais que le transcoding (Rust stream server) échoue, le track reste en `Processing`. Le cleanup worker hourly le flippera à `Failed` après 1h. Le fichier **reste lisible via `/stream`** pendant ce temps, mais il n'apparaît pas en library (filtre `status=Completed`).
### Parcours 2 — Uploader un morceau (artiste)
**Verdict : ✅ MAIS sur local disque.**
| # | Étape | Verdict | Preuve |
| --- | --------------------------- | :-----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Login | ✅ | Comme parcours 1. |
| 2 | Upgrade creator (si besoin) | ✅ | `POST /api/v1/users/me/upgrade-creator` — gate email-verified, idempotent (`upgrade_creator_handler.go`). UI `AccountSettingsCreatorCard.tsx`. |
| 3 | Uploader un fichier audio | ✅ | `POST /api/v1/tracks/upload``track_upload_handler.go:39-171`. Multipart, taille SSOT (`config/upload_limits.go`), ClamAV **sync** fail-secure. |
| 4 | Stockage physique | 🟡 | **`uploads/tracks/<userID>/<filename>` sur disque local** (`track_upload_handler.go:376`). Code S3/MinIO présent mais **non wiré** dans ce chemin. |
| 5 | Transcoding | 🟡 | **Dual-trigger** : gRPC Rust stream server (`stream_service.go:49`) **et** RabbitMQ job (`EnqueueTranscodingJob`). Redondance non documentée. |
| 6 | Track visible en library | ✅ | Après `status=Completed`. Avant : utilisateur voit son upload en "Processing" dans son tableau de bord. |
| 7 | Autre user peut trouver/lire| ✅ | Via search + parcours 1. Si track reste `Processing` (transcoding down) → pas en library mais `/tracks/:id/stream` sert quand même le raw. |
### Parcours 3 — Acheter sur le marketplace
**Verdict : ✅ (sandbox testing) + solidifiés massivement depuis v1.**
| # | Étape | Verdict | Preuve |
| --- | ---------------------------------- | :-----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | Browse produits | ✅ | `GET /api/v1/marketplace/products`, handlers DB réels. |
| 2 | Ajouter au panier | ✅ | `POST /api/v1/cart/items``cart.go:25-97`, DB-backed (table `cart_items`). |
| 3 | Checkout | ✅ | `POST /api/v1/orders``service.go:522-548` (prod flow Hyperswitch) ou `:550-586` (dev simulated). |
| 4 | **Paiement Hyperswitch** | ✅ | `paymentProvider.CreatePayment()` avec `Idempotency-Key: order.ID` (commit `4f15cfbd9`). Retourne `client_secret` consommé par `CheckoutPaymentForm.tsx`. |
| 5 | Webhook paiement | ✅ | `POST /api/v1/webhooks/hyperswitch` → raw payload logged (`webhook_log.go`), signature HMAC-SHA512 vérifiée, dispatcher `ProcessPaymentWebhook`. |
| 6 | Reconciliation si webhook perdu | ✅ | `reconcile_hyperswitch.go` sweep stuck orders > 30m avec payment_id non vide, synthèse webhook → `ProcessPaymentWebhook`. Idempotent. Configurable `RECONCILE_INTERVAL=1h` (5m pendant incident). |
| 7 | Confirmation + accès contenu | ✅ | Création licenses dans la transaction (`service.go:561-585`), lock `FOR UPDATE` pour exclusive. |
| 8 | Remboursement | ✅ | 3-phase `service.go:1297-1436` : pending row → `CreateRefund` PSP → persist `hyperswitch_refund_id`. Webhook `refund.succeeded` révoque licenses + débite vendeur. |
| 9 | Reverse-charge Stripe Connect | ✅ | `reversal_worker.go:12-180`, state `reversal_pending`, async, backoff 1m→1h. Rows pré-v1.0.7 sans `stripe_transfer_id``permanently_failed` avec message explicite. |
**Piège prod** : `HYPERSWITCH_ENABLED=false` = dev bypass. **Garde-fou** : `Config.Validate` refuse de booter en prod si `HYPERSWITCH_ENABLED=false` (`config.go:908-910`) — message explicite "marketplace orders complete without charging, effectively giving away products". Fail-closed au bon endroit.
### Parcours 4 — Chat
**Verdict : ✅ sur toutes les surfaces.**
| # | Étape | Verdict | Preuve |
| --- | ------------------------------- | :-----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | Ouvrir le chat | ✅ | `apps/web/src/features/chat/pages/ChatPage.tsx`. |
| 2 | Rejoindre / créer une room | ✅ | `POST /api/v1/conversations``CreateRoom:54`. |
| 3 | Envoyer un message | ✅ | WS dispatcher `handler.go:54-106``HandleSendMessage:18` → DB **avant** broadcast (`handler_messages.go:91-113`). |
| 4 | Recevoir (temps réel) | ✅ | Hub local, puis PubSub pour multi-instance. |
| 5 | Persistance | ✅ | `chat_messages` table, indexed. |
| 6 | Multi-instance sans Redis | ✅ | Fallback in-memory **avec log ERROR loud** ("Redis unavailable, cross-instance messages will be lost") (`chat_pubsub.go:23-27`). Plus de silent fail. |
| 7 | Typing / reactions / attach. | ✅ | 12 features wirées (voir §1 ligne 12). |
### Parcours 5 — Livestream
**Verdict : ✅ avec banner UI si RTMP down.**
| # | Étape | Verdict | Preuve |
| --- | ------------------------ | :-----: | ------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Démarrer un live | ✅ | `POST /api/v1/live/streams``live_stream_handler.go:71-98`, génère `stream_key` UUID + `rtmp_url`. |
| 2 | Push OBS → nginx-rtmp | ✅ | `on_publish` callback `live_stream_callback.go:38-80` avec secret `X-RTMP-Callback-Secret`, flip `is_live=true`. |
| 3 | Health check visible | ✅ | `GET /api/v1/live/health` (`live_health_handler.go:78-96`) + poll 15s front (`useLiveHealth.ts:41-61`). Banner warn si `rtmp_reachable=false`.|
| 4 | Viewer play live | ✅ | HLS via nginx-rtmp (`streamURL` = `baseURL + /{streamKey}/playlist.m3u8`). |
| 5 | Co-listening en parallèle| ✅ | Feature séparée, `colistening/hub.go:104-148`, host-authority sync 100ms drift threshold. |
**Piège** : nécessite `docker compose --profile live up` pour démarrer nginx-rtmp. Sans ça, banner red immédiat. Plus de silent fail comme en v1.
### Parcours 6 — Admin
**Verdict : ✅ complet avec persistance maintenance mode.**
| # | Étape | Verdict | Preuve |
| --- | ------------------------ | :-----: | ------------------------------------------------------------------------------------------------------------------------ |
| 1 | Accéder /admin | ✅ | Middleware JWT + role check, 2FA obligatoire. |
| 2 | Voir stats | ✅ | `admin/handler.go:43-54` `GetPlatformMetrics`. |
| 3 | Modérer (queue, bans) | ✅ | `moderation/handler.go:44` `GetModerationQueue`, ban/suspend wirés. |
| 4 | Gérer utilisateurs | ✅ | Admin handlers (user upgrade, role change). |
| 5 | Maintenance mode | ✅ | Persisté `platform_settings` (`middleware/maintenance.go:16-100`, TTL 10s). Survit au restart. **🟡 v1 → ✅ v2**. |
| 6 | Feature flags | ✅ | DB-backed. |
| 7 | Ledger health dashboard | ✅ | Grafana `config/grafana/dashboards/ledger-health.json` + 5 gauges + 3 alert rules (voir §1 ligne 28). |
| 8 | Admin transfers | ✅ | `admin_transfer_handler.go:36-91`, manual retry, state machine persistée. |
---
## 3. Carte des dépendances
### 3.1 Services — hard-required vs optionnels
| Service | Status | Comportement si down | Preuve |
| -------------------- | --------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
| **PostgreSQL** | 🔴 Hard-req | App panique au boot (`main.go:112-120`, migrations auto-run). | `db.Initialize()` + `RunMigrations()` fatal. |
| **Migrations** | 🔴 Auto | Appliquées au démarrage, boot fail si erreur SQL. | `database.go:234-256`. |
| **Redis** | 🟢 Dégradation | TokenBlacklist nil-safe. Chat PubSub fallback in-memory avec **log ERROR loud**. Rate limiter dégradé. | `chat_pubsub.go:23-27` ; `config.go:55-58`. |
| **RabbitMQ** | 🟢 Dégradation | EventBus publish failures maintenant **loggés ERROR** (commit `bf688af35`) au lieu de silent drop. | `main.go:128-139` ; `config.go:690-693`. |
| **MinIO / S3** | 🟢 Non utilisé | `AWS_S3_ENABLED=false` par défaut, **code S3 présent mais non wiré dans upload path**. Disque local always. | `config.go:697-720` ; `track_upload_handler.go:376`. |
| **Elasticsearch** | 🟢 Optionnel | Search fallback Postgres full-text search (tsvector + pg_trgm). ES non utilisé en chemin chaud. | `fulltext_search_service.go:14-30` ; `main.go:288-297` (cleanup only). |
| **ClamAV** | 🟠 Fail-secure | `CLAMAV_REQUIRED=true` par défaut → upload **rejeté** (503) si down. `=false` = bypass avec warning. | `upload_validator.go:87-88, 140-150` ; `services_init.go:27-46`. |
| **Hyperswitch** | 🟠 Prod-gate | `HYPERSWITCH_ENABLED=false` = dev bypass. **Prod : `Config.Validate` refuse boot** si false. | `config.go:908-910` ; `service.go:522-548, 550-586`. |
| **Stripe Connect** | 🟠 Prod-gate | Reversal worker tourne si config présente. Rows pre-v1.0.7 sans id → `permanently_failed`. | `reversal_worker.go:12-180` ; `main.go:188`. |
| **Nginx-RTMP** | 🟢 Profil live | `docker compose --profile live up`. Si down : banner UI immédiat sur Go Live page. | `live_health_handler.go:78-96` ; `useLiveHealth.ts:41-61`. |
| **Rust stream srv** | 🟢 Optionnel | HLS gated `HLSEnabled=false` default. Direct `/stream` fallback toujours disponible. Transcoding async. | `stream_service.go:49` ; `config.go:355` ; `track_hls_handler.go:266`. |
| **MailHog (SMTP)** | 🟢 Dev default | Branché `docker-compose.dev.yml:114-130`, port 1025. Dev : fail email → log + continue. Prod : 500 hard. | `.env.template:160-165` ; `service.go:381-407`. |
**Résumé** : **3 hard-required** (Postgres, migrations, bcrypt) · **le reste est optionnel avec fallback, fail-secure, ou prod-gate explicite**. C'est l'évolution la plus importante depuis v1 : il n'y a plus de silent failures non documentés.
### 3.2 Seeding
- `veza-backend-api/cmd/tools/seed/main.go` : modes `production` / `full` / `smoke`. Truncate tables → insert users → tracks → playlists → social → chat. **Manuel**, pas auto-run. Marche.
---
## 4. Stabilité — points de fragilité restants
| # | Fragilité | Impact | Preuve |
| -- | ------------------------------------------- | :-----: | --------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **WebRTC 1:1 sans STUN/TURN** | 🟡 Prod | Pas d'env var, pas de grep hit. NAT symétrique = call failures silencieuses (les signals passent, mais le flux média échoue). |
| 2 | **Stockage local disque only** | 🟡 Prod | `uploads/tracks/<userID>/` sur FS local. Pas scalable multi-pod sans volume partagé. Le code S3/MinIO est dead in upload path. |
| 3 | **HLS `HLSEnabled=false` par défaut** | 🟢 Dev | Fonctionnel grâce au fallback `/stream`. Pas d'adaptive bitrate out-of-box. Opérateur doit activer explicitement. |
| 4 | **Transcoding dual-trigger** | 🟡 Ops | `StreamService.StartProcessing` (gRPC) **et** `EnqueueTranscodingJob` (RabbitMQ) appelés tous les deux. Redondance non documentée. |
| 5 | **`HLS_STREAMING` absent de .env.template** | 🟠 Doc | Dev qui veut HLS doit trouver la var ailleurs. `.env.template` à compléter. |
| 6 | **Dev bypass Hyperswitch** | 🟢 Ops | Fail-closed prod (`Config.Validate`), mais en staging un opérateur distrait peut servir des licences gratuites. Mettre un warning loud au boot. |
| 7 | **Email tokens en query param** | 🟠 Sec | `?token=X` peut leak via Referer / logs proxy. Migration flagged v0.2 (commentaire `handlers/auth.go` L339). |
| 8 | **Register issue JWT avant email send** | 🟠 UX | User a ses tokens avant que l'email parte → login 403 immédiat tant que non-vérifié. Cohérent mais friction. |
| 9 | **ClamAV 10s timeout sync** | 🟢 UX | Upload bloque jusqu'à 10s sur scan. Acceptable pour fichiers audio <100MB. |
| 10 | **Subscription `pending_payment` item G** | 🟢 Roadm| v1.0.6.2 compense via filter, item G dans v107-plan refait le path proprement. Pas un bug, juste techdebt flaggée. |
**Zero silent fails** parmi les 6 surfaces critiques (Chat Redis, RabbitMQ, RTMP, HLS, SMTP, Hyperswitch). C'est le grand changement depuis v1.
---
## 5. Verdict final
**Veza v1.0.7-rc1 est prêt pour :**
- ✅ **Démo publique contrôlée** — un pod, infra dev `make dev`, Hyperswitch sandbox. Le parcours "register → verify → search → play → upload → purchase → refund" est intégralement opérationnel.
- ✅ **Sandbox payment testing** — refund réel, reconciliation, ledger-health gauges, Stripe Connect reversal. Toute la plomberie monétaire est audit-ready.
- ✅ **Beta privée multi-utilisateurs** — chat multi-instance avec alarme loud si Redis manque, co-listening host-authority, livestream avec health banner. Pas de silent fails.
**Veza v1.0.7-rc1 n'est PAS prêt pour :**
- 🟡 **Production publique grand-public scale** — le stockage uploads sur disque local ne survit pas à un second pod. MinIO/S3 doit être wiré dans le path upload (le code dort, il faut juste l'appeler).
- 🟡 **Calls WebRTC fiables hors LAN** — sans STUN/TURN, symmetric NAT = échec silencieux du flux média. À configurer avant d'ouvrir la feature calls au public.
- 🟠 **Opérateur ops naïf** — le dashboard Grafana ledger-health est là mais ne sert à rien si personne ne le regarde. Nécessite un runbook d'exploitation.
**Ce qui a changé depuis la v1 du 2026-04-16** — en 3 jours, l'équipe a fermé **7 findings 🔴/🟡** et ajouté **10 nouvelles capacités** (reconciliation, audit log webhook, ledger metrics, reversal async, upgrade creator, upload SSOT, RTMP health, orphan cleanup, maintenance persist, SMTP unified). Voir §6.
**En une phrase** : **le code est solide, la plomberie est honnête, les seuls 🟡 restants sont des features "scale" (storage, NAT) pas des bugs**.
---
## 6. Diff vs audit v1 (2026-04-16)
Tableau des évolutions : chaque ligne = un finding v1 avec son statut aujourd'hui.
| Finding v1 | v1 | v2 | Commit / Preuve |
| ---------------------------------------------------------- | :-: | :-: | ------------------------------------------------------------------------------------------------------ |
| Player/écoute audio sans fallback (HLSEnabled=false) | 🔴 | ✅ | Endpoint direct `/tracks/:id/stream` + Range cache bypass. `b875efcff`, `routes_tracks.go:118-120`. |
| Register : `IsVerified: true` hardcoded | 🔴 | ✅ | `service.go:200``IsVerified: false`. Commit trail. |
| Verify email : dead code | 🔴 | ✅ | Endpoint actif, login 403 sur unverified (`service.go:527`). |
| SMTP silent fail | 🟡 | ✅ | Env schema unifié (`066144352`). Prod : 500 hard. Dev : log + continue. MailHog branché par défaut. |
| Marketplace dev bypass | 🟡 | ✅ | Prod gate `Config.Validate` refuse boot (`config.go:908-910`). Dev bypass conservé, assumé. |
| Refund : row DB only, pas de reverse-charge | 🟡 | ✅ | 3-phase avec idempotency key. `959031667`, `4f15cfbd9`, `service.go:1297-1436`. |
| Subscription : payment gate bypass | 🟡 | ✅ | v1.0.6.2 hotfix `d31f5733d`, `hasEffectivePayment()`. |
| Chat multi-instance silent fallback | 🟡 | ✅ | Redis missing = **log ERROR loud** (`chat_pubsub.go:23-27`). Fallback conservé pour single-pod dev. |
| Livestream : dépendance cachée `--profile live` | 🟡 | ✅ | Health endpoint + banner UI (`64fa0c9ac`, `live_health_handler.go:78-96`). |
| Maintenance mode in-memory | 🟡 | ✅ | Persisté `platform_settings` + TTL 10s. `3a95e38fd`, `middleware/maintenance.go:16-100`. |
| Tracks orphelines `Processing` indéfiniment | 🟡 | ✅ | Cleanup hourly worker. `553026728`, `jobs/cleanup_orphan_tracks.go`. |
| RabbitMQ silent drop | 🟡 | ✅ | Log ERROR sur publish failure. `bf688af35`. |
| Upload size limits désalignés front/back | 🟠 | ✅ | SSOT `config/upload_limits.go` + hook `useUploadLimits`. `5848c2e40`. |
| Stripe Connect reversal inexistant | 🔵 | ✅ | Async worker + state machine `reversal_pending`. v1.0.7 items A+B. |
| Reconciliation Hyperswitch (stuck orders) | 🔵 | ✅ | `reconcile_hyperswitch.go:55-150`. v1.0.7 item C. |
| Webhook raw payload audit log | 🔵 | ✅ | `webhook_log.go` + cleanup 90j. v1.0.7 item E. |
| Ledger-health metrics + alerts | 🔵 | ✅ | 5 gauges Prometheus + 3 alert rules + Grafana dashboard. v1.0.7 item F. |
| Idempotency-key Hyperswitch | 🔵 | ✅ | Sur CreatePayment + CreateRefund. v1.0.7 item D (`4f15cfbd9`). |
| Self-service creator upgrade | 🔵 | ✅ | `POST /users/me/upgrade-creator`, email-verified gate. `c32278dc1`. |
| WebRTC sans STUN/TURN | 🟡 | 🟡 | **Toujours pas fixé.** Signaling ok, NAT traversal non. |
| Stockage uploads sur disque local | 🟡 | 🟡 | **Toujours pas fixé.** Code S3 présent, non wiré. |
| HLS `HLSEnabled=false` par défaut | 🔴 | 🟢 | Plus bloquant grâce au fallback direct stream, mais flag toujours off. |
Légende : 🔵 = finding absent de v1 mais identifié ici, 🟢 = non-bloquant en v2, 🟠 = doc/cleanup.
**Bilan** : **18 findings v1 résolus**, **2 subsistants** (WebRTC TURN, stockage local). **7 nouvelles capacités ajoutées** (reconcil, audit log, ledger metrics, reversal, upgrade creator, upload SSOT, RTMP health). Le "chemin critique v1.0.5 public-ready" listé en v1 est **intégralement réalisé** par v1.0.5 → v1.0.7-rc1.
---
*Généré par Claude Code Opus 4.7 (1M context, /effort max, /plan) — 5 agents Explore parallèles + vérifications ponctuelles directes (`routes_tracks.go:118`, `core/auth/service.go:200`, `config.go:355/907-910`, `marketplace/service.go:522-586`). Cross-référencé avec `docs/audit-2026-04/v107-plan.md` et `CHANGELOG.md` v1.0.5 → v1.0.7-rc1. Une correction par rapport à v1 : le Player n'est plus 🔴 — la v1 avait loupé l'endpoint `/stream` (fallback direct avec Range support).*

12
apps/web/.size-limit.json Normal file
View file

@ -0,0 +1,12 @@
[
{
"path": "dist/assets/index-*.js",
"limit": "300 KB",
"gzip": true
},
{
"path": "dist/assets/*.css",
"limit": "80 KB",
"gzip": true
}
]

View file

@ -0,0 +1,14 @@
import { CustomProjectConfig } from 'lost-pixel';
export const config: CustomProjectConfig = {
storybookShots: {
storybookUrl: './storybook-static',
},
lostPixelProjectId: 'veza-visual',
generateOnly: true,
failOnDifference: true,
threshold: 0.01,
imagePathBaseline: '.lostpixel/baselines',
imagePathCurrent: '.lostpixel/current',
imagePathDifference: '.lostpixel/difference',
};

View file

@ -0,0 +1,45 @@
/**
* Centralized data-testid constants for all UI components.
* Use these in components via `data-testid={TESTID.toast.root(type)}`.
* Use the E2E mirror in `tests/e2e/helpers/selectors.ts` for Playwright tests.
*/
export const TESTID = {
toast: {
root: (type: 'success' | 'error' | 'info') => `toast-${type}`,
message: 'toast-message',
close: 'toast-close',
},
dialog: {
root: 'dialog',
title: 'dialog-title',
close: 'dialog-close',
content: 'dialog-content',
footer: 'dialog-footer',
confirm: 'dialog-confirm',
cancel: 'dialog-cancel',
},
confirmationDialog: {
root: 'confirmation-dialog',
description: 'confirmation-description',
icon: 'confirmation-icon',
},
radioGroup: {
root: 'radio-group',
item: (value: string) => `radio-item-${value}`,
},
checkbox: {
root: 'checkbox',
input: 'checkbox-input',
label: 'checkbox-label',
},
sidebar: 'app-sidebar',
header: 'app-header',
player: 'global-player',
loginForm: 'login-form',
registerForm: 'register-form',
audioElement: 'audio-element',
searchInput: 'search-input',
volumeControl: 'volume-control',
trackCard: 'track-card',
playlistCard: 'playlist-card',
} as const;

View file

@ -0,0 +1,585 @@
/**
* Property-based tests for Zod schemas.
* Uses fast-check to fuzz validation schemas and verify invariants.
*/
import { describe, it, expect } from 'vitest';
import * as fc from 'fast-check';
import {
emailSchema,
passwordSchema,
usernameSchema,
loginSchema,
searchSchema,
paginationSchema,
chatMessageSchema,
validateForm,
} from '../validation';
import {
loginRequestSchema,
registerRequestSchema,
paginationParamsSchema,
safeValidateApiRequest,
} from '../apiRequestSchemas';
import {
uuidSchema,
isoDateSchema,
paginationDataSchema,
} from '../apiSchemas';
// ---------------------------------------------------------------------------
// Helpers: fast-check v4 compatible string arbitraries
// ---------------------------------------------------------------------------
/** Lowercase alpha strings */
function lowerAlpha(min: number, max: number) {
return fc.stringMatching(new RegExp(`^[a-z]{${min},${max}}$`));
}
/** Lowercase alphanumeric strings */
function lowerAlphaNum(min: number, max: number) {
return fc.stringMatching(new RegExp(`^[a-z0-9]{${min},${max}}$`));
}
/** Uppercase alpha strings */
function upperAlpha(min: number, max: number) {
return fc.stringMatching(new RegExp(`^[A-Z]{${min},${max}}$`));
}
/** Digit strings */
function digitStr(min: number, max: number) {
return fc.stringMatching(new RegExp(`^[0-9]{${min},${max}}$`));
}
/** Lowercase alpha + digits + special subset */
function lowerAlphaNumSpecial(min: number, max: number) {
return fc.stringMatching(new RegExp(`^[a-z0-9!@#]{${min},${max}}$`));
}
/** Lowercase alpha + digits + underscore */
function usernameChars(min: number, max: number) {
return fc.stringMatching(new RegExp(`^[a-z0-9_]{${min},${max}}$`));
}
/** Lowercase alpha + digits + space (for safe chat content) */
function safeChatContent(min: number, max: number) {
return fc.stringMatching(new RegExp(`^[a-z0-9 ]{${min},${max}}$`));
}
/** Arbitrary that generates valid UUIDs */
const validUuid = fc.uuid();
/** Arbitrary that generates valid ISO 8601 datetime strings */
const validIsoDate = fc.date({ min: new Date('2000-01-01'), max: new Date('2099-12-31') }).map(
(d) => d.toISOString(),
);
/** Arbitrary that generates valid email-shaped strings */
const validEmail = fc
.tuple(
lowerAlphaNum(1, 20),
lowerAlpha(2, 10),
fc.constantFrom('com', 'org', 'net', 'io', 'fr'),
)
.map(([local, domain, tld]) => `${local}@${domain}.${tld}`);
/**
* Arbitrary that generates valid passwords (meets validation.ts: min 8 chars).
* Must satisfy:
* - At least 1 uppercase, 1 lowercase, 1 digit, 1 special
* - No 4+ repeating characters (.)\1{3,}
* - No common patterns (123456, password, qwerty)
* - Total length >= 8
* We use a fixed template to guarantee these constraints.
*/
const validPassword = fc
.tuple(
fc.constantFrom('Abc', 'Def', 'Ghj', 'Kmn', 'Pqr', 'Stu', 'Wxy'),
fc.constantFrom('12', '34', '56', '78', '90', '27'),
fc.constantFrom('xyz', 'wuv', 'mnp', 'qrs', 'bcd', 'efg'),
fc.constantFrom('!', '@', '#', '$', '%', '^'),
)
.map(([mixed1, digits, mixed2, special]) => `${mixed1}${digits}${mixed2}${special}`);
/**
* Arbitrary that generates valid passwords for apiRequestSchemas (min 12 chars).
* Uses a template approach to guarantee all constraints.
*/
const validApiPassword = fc
.tuple(
fc.constantFrom('AbCd', 'EfGh', 'JkMn', 'PqRs', 'TuWx'),
fc.constantFrom('123', '456', '789', '270', '839'),
fc.constantFrom('efgh', 'ijkl', 'mnop', 'qrst', 'uvwx'),
fc.constantFrom('!@', '@#', '#$', '$%'),
)
.map(([mixed, digits, lower, special]) => `${mixed}${digits}${lower}${special}`);
/** Arbitrary that generates valid usernames */
const validUsername = usernameChars(3, 30)
.filter((u) => !/^(admin|root|user|test|demo)$/i.test(u));
// ---------------------------------------------------------------------------
// UUID Schema
// ---------------------------------------------------------------------------
describe('property: uuidSchema', () => {
it('accepts all valid v4 UUIDs', () => {
fc.assert(
fc.property(validUuid, (uuid) => {
expect(uuidSchema.safeParse(uuid).success).toBe(true);
}),
);
});
it('rejects random non-UUID strings', () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 50 }).filter((s) => !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s)),
(s) => {
expect(uuidSchema.safeParse(s).success).toBe(false);
},
),
);
});
});
// ---------------------------------------------------------------------------
// ISO Date Schema
// ---------------------------------------------------------------------------
describe('property: isoDateSchema', () => {
it('accepts valid ISO 8601 datetime strings', () => {
fc.assert(
fc.property(validIsoDate, (dateStr) => {
expect(isoDateSchema.safeParse(dateStr).success).toBe(true);
}),
);
});
it('rejects plain English dates', () => {
fc.assert(
fc.property(
fc.constantFrom('yesterday', 'tomorrow', 'last week', 'January 1st'),
(s) => {
expect(isoDateSchema.safeParse(s).success).toBe(false);
},
),
);
});
});
// ---------------------------------------------------------------------------
// Email Schema (validation.ts)
// ---------------------------------------------------------------------------
describe('property: emailSchema (validation.ts)', () => {
it('accepts well-formed emails', () => {
fc.assert(
fc.property(validEmail, (email) => {
expect(emailSchema.safeParse(email).success).toBe(true);
}),
);
});
it('rejects strings without @ symbol', () => {
fc.assert(
fc.property(lowerAlpha(1, 30), (s) => {
expect(emailSchema.safeParse(s).success).toBe(false);
}),
);
});
it('rejects empty strings', () => {
expect(emailSchema.safeParse('').success).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Password Schema
// ---------------------------------------------------------------------------
describe('property: passwordSchema', () => {
it('accepts valid passwords', () => {
fc.assert(
fc.property(validPassword, (pwd) => {
expect(passwordSchema.safeParse(pwd).success).toBe(true);
}),
);
});
it('rejects passwords under 8 characters', () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 7 }),
(s) => {
expect(passwordSchema.safeParse(s).success).toBe(false);
},
),
);
});
it('rejects passwords without uppercase', () => {
fc.assert(
fc.property(lowerAlphaNumSpecial(8, 20), (s) => {
expect(passwordSchema.safeParse(s).success).toBe(false);
}),
);
});
});
// ---------------------------------------------------------------------------
// Username Schema
// ---------------------------------------------------------------------------
describe('property: usernameSchema', () => {
it('accepts valid usernames', () => {
fc.assert(
fc.property(validUsername, (u) => {
expect(usernameSchema.safeParse(u).success).toBe(true);
}),
);
});
it('rejects usernames shorter than 3 characters', () => {
fc.assert(
fc.property(lowerAlpha(1, 2), (u) => {
expect(usernameSchema.safeParse(u).success).toBe(false);
}),
);
});
it('rejects usernames with special characters', () => {
fc.assert(
fc.property(
fc.tuple(
lowerAlpha(2, 10),
fc.constantFrom('!', '@', '#', '$', ' ', '.', ','),
lowerAlpha(1, 10),
),
([prefix, special, suffix]) => {
expect(usernameSchema.safeParse(`${prefix}${special}${suffix}`).success).toBe(false);
},
),
);
});
it('rejects reserved usernames', () => {
fc.assert(
fc.property(
fc.constantFrom('admin', 'root', 'user', 'test', 'demo', 'ADMIN', 'Root'),
(reserved) => {
expect(usernameSchema.safeParse(reserved).success).toBe(false);
},
),
);
});
});
// ---------------------------------------------------------------------------
// Login Schema
// ---------------------------------------------------------------------------
describe('property: loginSchema', () => {
it('accepts valid login data', () => {
fc.assert(
fc.property(validEmail, validPassword, (email, password) => {
const result = loginSchema.safeParse({ email, password });
expect(result.success).toBe(true);
}),
);
});
it('rejects when email is missing', () => {
fc.assert(
fc.property(validPassword, (password) => {
expect(loginSchema.safeParse({ password }).success).toBe(false);
}),
);
});
it('rejects when password is missing', () => {
fc.assert(
fc.property(validEmail, (email) => {
expect(loginSchema.safeParse({ email }).success).toBe(false);
}),
);
});
});
// ---------------------------------------------------------------------------
// Login Request Schema (apiRequestSchemas)
// ---------------------------------------------------------------------------
describe('property: loginRequestSchema', () => {
it('accepts valid login request data', () => {
fc.assert(
fc.property(validEmail, fc.string({ minLength: 1, maxLength: 100 }), (email, password) => {
const result = loginRequestSchema.safeParse({ email, password });
expect(result.success).toBe(true);
}),
);
});
it('rejects empty password', () => {
fc.assert(
fc.property(validEmail, (email) => {
expect(loginRequestSchema.safeParse({ email, password: '' }).success).toBe(false);
}),
);
});
});
// ---------------------------------------------------------------------------
// Register Request Schema
// ---------------------------------------------------------------------------
describe('property: registerRequestSchema', () => {
it('accepts valid registration data', () => {
fc.assert(
fc.property(
validUsername,
validEmail,
validApiPassword,
(username, email, password) => {
const result = registerRequestSchema.safeParse({ username, email, password });
expect(result.success).toBe(true);
},
),
);
});
});
// ---------------------------------------------------------------------------
// Pagination Params Schema
// ---------------------------------------------------------------------------
describe('property: paginationParamsSchema', () => {
it('accepts valid pagination params', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 1000 }),
fc.integer({ min: 1, max: 100 }),
(page, limit) => {
expect(paginationParamsSchema.safeParse({ page, limit }).success).toBe(true);
},
),
);
});
it('rejects zero or negative page', () => {
fc.assert(
fc.property(fc.integer({ min: -100, max: 0 }), (page) => {
expect(paginationParamsSchema.safeParse({ page, limit: 10 }).success).toBe(false);
}),
);
});
it('rejects limit over 100', () => {
fc.assert(
fc.property(fc.integer({ min: 101, max: 10000 }), (limit) => {
expect(paginationParamsSchema.safeParse({ page: 1, limit }).success).toBe(false);
}),
);
});
it('accepts empty object (all optional)', () => {
expect(paginationParamsSchema.safeParse({}).success).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Pagination Data Schema (apiSchemas)
// ---------------------------------------------------------------------------
describe('property: paginationDataSchema', () => {
it('accepts valid pagination data', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 1000 }),
fc.integer({ min: 1, max: 100 }),
fc.integer({ min: 0, max: 100000 }),
fc.boolean(),
fc.boolean(),
(page, limit, total, hasNext, hasPrev) => {
const totalPages = Math.ceil(total / limit);
const data = {
page,
limit,
total,
total_pages: totalPages,
has_next: hasNext,
has_prev: hasPrev,
};
expect(paginationDataSchema.safeParse(data).success).toBe(true);
},
),
);
});
it('rejects negative total', () => {
expect(
paginationDataSchema.safeParse({
page: 1,
limit: 10,
total: -1,
total_pages: 0,
has_next: false,
has_prev: false,
}).success,
).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Search Schema
// ---------------------------------------------------------------------------
describe('property: searchSchema', () => {
it('accepts valid search queries', () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 100 }),
(query) => {
expect(searchSchema.safeParse({ query }).success).toBe(true);
},
),
);
});
it('rejects empty query', () => {
expect(searchSchema.safeParse({ query: '' }).success).toBe(false);
});
it('rejects query over 100 chars', () => {
fc.assert(
fc.property(
fc.string({ minLength: 101, maxLength: 200 }),
(query) => {
expect(searchSchema.safeParse({ query }).success).toBe(false);
},
),
);
});
it('accepts valid type filter values', () => {
fc.assert(
fc.property(
fc.constantFrom('all', 'users', 'tracks', 'conversations'),
fc.string({ minLength: 1, maxLength: 50 }),
(type, query) => {
expect(searchSchema.safeParse({ query, type }).success).toBe(true);
},
),
);
});
});
// ---------------------------------------------------------------------------
// Pagination Schema (validation.ts)
// ---------------------------------------------------------------------------
describe('property: paginationSchema', () => {
it('accepts valid pagination params', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 10000 }),
fc.integer({ min: 1, max: 100 }),
fc.constantFrom('asc', 'desc') as fc.Arbitrary<'asc' | 'desc'>,
(page, limit, sortOrder) => {
expect(paginationSchema.safeParse({ page, limit, sortOrder }).success).toBe(true);
},
),
);
});
});
// ---------------------------------------------------------------------------
// Chat Message Schema
// ---------------------------------------------------------------------------
describe('property: chatMessageSchema', () => {
it('accepts valid chat messages', () => {
fc.assert(
fc.property(
safeChatContent(1, 200),
validUuid,
(content, conversationId) => {
expect(
chatMessageSchema.safeParse({ content, conversationId }).success,
).toBe(true);
},
),
);
});
it('rejects empty content', () => {
expect(
chatMessageSchema.safeParse({ content: '', conversationId: '00000000-0000-0000-0000-000000000000' }).success,
).toBe(false);
});
it('rejects content over 2000 characters', () => {
const longContent = 'a'.repeat(2001);
expect(
chatMessageSchema.safeParse({
content: longContent,
conversationId: '00000000-0000-0000-0000-000000000000',
}).success,
).toBe(false);
});
it('rejects XSS payloads in content', () => {
const xssPayloads = [
'<script>alert(1)</script>',
'javascript:alert(1)',
'onclick=alert(1)',
];
for (const payload of xssPayloads) {
expect(
chatMessageSchema.safeParse({
content: payload,
conversationId: '00000000-0000-0000-0000-000000000000',
}).success,
).toBe(false);
}
});
});
// ---------------------------------------------------------------------------
// validateForm utility
// ---------------------------------------------------------------------------
describe('property: validateForm', () => {
it('returns success: true for valid data', () => {
fc.assert(
fc.property(validEmail, validPassword, (email, password) => {
const result = validateForm(loginSchema, { email, password });
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
expect(result.errors).toBeUndefined();
}),
);
});
it('returns success: false with errors for invalid data', () => {
fc.assert(
fc.property(fc.integer(), fc.integer(), (a, b) => {
const result = validateForm(loginSchema, { email: a, password: b });
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
}),
);
});
});
// ---------------------------------------------------------------------------
// safeValidateApiRequest utility
// ---------------------------------------------------------------------------
describe('property: safeValidateApiRequest', () => {
it('returns success for valid login requests', () => {
fc.assert(
fc.property(validEmail, fc.string({ minLength: 1, maxLength: 50 }), (email, password) => {
const result = safeValidateApiRequest(loginRequestSchema, { email, password });
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
}),
);
});
it('returns error for garbage input', () => {
fc.assert(
fc.property(fc.anything(), (data) => {
const result = safeValidateApiRequest(loginRequestSchema, data);
// If it somehow passes, that's fine; we just assert the shape is correct
if (!result.success) {
expect(result.error).toBeDefined();
}
}),
);
});
});

View file

@ -0,0 +1,441 @@
/**
* Property-based tests for utils/format.ts
* Uses fast-check to verify invariants across random inputs.
*/
import { describe, it, expect } from 'vitest';
import * as fc from 'fast-check';
import {
formatFileSize,
formatNumber,
formatPercentage,
truncate,
capitalize,
capitalizeWords,
slugify,
initials,
formatUsername,
formatEmail,
formatPhoneNumber,
formatBytes,
formatDuration,
formatList,
formatPlural,
} from '../format';
// ---------------------------------------------------------------------------
// Helpers: fast-check v4 compatible string arbitraries
// ---------------------------------------------------------------------------
/** Lowercase alpha strings of given length range */
function lowerAlpha(min: number, max: number) {
return fc.stringMatching(new RegExp(`^[a-z]{${min},${max}}$`));
}
/** Digit strings of exact or ranged length */
function digitString(min: number, max: number) {
return fc.stringMatching(new RegExp(`^[0-9]{${min},${max}}$`));
}
/** Lowercase alphanumeric + underscore strings */
function alphaNumUnderscore(min: number, max: number) {
return fc.stringMatching(new RegExp(`^[a-z0-9_]{${min},${max}}$`));
}
describe('property: formatFileSize', () => {
it('returns "0 Bytes" for zero', () => {
expect(formatFileSize(0)).toBe('0 Bytes');
});
it('always returns a string containing a size unit for positive integers', () => {
fc.assert(
fc.property(fc.integer({ min: 1, max: 1e15 }), (n) => {
const result = formatFileSize(n);
const units = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
expect(units.some((u) => result.includes(u))).toBe(true);
}),
);
});
it('produces a numeric prefix parseable as a number', () => {
fc.assert(
fc.property(fc.integer({ min: 1, max: 1e15 }), (n) => {
const result = formatFileSize(n);
const numericPart = result.split(' ')[0];
expect(Number.isNaN(Number(numericPart))).toBe(false);
}),
);
});
});
describe('property: formatNumber', () => {
it('returns the number as string for values below 1000', () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 999 }), (n) => {
expect(formatNumber(n)).toBe(n.toString());
}),
);
});
it('returns K suffix for thousands', () => {
fc.assert(
fc.property(fc.integer({ min: 1000, max: 999999 }), (n) => {
expect(formatNumber(n)).toMatch(/K$/);
}),
);
});
it('returns M suffix for millions', () => {
fc.assert(
fc.property(fc.integer({ min: 1000000, max: 999999999 }), (n) => {
expect(formatNumber(n)).toMatch(/M$/);
}),
);
});
it('returns B suffix for billions', () => {
fc.assert(
fc.property(fc.integer({ min: 1000000000, max: 9000000000 }), (n) => {
expect(formatNumber(n)).toMatch(/B$/);
}),
);
});
});
describe('property: formatPercentage', () => {
it('always ends with a % sign', () => {
fc.assert(
fc.property(fc.double({ min: 0, max: 1, noNaN: true }), (v) => {
expect(formatPercentage(v)).toMatch(/%$/);
}),
);
});
it('formats 0 as 0.0%', () => {
expect(formatPercentage(0)).toBe('0.0%');
});
it('formats 1 as 100.0%', () => {
expect(formatPercentage(1)).toBe('100.0%');
});
});
describe('property: truncate', () => {
it('never returns a string longer than maxLength', () => {
fc.assert(
fc.property(
fc.string({ minLength: 0, maxLength: 500 }),
fc.integer({ min: 4, max: 100 }),
(text, maxLen) => {
const result = truncate(text, maxLen);
expect(result.length).toBeLessThanOrEqual(maxLen);
},
),
);
});
it('returns the original string when shorter than maxLength', () => {
fc.assert(
fc.property(
fc.string({ minLength: 0, maxLength: 50 }),
(text) => {
const result = truncate(text, text.length + 10);
expect(result).toBe(text);
},
),
);
});
it('ends with the suffix when truncated', () => {
fc.assert(
fc.property(
fc.string({ minLength: 10, maxLength: 100 }),
(text) => {
const result = truncate(text, 5);
expect(result).toMatch(/\.\.\.$/);
},
),
);
});
});
describe('property: capitalize', () => {
it('first character is uppercase for non-empty ASCII strings', () => {
fc.assert(
fc.property(lowerAlpha(1, 50), (text) => {
const result = capitalize(text);
expect(result[0]).toBe(text[0].toUpperCase());
}),
);
});
it('rest of string is lowercase', () => {
fc.assert(
fc.property(lowerAlpha(2, 50), (text) => {
const result = capitalize(text);
expect(result.slice(1)).toBe(text.slice(1).toLowerCase());
}),
);
});
it('preserves string length', () => {
fc.assert(
fc.property(fc.string({ minLength: 1, maxLength: 100 }), (text) => {
expect(capitalize(text).length).toBe(text.length);
}),
);
});
});
describe('property: capitalizeWords', () => {
it('every word starts with uppercase for ASCII words', () => {
fc.assert(
fc.property(
fc.array(lowerAlpha(1, 10), { minLength: 1, maxLength: 5 }),
(words) => {
const text = words.join(' ');
const result = capitalizeWords(text);
const resultWords = result.split(' ');
resultWords.forEach((w) => {
expect(w[0]).toBe(w[0].toUpperCase());
});
},
),
);
});
});
describe('property: slugify', () => {
it('result contains only lowercase alphanumeric and hyphens', () => {
fc.assert(
fc.property(fc.string({ minLength: 1, maxLength: 100 }), (text) => {
const result = slugify(text);
expect(result).toMatch(/^[a-z0-9-]*$/);
}),
);
});
it('never starts or ends with a hyphen', () => {
fc.assert(
fc.property(fc.string({ minLength: 1, maxLength: 100 }), (text) => {
const result = slugify(text);
if (result.length > 0) {
expect(result[0]).not.toBe('-');
expect(result[result.length - 1]).not.toBe('-');
}
}),
);
});
it('never contains consecutive hyphens', () => {
fc.assert(
fc.property(fc.string({ minLength: 1, maxLength: 100 }), (text) => {
const result = slugify(text);
expect(result).not.toMatch(/--/);
}),
);
});
it('is idempotent', () => {
fc.assert(
fc.property(fc.string({ minLength: 1, maxLength: 100 }), (text) => {
const once = slugify(text);
const twice = slugify(once);
expect(twice).toBe(once);
}),
);
});
});
describe('property: initials', () => {
it('returns at most 2 characters', () => {
fc.assert(
fc.property(fc.string({ minLength: 1, maxLength: 100 }), (name) => {
expect(initials(name).length).toBeLessThanOrEqual(2);
}),
);
});
it('result is all uppercase for alpha word names', () => {
fc.assert(
fc.property(
fc.array(lowerAlpha(1, 10), { minLength: 1, maxLength: 3 }),
(words) => {
const name = words.join(' ');
const result = initials(name);
expect(result).toBe(result.toUpperCase());
},
),
);
});
});
describe('property: formatUsername', () => {
it('always starts with @', () => {
fc.assert(
fc.property(fc.string({ minLength: 1, maxLength: 30 }), (username) => {
expect(formatUsername(username)).toBe(`@${username}`);
}),
);
});
});
describe('property: formatEmail', () => {
it('obscures local part for emails with local part > 3 chars', () => {
fc.assert(
fc.property(
lowerAlpha(4, 20),
lowerAlpha(3, 10),
(local, domain) => {
const email = `${local}@${domain}.com`;
const result = formatEmail(email);
expect(result).toContain('***');
expect(result).toContain('@');
},
),
);
});
});
describe('property: formatPhoneNumber', () => {
it('formats 10-digit numbers with spaces', () => {
fc.assert(
fc.property(digitString(10, 10), (digits) => {
const result = formatPhoneNumber(digits);
const parts = result.split(' ');
expect(parts.length).toBe(5);
parts.forEach((part) => expect(part.length).toBe(2));
}),
);
});
it('returns original for non-10-digit strings', () => {
fc.assert(
fc.property(digitString(1, 9), (digits) => {
expect(formatPhoneNumber(digits)).toBe(digits);
}),
);
});
});
describe('property: formatBytes', () => {
it('returns "0 Bytes" for zero', () => {
expect(formatBytes(0)).toBe('0 Bytes');
});
it('always contains a size unit for positive integers', () => {
fc.assert(
fc.property(fc.integer({ min: 1, max: 1e15 }), (n) => {
const result = formatBytes(n);
const units = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
expect(units.some((u) => result.includes(u))).toBe(true);
}),
);
});
});
describe('property: formatDuration', () => {
it('always contains a colon', () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 86400 }), (seconds) => {
expect(formatDuration(seconds)).toContain(':');
}),
);
});
it('formats under-60s as 0:XX', () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 59 }), (seconds) => {
const result = formatDuration(seconds);
expect(result).toMatch(/^0:\d{2}$/);
}),
);
});
it('includes hours prefix for >= 3600 seconds', () => {
fc.assert(
fc.property(fc.integer({ min: 3600, max: 86400 }), (seconds) => {
const result = formatDuration(seconds);
expect(result.split(':').length).toBe(3);
}),
);
});
});
describe('property: formatList', () => {
it('returns empty string for empty array', () => {
expect(formatList([])).toBe('');
});
it('returns single item as-is', () => {
fc.assert(
fc.property(fc.string({ minLength: 1, maxLength: 50 }), (item) => {
expect(formatList([item])).toBe(item);
}),
);
});
it('contains the conjunction for 2+ items', () => {
fc.assert(
fc.property(
fc.array(fc.string({ minLength: 1, maxLength: 20 }), { minLength: 2, maxLength: 5 }),
(items) => {
const result = formatList(items);
expect(result).toContain('et');
},
),
);
});
it('contains all items in the result', () => {
fc.assert(
fc.property(
fc.array(lowerAlpha(3, 10), { minLength: 1, maxLength: 5 }),
(items) => {
const result = formatList(items);
items.forEach((item) => {
expect(result).toContain(item);
});
},
),
);
});
});
describe('property: formatPlural', () => {
it('uses singular for 0 and 1', () => {
fc.assert(
fc.property(
fc.constantFrom(0, 1),
fc.string({ minLength: 1, maxLength: 20 }),
(count, word) => {
expect(formatPlural(count, word)).toBe(`${count} ${word}`);
},
),
);
});
it('appends s by default for count >= 2', () => {
fc.assert(
fc.property(
fc.integer({ min: 2, max: 10000 }),
lowerAlpha(1, 20),
(count, word) => {
expect(formatPlural(count, word)).toBe(`${count} ${word}s`);
},
),
);
});
it('uses custom plural when provided', () => {
fc.assert(
fc.property(
fc.integer({ min: 2, max: 10000 }),
fc.string({ minLength: 1, maxLength: 20 }),
fc.string({ minLength: 1, maxLength: 20 }),
(count, singular, plural) => {
expect(formatPlural(count, singular, plural)).toBe(`${count} ${plural}`);
},
),
);
});
});

View file

@ -0,0 +1,26 @@
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
export default {
testRunner: 'vitest',
vitest: {
configFile: 'vitest.config.ts',
},
mutate: [
'src/utils/**/*.ts',
'src/schemas/**/*.ts',
'src/services/**/*.ts',
'!src/**/*.test.ts',
'!src/**/*.spec.ts',
'!src/**/*.d.ts',
],
reporters: ['html', 'clear-text', 'progress'],
htmlReporter: {
fileName: 'reports/mutation/index.html',
},
thresholds: {
high: 80,
low: 60,
break: 50,
},
concurrency: 2,
timeoutMS: 60000,
};

View file

@ -0,0 +1,85 @@
# E2E Test Stability Guide
## Architecture
```
tests/e2e/
├── playwright.config.ts # Main config (sharding, multi-browser)
├── global-setup.ts # Creates test users via API
├── global-teardown.ts # Cleanup
├── helpers.ts # Core helpers (login, navigate, assert)
├── helpers/
│ └── selectors.ts # Centralized selectors (SEL object)
├── fixtures/
│ ├── auth.fixture.ts # API-driven auth fixtures
│ ├── factories.ts # Test data factories (user, playlist, etc.)
│ └── file-helpers.ts # Mock MP3 file generators
├── *.spec.ts # Test specs
└── audit/ # Audit-specific specs (a11y, visual, etc.)
```
## Selectors
**Always use `data-testid` for E2E selectors.** Import from `helpers/selectors.ts`:
```ts
import { SEL } from './helpers/selectors';
// Good
await page.getByTestId(SEL.toast.success);
await page.getByTestId(SEL.dialog.confirm);
// Bad — fragile, breaks on text changes
await page.getByText('Create');
await page.locator('button.submit');
```
Component `data-testid` are defined in `apps/web/src/components/ui/testids.ts` and mirrored in `SEL`.
## Authentication
**Use API login, not UI login** for tests that don't test the login flow:
```ts
import { test, expect } from '../fixtures/auth.fixture';
test('playlist CRUD', async ({ listenerPage }) => {
// listenerPage is already authenticated via API
await listenerPage.goto('/playlists');
});
```
## Data Factories
**Create test data via API, not UI clicks:**
```ts
import { createPlaylist, ensureTracksExist } from '../fixtures/factories';
test('add track to playlist', async ({ creatorPage }) => {
const playlist = await createPlaylist(creatorPage, { name: 'Test Playlist' });
// ...
});
```
## CI Configuration
- **e2e-critical**: `@critical` tag only, Chromium, blocks PR (<3min)
- **e2e-full**: All tests, 4-way sharded, all browsers, 10-15min
## Debugging Flaky Tests
1. Run locally with trace: `PLAYWRIGHT_TRACE=on npm run e2e:serial`
2. Check `tests/e2e/test-results/` for trace files
3. Open trace: `npx playwright show-trace <trace.zip>`
4. Check flaky report: `node scripts/flaky-detection.mjs`
## Common Pitfalls
| Problem | Solution |
|---------|----------|
| Toast selector mismatch | Use `data-testid="toast-success"` not text content |
| Dialog button collision | Use `data-testid="dialog-confirm"` not `getByText('Create')` |
| Rate limit in tests | Env `DISABLE_RATE_LIMIT_FOR_TESTS=true` |
| Stale selector after navigation | Wait for `main` element after `navigateTo()` |
| Login flaky | Use `loginViaAPI()` instead of `loginViaUI()` |

0
help Normal file
View file

View file

@ -0,0 +1,50 @@
/**
* Compare k6 summary JSON against baseline thresholds.
* Usage: node compare.mjs <summary.json>
* Exit 1 if p95 latency degrades by > 20% from baseline.
*/
import { readFileSync } from 'fs';
const baselineThresholds = {
http_req_duration_p95: 500, // ms
http_req_duration_p99: 1000, // ms
http_req_failed_rate: 0.01, // 1%
};
const summaryPath = process.argv[2];
if (!summaryPath) {
console.error('Usage: node compare.mjs <k6-summary.json>');
process.exit(1);
}
try {
const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
const metrics = summary.metrics || {};
let failed = false;
const p95 = metrics.http_req_duration?.values?.['p(95)'];
if (p95 && p95 > baselineThresholds.http_req_duration_p95) {
console.error(`FAIL: p95 latency ${p95.toFixed(0)}ms > baseline ${baselineThresholds.http_req_duration_p95}ms`);
failed = true;
}
const p99 = metrics.http_req_duration?.values?.['p(99)'];
if (p99 && p99 > baselineThresholds.http_req_duration_p99) {
console.error(`FAIL: p99 latency ${p99.toFixed(0)}ms > baseline ${baselineThresholds.http_req_duration_p99}ms`);
failed = true;
}
const failRate = metrics.http_req_failed?.values?.rate;
if (failRate && failRate > baselineThresholds.http_req_failed_rate) {
console.error(`FAIL: error rate ${(failRate * 100).toFixed(2)}% > baseline ${baselineThresholds.http_req_failed_rate * 100}%`);
failed = true;
}
if (failed) {
process.exit(1);
}
console.log('PASS: All performance metrics within baseline thresholds');
} catch (e) {
console.error('Failed to parse summary:', e.message);
process.exit(1);
}

View file

@ -1,320 +0,0 @@
syntax = "proto3";
package veza.chat;
option go_package = "veza-backend-api/proto/chat";
import "common/auth.proto";
// Service Chat pour communication avec le module Rust
service ChatService {
// Gestion des salles
rpc CreateRoom(CreateRoomRequest) returns (CreateRoomResponse);
rpc JoinRoom(JoinRoomRequest) returns (JoinRoomResponse);
rpc LeaveRoom(LeaveRoomRequest) returns (LeaveRoomResponse);
rpc GetRoomInfo(GetRoomInfoRequest) returns (Room);
rpc ListRooms(ListRoomsRequest) returns (ListRoomsResponse);
// Gestion des messages
rpc SendMessage(SendMessageRequest) returns (SendMessageResponse);
rpc GetMessageHistory(GetMessageHistoryRequest) returns (GetMessageHistoryResponse);
rpc DeleteMessage(DeleteMessageRequest) returns (DeleteMessageResponse);
// Messages directs
rpc SendDirectMessage(SendDirectMessageRequest) returns (SendDirectMessageResponse);
rpc GetDirectMessages(GetDirectMessagesRequest) returns (GetDirectMessagesResponse);
// Modération
rpc MuteUser(MuteUserRequest) returns (MuteUserResponse);
rpc BanUser(BanUserRequest) returns (BanUserResponse);
rpc ModerateMessage(ModerateMessageRequest) returns (ModerateMessageResponse);
// Statistiques temps réel
rpc GetRoomStats(GetRoomStatsRequest) returns (RoomStats);
rpc GetUserActivity(GetUserActivityRequest) returns (UserActivity);
}
// Messages pour les salles
message CreateRoomRequest {
string name = 1;
string description = 2;
RoomType type = 3;
RoomVisibility visibility = 4;
int64 created_by = 5;
string auth_token = 6;
}
message CreateRoomResponse {
Room room = 1;
string error = 2;
}
message JoinRoomRequest {
string room_id = 1;
int64 user_id = 2;
string auth_token = 3;
}
message JoinRoomResponse {
bool success = 1;
RoomMember member = 2;
string error = 3;
}
message LeaveRoomRequest {
string room_id = 1;
int64 user_id = 2;
string auth_token = 3;
}
message LeaveRoomResponse {
bool success = 1;
string error = 2;
}
message GetRoomInfoRequest {
string room_id = 1;
string auth_token = 2;
}
message ListRoomsRequest {
RoomVisibility visibility = 1;
int32 page = 2;
int32 limit = 3;
string auth_token = 4;
}
message ListRoomsResponse {
repeated Room rooms = 1;
int32 total = 2;
string error = 3;
}
// Messages pour les messages
message SendMessageRequest {
string room_id = 1;
int64 sender_id = 2;
string content = 3;
MessageType type = 4;
string auth_token = 5;
string reply_to = 6; // ID du message parent
}
message SendMessageResponse {
Message message = 1;
string error = 2;
}
message GetMessageHistoryRequest {
string room_id = 1;
int32 limit = 2;
string before_id = 3; // pagination
string auth_token = 4;
}
message GetMessageHistoryResponse {
repeated Message messages = 1;
bool has_more = 2;
string error = 3;
}
message DeleteMessageRequest {
string message_id = 1;
int64 user_id = 2;
string auth_token = 3;
}
message DeleteMessageResponse {
bool success = 1;
string error = 2;
}
// Messages directs
message SendDirectMessageRequest {
int64 sender_id = 1;
int64 recipient_id = 2;
string content = 3;
MessageType type = 4;
string auth_token = 5;
}
message SendDirectMessageResponse {
DirectMessage message = 1;
string error = 2;
}
message GetDirectMessagesRequest {
int64 user_id = 1;
int64 other_user_id = 2;
int32 limit = 3;
string before_id = 4;
string auth_token = 5;
}
message GetDirectMessagesResponse {
repeated DirectMessage messages = 1;
bool has_more = 2;
string error = 3;
}
// Modération
message MuteUserRequest {
string room_id = 1;
int64 user_id = 2;
int64 moderator_id = 3;
int64 duration_seconds = 4;
string reason = 5;
string auth_token = 6;
}
message MuteUserResponse {
bool success = 1;
string error = 2;
}
message BanUserRequest {
string room_id = 1;
int64 user_id = 2;
int64 moderator_id = 3;
string reason = 4;
string auth_token = 5;
}
message BanUserResponse {
bool success = 1;
string error = 2;
}
message ModerateMessageRequest {
string message_id = 1;
int64 moderator_id = 2;
ModerationAction action = 3;
string reason = 4;
string auth_token = 5;
}
message ModerateMessageResponse {
bool success = 1;
string error = 2;
}
// Statistiques
message GetRoomStatsRequest {
string room_id = 1;
string auth_token = 2;
}
message GetUserActivityRequest {
int64 user_id = 1;
string auth_token = 2;
}
// Types de données
message Room {
string id = 1;
string name = 2;
string description = 3;
RoomType type = 4;
RoomVisibility visibility = 5;
int64 created_by = 6;
int64 created_at = 7;
int32 member_count = 8;
int32 online_count = 9;
bool is_active = 10;
}
message RoomMember {
int64 user_id = 1;
string username = 2;
RoomRole role = 3;
int64 joined_at = 4;
bool is_online = 5;
int64 last_seen = 6;
}
message Message {
string id = 1;
string room_id = 2;
int64 sender_id = 3;
string sender_username = 4;
string content = 5;
MessageType type = 6;
int64 created_at = 7;
int64 updated_at = 8;
bool is_edited = 9;
bool is_deleted = 10;
string reply_to = 11;
repeated MessageReaction reactions = 12;
}
message DirectMessage {
string id = 1;
int64 sender_id = 2;
int64 recipient_id = 3;
string content = 4;
MessageType type = 5;
int64 created_at = 6;
bool is_read = 7;
bool is_deleted = 8;
}
message MessageReaction {
string emoji = 1;
repeated int64 user_ids = 2;
int32 count = 3;
}
message RoomStats {
string room_id = 1;
int32 total_members = 2;
int32 online_members = 3;
int32 messages_today = 4;
int32 total_messages = 5;
repeated int64 active_users = 6;
}
message UserActivity {
int64 user_id = 1;
int32 rooms_joined = 2;
int32 messages_sent = 3;
int64 last_activity = 4;
bool is_online = 5;
string current_status = 6;
}
// Énumérations
enum RoomType {
PUBLIC = 0;
PRIVATE = 1;
DIRECT = 2;
PREMIUM = 3;
}
enum RoomVisibility {
OPEN = 0;
INVITE_ONLY = 1;
HIDDEN = 2;
}
enum RoomRole {
MEMBER = 0;
MODERATOR = 1;
ADMIN = 2;
OWNER = 3;
}
enum MessageType {
TEXT = 0;
IMAGE = 1;
FILE = 2;
AUDIO = 3;
VIDEO = 4;
SYSTEM = 5;
}
enum ModerationAction {
WARN = 0;
DELETE = 1;
EDIT = 2;
FLAG = 3;
}

62
scripts/coverage-trend.mjs Executable file
View file

@ -0,0 +1,62 @@
#!/usr/bin/env node
/**
* Coverage Trend Script
*
* Reads Vitest coverage summary and appends to a JSON trend file.
* Usage: node scripts/coverage-trend.mjs [coverage-summary.json] [trend-output.json]
*/
import { readFileSync, writeFileSync, existsSync } from 'fs';
const summaryPath = process.argv[2] || 'apps/web/coverage/coverage-summary.json';
const trendPath = process.argv[3] || 'coverage-trend.json';
function readTrend() {
if (existsSync(trendPath)) {
return JSON.parse(readFileSync(trendPath, 'utf8'));
}
return { entries: [] };
}
function extractCoverage(summaryPath) {
if (!existsSync(summaryPath)) {
console.error(`Coverage summary not found: ${summaryPath}`);
return null;
}
const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
const total = summary.total || {};
return {
date: new Date().toISOString().split('T')[0],
commit: process.env.GITHUB_SHA?.slice(0, 7) || 'local',
lines: total.lines?.pct ?? 0,
branches: total.branches?.pct ?? 0,
functions: total.functions?.pct ?? 0,
statements: total.statements?.pct ?? 0,
};
}
const coverage = extractCoverage(summaryPath);
if (coverage) {
const trend = readTrend();
// Keep last 100 entries
trend.entries.push(coverage);
if (trend.entries.length > 100) {
trend.entries = trend.entries.slice(-100);
}
writeFileSync(trendPath, JSON.stringify(trend, null, 2));
console.log(`Coverage trend updated: lines=${coverage.lines}%, branches=${coverage.branches}%`);
// Check for regression (> 2% drop from last entry)
if (trend.entries.length >= 2) {
const prev = trend.entries[trend.entries.length - 2];
const linesDrop = prev.lines - coverage.lines;
if (linesDrop > 2) {
console.warn(`WARNING: Line coverage dropped ${linesDrop.toFixed(1)}% (${prev.lines}% -> ${coverage.lines}%)`);
}
}
}

135
scripts/flaky-detection.mjs Executable file
View file

@ -0,0 +1,135 @@
#!/usr/bin/env node
/**
* Flaky Test Detection Script
*
* Analyzes Playwright JSON results to detect flaky tests (tests that passed on retry).
* Usage: node scripts/flaky-detection.mjs [results-dir]
*
* Output: Markdown report to stdout, suitable for piping to a file or GitHub comment.
*/
import { readFileSync, readdirSync, existsSync } from 'fs';
import { join } from 'path';
const resultsDir = process.argv[2] || 'tests/e2e/test-results';
const resultsFile = process.argv[3] || 'tests/e2e/test-results/results.json';
function analyzeResults(filePath) {
if (!existsSync(filePath)) {
console.error(`Results file not found: ${filePath}`);
return null;
}
const raw = JSON.parse(readFileSync(filePath, 'utf8'));
const suites = raw.suites || [];
const flaky = [];
const failed = [];
const slow = [];
function walkSpecs(specs, suitePath = '') {
for (const spec of specs) {
const fullTitle = suitePath ? `${suitePath} > ${spec.title}` : spec.title;
for (const test of spec.tests || []) {
const results = test.results || [];
// Flaky: passed eventually but had retries
if (test.status === 'expected' && results.length > 1) {
flaky.push({
title: fullTitle,
retries: results.length - 1,
file: spec.file || 'unknown',
});
}
// Failed
if (test.status === 'unexpected') {
failed.push({
title: fullTitle,
file: spec.file || 'unknown',
error: results[results.length - 1]?.error?.message?.slice(0, 200) || 'unknown',
});
}
// Slow (> 30s)
const duration = results.reduce((sum, r) => sum + (r.duration || 0), 0);
if (duration > 30000) {
slow.push({
title: fullTitle,
duration: Math.round(duration / 1000),
file: spec.file || 'unknown',
});
}
}
}
}
function walkSuites(suites, path = '') {
for (const suite of suites) {
const suitePath = path ? `${path} > ${suite.title}` : suite.title;
walkSpecs(suite.specs || [], suitePath);
walkSuites(suite.suites || [], suitePath);
}
}
walkSuites(suites);
return { flaky, failed, slow };
}
function generateReport(analysis) {
if (!analysis) return '# Flaky Test Report\n\nNo results file found.\n';
const { flaky, failed, slow } = analysis;
const lines = ['# Flaky Test Report', ''];
lines.push(`Generated: ${new Date().toISOString()}`, '');
// Flaky tests
lines.push(`## Flaky Tests (${flaky.length})`, '');
if (flaky.length === 0) {
lines.push('No flaky tests detected.', '');
} else {
lines.push('| Test | Retries | File |');
lines.push('|------|---------|------|');
for (const t of flaky.sort((a, b) => b.retries - a.retries)) {
lines.push(`| ${t.title} | ${t.retries} | \`${t.file}\` |`);
}
lines.push('');
}
// Failed tests
lines.push(`## Failed Tests (${failed.length})`, '');
if (failed.length === 0) {
lines.push('No failed tests.', '');
} else {
lines.push('| Test | Error | File |');
lines.push('|------|-------|------|');
for (const t of failed) {
const safeError = t.error.replace(/\|/g, '\\|').replace(/\n/g, ' ');
lines.push(`| ${t.title} | ${safeError} | \`${t.file}\` |`);
}
lines.push('');
}
// Slow tests
lines.push(`## Slow Tests (> 30s) (${slow.length})`, '');
if (slow.length === 0) {
lines.push('No slow tests.', '');
} else {
lines.push('| Test | Duration | File |');
lines.push('|------|----------|------|');
for (const t of slow.sort((a, b) => b.duration - a.duration)) {
lines.push(`| ${t.title} | ${t.duration}s | \`${t.file}\` |`);
}
lines.push('');
}
return lines.join('\n');
}
const analysis = analyzeResults(resultsFile);
console.log(generateReport(analysis));
if (analysis && analysis.flaky.length > 0) {
process.exit(0); // Flaky tests are warnings, not failures
}

View file

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -e
cd apps/web
npm run build-storybook
npx lost-pixel update
echo "Baselines updated in apps/web/.lostpixel/baselines/"
echo "Don't forget to commit them."

View file

@ -0,0 +1,54 @@
import { test, expect } from '@playwright/test';
test.describe('Keyboard Navigation @a11y', () => {
test('login page: Tab navigates through all interactive elements', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('networkidle').catch(() => {});
// First Tab should focus email input
await page.keyboard.press('Tab');
const focused1 = await page.evaluate(() => document.activeElement?.tagName + '.' + document.activeElement?.getAttribute('type'));
expect(focused1).toContain('INPUT');
// Tab through remaining fields
await page.keyboard.press('Tab'); // password
await page.keyboard.press('Tab'); // remember me or submit
await page.keyboard.press('Tab'); // submit or link
// Verify we can reach the submit button via keyboard
const submitReachable = await page.evaluate(() => {
const btn = document.querySelector('[data-testid="login-submit"]');
return btn !== null;
});
expect(submitReachable).toBeTruthy();
});
test('Escape closes dialogs', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('networkidle').catch(() => {});
// Check for any open dialogs and verify Escape closes them
const dialog = page.locator('[role="dialog"]');
if (await dialog.isVisible({ timeout: 2000 }).catch(() => false)) {
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible({ timeout: 3000 });
}
});
test('focus-visible styles are present on interactive elements', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('networkidle').catch(() => {});
// Tab to first input to trigger focus-visible
await page.keyboard.press('Tab');
// Check that focus styles are applied (outline or ring)
const hasFocusStyle = await page.evaluate(() => {
const el = document.activeElement;
if (!el) return false;
const styles = window.getComputedStyle(el);
return styles.outlineStyle !== 'none' || styles.boxShadow !== 'none';
});
expect(hasFocusStyle).toBeTruthy();
});
});

View file

@ -0,0 +1,59 @@
import { test as base, expect, type Page } from '@playwright/test';
import { CONFIG } from '../helpers';
/**
* API-driven authentication fixture.
* Replaces UI login flows for faster, deterministic tests.
*
* Usage:
* import { test } from './fixtures/auth.fixture';
* test('something', async ({ listenerPage, creatorPage }) => { ... });
*/
async function loginAndSetup(page: Page, email: string, password: string): Promise<void> {
const base = CONFIG.baseURL;
await page.goto(`${base}/`, { waitUntil: 'commit', timeout: CONFIG.timeouts.navigation });
const response = await page.request.post(`${base}/api/v1/auth/login`, {
data: { email, password, remember_me: false },
});
expect(response.ok(), `Login API failed: ${response.status()} for ${email}`).toBeTruthy();
await page.evaluate(() => {
localStorage.setItem(
'auth-storage',
JSON.stringify({ state: { isAuthenticated: true, isLoading: false, error: null }, version: 1 }),
);
});
}
type AuthFixtures = {
listenerPage: Page;
creatorPage: Page;
adminPage: Page;
moderatorPage: Page;
};
export const test = base.extend<AuthFixtures>({
listenerPage: async ({ page }, use) => {
await loginAndSetup(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
await use(page);
},
creatorPage: async ({ page }, use) => {
await loginAndSetup(page, CONFIG.users.creator.email, CONFIG.users.creator.password);
await use(page);
},
adminPage: async ({ page }, use) => {
await loginAndSetup(page, CONFIG.users.admin.email, CONFIG.users.admin.password);
await use(page);
},
moderatorPage: async ({ page }, use) => {
await loginAndSetup(page, CONFIG.users.moderator.email, CONFIG.users.moderator.password);
await use(page);
},
});
export { expect } from '@playwright/test';

View file

@ -0,0 +1,82 @@
import type { Page } from '@playwright/test';
import { CONFIG } from '../helpers';
/**
* API-driven test data factories.
* Create data via backend API instead of UI interactions.
* Use these to set up test preconditions deterministically.
*/
let counter = 0;
function uniqueId(prefix = 'e2e') {
counter++;
return `${prefix}-${Date.now()}-${counter}`;
}
/**
* Create a test user via registration API.
*/
export async function createUser(
page: Page,
overrides: { email?: string; password?: string; username?: string } = {},
) {
const base = CONFIG.baseURL;
const data = {
email: overrides.email ?? `${uniqueId('test')}@veza.test`,
password: overrides.password ?? 'TestPass123!',
username: overrides.username ?? uniqueId('user'),
display_name: overrides.username ?? 'E2E Test User',
};
const response = await page.request.post(`${base}/api/v1/auth/register`, { data });
if (!response.ok()) {
const body = await response.text();
throw new Error(`createUser failed: ${response.status()}${body}`);
}
return { ...data, response: await response.json() };
}
/**
* Create a playlist via API (requires authenticated page).
*/
export async function createPlaylist(
page: Page,
overrides: { name?: string; description?: string; visibility?: string } = {},
) {
const base = CONFIG.baseURL;
const data = {
name: overrides.name ?? `E2E Playlist ${uniqueId()}`,
description: overrides.description ?? 'Created by E2E factory',
visibility: overrides.visibility ?? 'private',
};
const response = await page.request.post(`${base}/api/v1/playlists`, { data });
if (!response.ok()) {
const body = await response.text();
throw new Error(`createPlaylist failed: ${response.status()}${body}`);
}
return { ...data, response: await response.json() };
}
/**
* Delete a resource via API.
*/
export async function deleteResource(page: Page, endpoint: string) {
const base = CONFIG.baseURL;
const response = await page.request.delete(`${base}${endpoint}`);
return response;
}
/**
* Seed: Ensure at least one track exists for the creator user.
* Returns true if tracks are available.
*/
export async function ensureTracksExist(page: Page): Promise<boolean> {
const base = CONFIG.baseURL;
const response = await page.request.get(`${base}/api/v1/tracks?limit=1`);
if (!response.ok()) return false;
const body = await response.json();
return (body.data?.length ?? 0) > 0;
}

View file

@ -0,0 +1,70 @@
/**
* Centralized Playwright selectors mirrors TESTID from
* apps/web/src/components/ui/testids.ts
*
* Usage:
* import { SEL } from './helpers/selectors';
* await page.getByTestId(SEL.toast.success).click();
*/
export const SEL = {
// Toast
toast: {
success: 'toast-success',
error: 'toast-error',
info: 'toast-info',
message: 'toast-message',
close: 'toast-close',
},
// Dialog
dialog: {
root: 'dialog',
title: 'dialog-title',
close: 'dialog-close',
content: 'dialog-content',
footer: 'dialog-footer',
confirm: 'dialog-confirm',
cancel: 'dialog-cancel',
},
// Confirmation Dialog
confirmationDialog: {
root: 'confirmation-dialog',
description: 'confirmation-description',
icon: 'confirmation-icon',
},
// Radio
radioGroup: {
root: 'radio-group',
item: (value: string) => `radio-item-${value}`,
},
// Checkbox
checkbox: {
root: 'checkbox',
input: 'checkbox-input',
label: 'checkbox-label',
},
// Layout
sidebar: 'app-sidebar',
header: 'app-header',
player: 'global-player',
// Auth
loginForm: 'login-form',
loginSubmit: 'login-submit',
registerForm: 'register-form',
// Player
audioElement: 'audio-element',
volumeControl: 'volume-control',
// Search
searchInput: 'search-input',
// Cards
trackCard: 'track-card',
playlistCard: 'playlist-card',
} as const;

View file

@ -1,177 +0,0 @@
package repository
import (
"context"
"errors"
"sync"
"veza-backend-api/internal/models"
"github.com/google/uuid"
)
// UserRepositoryImpl implémentation en mémoire du repository des utilisateurs
type UserRepositoryImpl struct {
users map[string]*models.User
emails map[string]string
usernames map[string]string // username -> userID mapping
mutex sync.RWMutex
}
// NewUserRepository crée une nouvelle instance du repository
func NewUserRepository() *UserRepositoryImpl {
return &UserRepositoryImpl{
users: make(map[string]*models.User),
emails: make(map[string]string),
usernames: make(map[string]string),
}
}
// GetByID récupère un utilisateur par son ID
func (r *UserRepositoryImpl) GetByID(_ context.Context, id string) (*models.User, error) {
r.mutex.RLock()
defer r.mutex.RUnlock()
user, exists := r.users[id]
if !exists {
return nil, errors.New("user not found")
}
// Retourner une copie pour éviter les modifications accidentelles
userCopy := *user
return &userCopy, nil
}
// GetByEmail récupère un utilisateur par son email
func (r *UserRepositoryImpl) GetByEmail(_ context.Context, email string) (*models.User, error) {
r.mutex.RLock()
defer r.mutex.RUnlock()
userID, exists := r.emails[email]
if !exists {
return nil, errors.New("user not found")
}
user, exists := r.users[userID]
if !exists {
return nil, errors.New("user not found")
}
// Retourner une copie pour éviter les modifications accidentelles
userCopy := *user
return &userCopy, nil
}
// GetByUsername récupère un utilisateur par son username
func (r *UserRepositoryImpl) GetByUsername(_ context.Context, username string) (*models.User, error) {
r.mutex.RLock()
defer r.mutex.RUnlock()
userID, exists := r.usernames[username]
if !exists {
return nil, errors.New("user not found")
}
user, exists := r.users[userID]
if !exists {
return nil, errors.New("user not found")
}
// Retourner une copie pour éviter les modifications accidentelles
userCopy := *user
return &userCopy, nil
}
// Create crée un nouvel utilisateur
func (r *UserRepositoryImpl) Create(_ context.Context, user *models.User) error {
r.mutex.Lock()
defer r.mutex.Unlock()
// Vérifier si l'email existe déjà
if _, exists := r.emails[user.Email]; exists {
return errors.New("email already exists")
}
// Assigner un ID si vide
if user.ID == uuid.Nil {
user.ID = uuid.New()
}
// Créer une copie pour éviter les modifications accidentelles
userCopy := *user
// Forcer les valeurs par défaut
userCopy.Role = "user"
userCopy.FirstName = user.FirstName
userCopy.LastName = user.LastName
userCopy.Avatar = user.Avatar
userCopy.Bio = user.Bio
userCopy.IsActive = true
userCopy.IsVerified = false
userCopy.IsAdmin = false
userIDStr := user.ID.String()
r.users[userIDStr] = &userCopy
r.emails[user.Email] = userIDStr
r.usernames[user.Username] = userIDStr
return nil
}
// Update met à jour un utilisateur existant
func (r *UserRepositoryImpl) Update(_ context.Context, user *models.User) error {
r.mutex.Lock()
defer r.mutex.Unlock()
userIDStr := user.ID.String()
// Vérifier si l'utilisateur existe
existingUser, exists := r.users[userIDStr]
if !exists {
return errors.New("user not found")
}
// Si l'email a changé, vérifier qu'il n'existe pas déjà
if existingUser.Email != user.Email {
if _, emailExists := r.emails[user.Email]; emailExists {
return errors.New("email already exists")
}
// Mettre à jour les mappings
delete(r.emails, existingUser.Email)
r.emails[user.Email] = userIDStr
}
// Si le username a changé, mettre à jour le mapping
if existingUser.Username != user.Username {
// Vérifier que le nouveau username n'est pas déjà pris (par un autre utilisateur)
if existingUserID, usernameExists := r.usernames[user.Username]; usernameExists && existingUserID != userIDStr {
return errors.New("username already exists")
}
// Mettre à jour les mappings
delete(r.usernames, existingUser.Username)
r.usernames[user.Username] = userIDStr
}
// Créer une copie pour éviter les modifications accidentelles
userCopy := *user
r.users[userIDStr] = &userCopy
return nil
}
// Delete supprime un utilisateur
func (r *UserRepositoryImpl) Delete(_ context.Context, id string) error {
r.mutex.Lock()
defer r.mutex.Unlock()
user, exists := r.users[id]
if !exists {
return errors.New("user not found")
}
// Supprimer les mappings
delete(r.users, id)
delete(r.emails, user.Email)
delete(r.usernames, user.Username)
return nil
}

View file

@ -1,67 +0,0 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use chrono::{DateTime, Utc};
/// Conversation information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conversation {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub is_private: bool,
pub created_by: Uuid,
pub participants: Vec<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_message_at: Option<DateTime<Utc>>,
}
/// Message information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub id: Uuid,
pub conversation_id: Uuid,
pub user_id: Uuid,
pub content: String,
pub message_type: MessageType,
pub reply_to: Option<Uuid>,
pub attachments: Vec<Attachment>,
pub reactions: Vec<Reaction>,
pub is_edited: bool,
pub is_deleted: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Message types
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MessageType {
Text,
Image,
Audio,
Video,
File,
System,
Call,
CallEnded,
}
/// Attachment information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attachment {
pub id: Uuid,
pub filename: String,
pub mime_type: String,
pub size: u64,
pub url: String,
pub thumbnail_url: Option<String>,
pub metadata: Option<serde_json::Value>,
}
/// Reaction information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Reaction {
pub emoji: String,
pub count: u32,
pub users: Vec<Uuid>,
}

View file

@ -5,21 +5,22 @@
pub mod user; pub mod user;
pub mod track; pub mod track;
pub mod playlist; pub mod playlist;
pub mod chat; // `chat` + `websocket` modules removed 2026-04-20: chat server Rust supprimé
// le 2026-02-22 (commit 05d02386d). Leurs types (Conversation/Message/
// MessageType/WebSocketMessage/CallType/...) étaient orphelins depuis —
// aucun consumer dans veza-backend-api ni veza-stream-server.
// Le chat WebSocket vit 100% côté Go.
pub mod api; pub mod api;
pub mod media; pub mod media;
pub mod system; pub mod system;
pub mod websocket;
pub mod files; pub mod files;
// Re-export specific types for convenience // Re-export specific types for convenience
pub use user::{User, Session}; pub use user::{User, Session};
pub use track::Track; pub use track::Track;
pub use playlist::{Playlist, PlaylistTrack}; pub use playlist::{Playlist, PlaylistTrack};
pub use chat::{Conversation, Message, MessageType, Attachment, Reaction};
pub use api::{ApiResponse, ApiError, ApiMeta, PaginationMeta, RateLimitMeta, SearchRequest, SearchResponse}; pub use api::{ApiResponse, ApiError, ApiMeta, PaginationMeta, RateLimitMeta, SearchRequest, SearchResponse};
pub use media::{StreamingRequest, StreamingResponse, AudioChunk, StreamingQuality}; pub use media::{StreamingRequest, StreamingResponse, AudioChunk, StreamingQuality};
pub use system::{AuditLog, HealthCheck, Metrics, Migration}; pub use system::{AuditLog, HealthCheck, Metrics, Migration};
pub use websocket::{WebSocketMessage, PresenceStatus, CallType};
pub use files::{FileUploadRequest, FileUploadResponse, FileMetadata}; pub use files::{FileUploadRequest, FileUploadResponse, FileMetadata};

View file

@ -1,72 +0,0 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use chrono::{DateTime, Utc};
use crate::types::chat::MessageType;
/// WebSocket message types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum WebSocketMessage {
/// Text message
Message {
conversation_id: Uuid,
content: String,
message_type: MessageType,
reply_to: Option<Uuid>,
},
/// Typing indicator
Typing {
conversation_id: Uuid,
is_typing: bool,
},
/// Read receipt
ReadReceipt {
conversation_id: Uuid,
message_id: Uuid,
},
/// User presence
Presence {
status: PresenceStatus,
last_seen: DateTime<Utc>,
},
/// Call invitation
CallInvite {
conversation_id: Uuid,
call_type: CallType,
},
/// Call response
CallResponse {
conversation_id: Uuid,
accepted: bool,
},
/// Call end
CallEnd {
conversation_id: Uuid,
duration: u32,
},
/// Error message
Error {
code: String,
message: String,
},
/// Ping/Pong for keepalive
Ping,
Pong,
}
/// Presence status
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PresenceStatus {
Online,
Away,
Busy,
Offline,
}
/// Call types
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CallType {
Audio,
Video,
ScreenShare,
}