Compare commits
10 commits
6d51f52aae
...
778c85508b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
778c85508b | ||
|
|
b5281bec98 | ||
|
|
ebf3276daa | ||
|
|
18eed3c49c | ||
|
|
172581ff02 | ||
|
|
4310dbb734 | ||
|
|
12f873bdb8 | ||
|
|
68d946172f | ||
|
|
7fa35edc5c | ||
|
|
d12b901de5 |
62 changed files with 3243 additions and 2838 deletions
27
.commitlintrc.json
Normal file
27
.commitlintrc.json
Normal 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]
|
||||
}
|
||||
}
|
||||
67
.gitignore
vendored
67
.gitignore
vendored
|
|
@ -99,9 +99,10 @@ apps/web/.env.local
|
|||
docker-data/
|
||||
*.tar
|
||||
|
||||
# HAProxy SSL certs (never commit private keys)
|
||||
# HAProxy SSL certs (never commit private keys or full-chain certs)
|
||||
docker/haproxy/certs/*.key
|
||||
docker/haproxy/certs/*.pem
|
||||
docker/haproxy/certs/*.crt
|
||||
|
||||
# JWT RSA keys (v0.9.1 RS256 migration — NEVER commit)
|
||||
jwt-private.pem
|
||||
|
|
@ -196,3 +197,67 @@ veza-backend-api/backend*.log
|
|||
|
||||
# AI tooling session state (not code)
|
||||
.cursor/
|
||||
|
||||
# ============================================================
|
||||
# Post-audit J2 (2026-04-20) — branch chore/v1.0.7-cleanup
|
||||
# ============================================================
|
||||
|
||||
# Tracked audio fixtures — use git-lfs or fixtures repo, never commit raw audio
|
||||
veza-backend-api/uploads/
|
||||
|
||||
# TLS/SSL certificates committed pre-2026-04 (regen with scripts/generate-ssl-cert.sh)
|
||||
config/ssl/*.pem
|
||||
config/ssl/*.key
|
||||
config/ssl/*.crt
|
||||
|
||||
# Playwright MCP session debris
|
||||
.playwright-mcp/
|
||||
|
||||
# AI session artefacts / context dumps
|
||||
CLAUDE_CONTEXT.txt
|
||||
UI_CONTEXT_SUMMARY.md
|
||||
*.context.txt
|
||||
*.ai-session.txt
|
||||
|
||||
# One-off generated tooling scripts (should live in scripts/ if kept)
|
||||
/generate_page_fix_prompts.sh
|
||||
/build-archive.log
|
||||
|
||||
# Apps/web stale audit reports (generated, never tracked)
|
||||
apps/web/AUDIT_ISSUES.json
|
||||
apps/web/audit_remediation.json
|
||||
apps/web/lint_comprehensive.json
|
||||
apps/web/storybook-roadmap.json
|
||||
apps/web/storybook-*.json
|
||||
|
||||
# Root PNG screenshots — move to docs/screenshots/ if historical value
|
||||
/design-system-*.png
|
||||
/forgot-password-*.png
|
||||
/register-*.png
|
||||
/reset-password-*.png
|
||||
/settings-*.png
|
||||
/storybook-*.png
|
||||
|
||||
# ============================================================
|
||||
# Post-audit J3 (2026-04-23) — history rewrite (BFG pass, 1.5G → 66M)
|
||||
# ============================================================
|
||||
# Additional Go build artifacts found in BFG scan
|
||||
veza-backend-api/bin/
|
||||
veza-backend-api/veza-backend-api
|
||||
veza-backend-api/migrate
|
||||
|
||||
# Vendored binaries mistakenly committed
|
||||
dev-environment/scripts/kubectl
|
||||
|
||||
# Incus build outputs (generated per release cut)
|
||||
.build/
|
||||
|
||||
# E2E report outputs (Playwright)
|
||||
tests/e2e/audit/results/
|
||||
tests/e2e/playwright-report/
|
||||
|
||||
# Session-scratch screenshots
|
||||
frontend_screenshots/
|
||||
|
||||
# Audit_remediation glob (supersedes J2's exact-match json)
|
||||
apps/web/audit_remediation*
|
||||
|
|
|
|||
3
.husky/commit-msg
Executable file
3
.husky/commit-msg
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
#!/usr/bin/env sh
|
||||
|
||||
npx --no -- commitlint --edit "$1"
|
||||
|
|
@ -1,20 +1,24 @@
|
|||
#!/usr/bin/env sh
|
||||
# Each step runs in a subshell so the cd does not leak across steps.
|
||||
# Pre-commit runs from the repo root; every cd below is relative to that.
|
||||
|
||||
# Generate TypeScript types from OpenAPI spec before commit
|
||||
# This ensures types are always up-to-date with the backend API
|
||||
cd apps/web && bash scripts/generate-types.sh
|
||||
(cd apps/web && bash scripts/generate-types.sh)
|
||||
|
||||
# Implicit 10.1: Type checking
|
||||
# Prevent commits with TypeScript errors (warnings are allowed)
|
||||
cd apps/web && npm run typecheck 2>&1 | grep -q "error TS" && {
|
||||
(cd apps/web && npm run typecheck 2>&1 | grep -q "error TS") && {
|
||||
echo "❌ Type checking failed. Please fix TypeScript errors before committing."
|
||||
echo "💡 Run 'npm run typecheck' to see all errors."
|
||||
exit 1
|
||||
} || true
|
||||
|
||||
# Implicit 10.2: Linting
|
||||
# Prevent commits with linting errors (warnings are allowed)
|
||||
cd apps/web && npm run lint 2>&1 | grep -q "error" && {
|
||||
# Prevent commits with linting errors (warnings are allowed).
|
||||
# Pattern matches "(N error" with N>=1 in ESLint's summary line —
|
||||
# avoids false positive on "(0 errors, K warnings)".
|
||||
(cd apps/web && npm run lint 2>&1 | grep -qE "\([1-9][0-9]* error") && {
|
||||
echo "❌ Linting failed. Please fix linting errors before committing."
|
||||
echo "💡 Tip: Run 'npm run lint:fix' to automatically fix some issues."
|
||||
exit 1
|
||||
|
|
@ -24,7 +28,7 @@ cd apps/web && npm run lint 2>&1 | grep -q "error" && {
|
|||
# Skip if SKIP_TESTS environment variable is set (for quick commits)
|
||||
# Only runs unit tests (not E2E) to keep it fast
|
||||
if [ -z "$SKIP_TESTS" ]; then
|
||||
cd apps/web && npm test -- --run 2>&1 | grep -q "FAIL" && {
|
||||
(cd apps/web && npm test -- --run 2>&1 | grep -q "FAIL") && {
|
||||
echo "❌ Tests failed. Please fix failing tests before committing."
|
||||
echo "💡 Tip: Run 'npm test' to see all test failures."
|
||||
echo "💡 Tip: Set SKIP_TESTS=1 to skip tests for this commit (not recommended)."
|
||||
|
|
|
|||
35
.husky/pre-push
Executable file
35
.husky/pre-push
Executable 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
15
.pa11yci.json
Normal 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
11
.semgrepignore
Normal 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
2
.zap/rules.tsv
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
10011 IGNORE (Cookie Without Secure Flag - dev only)
|
||||
10054 IGNORE (Cookie Without SameSite Attribute - dev only)
|
||||
|
695
AUDIT_REPORT.md
Normal file
695
AUDIT_REPORT.md
Normal file
|
|
@ -0,0 +1,695 @@
|
|||
# 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
|
||||
|
||||
> **Mise à jour 2026-04-23** — colonne `Statut` ajoutée après la session cleanup tier 1/2/3 + BFG history rewrite. Voir §9.bis pour le détail des 3 false-positives identifiés pendant l'exécution.
|
||||
|
||||
Classement pour la suite (post-v1.0.7-rc1 → v1.0.7 final → v1.0.8).
|
||||
|
||||
| # | Priorité | Impact | Effort | Statut 2026-04-23 | Rationale / Preuve |
|
||||
| --- | -------------------------------------------------------------------------------- | :----: | :-----: | :---------------- | -------------------------------------------------------------------------- |
|
||||
| 1 | **Supprimer `api` 99 MB + binaires Go trackés racine + `uploads/*.mp3`** | 🔴 CRIT | XS (1h) | ✅ DONE | BFG pass 2026-04-23, 1.5G → 66M. Force-push stages 1+2 OK. |
|
||||
| 2 | **Rotate TLS certs + supprimer `.pem` trackés + .env committed** | 🔴 CRIT | S (4h) | ✅ DONE | `.env*` + certs stripped via BFG. Keys regen, gitignorées. |
|
||||
| 3 | **Transactions marketplace/subscription** | 🔴 CRIT | M (3j) | ✅ DONE | Commit `b5281bec` — `UpdateProductImages` + `SetProductLicenses` en tx. |
|
||||
| 4 | **Context propagation : 31× `context.Background()` dans handlers** | 🔴 | S (1j) | ⚠️ FALSE-POSITIVE | 26/31 dans `*_test.go`, 5 legit (health probes + WS pumps). Voir §9.bis. |
|
||||
| 5 | **Ajouter CSP + X-Frame-Options headers** | 🔴 | S (1j) | ⚠️ FALSE-POSITIVE | `middleware/security_headers.go` couvre déjà CSP + XFO + HSTS + CORP/COEP/COOP. Voir §9.bis. |
|
||||
| 6 | **Pin MinIO `:latest` → tag daté** | 🔴 | XS (10min) | ✅ DONE | Commit `4310dbb7` — pinned `RELEASE.2025-09-07T16-13-09Z` × 4 compose files. |
|
||||
| 7 | **Nettoyer `.playwright-mcp/*.yml` + 48 PNG racine + `CLAUDE_CONTEXT.txt` + dead reports apps/web/** | 🟡 | S (2h) | ✅ DONE | Commits `d12b901d` + `172581ff` + BFG pass. |
|
||||
| 8 | **Terminer OpenAPI typegen** (frontend services + backend swaggo) | 🟡 | L (5j) | 📋 DEFERRED v1.0.8 | Memory entry, drift risk. `api.ts` 6550 LOC déjà là. Plan séparé requis. |
|
||||
| 9 | **Supprimer 19 workflows `.disabled` (1676 LOC mort) OU réactiver utiles (SAST, DAST, openapi-lint)** | 🟡 | S (4h) | ✅ DONE | Archivés dans `docs/archive/workflows/` via commit `172581ff`. |
|
||||
| 10 | **Consolider `RespondWithAppError` dupliqué** | 🟡 | S (1j) | ⚠️ FALSE-POSITIVE | `handlers/error_response.go:12` = wrapper intentionnel déléguant à `response/response.go:101`. Pas dupe. Voir §9.bis. |
|
||||
| 11 | **Wirer `UserRateLimiter` configuré mais non appelé** | 🟡 | S (1j) | ✅ DONE | Commit `ebf3276d` — wired in `AuthMiddleware.RequireAuth()`. |
|
||||
| 12 | **Supprimer `internal/repository/` (in-mem mock orphelin)** | 🟡 | XS | ✅ DONE | `user_repository.go` supprimé dans commit `172581ff`. |
|
||||
| 13 | **Remove/archive `proto/chat/chat.proto` + `veza-common/src/chat.rs`** | 🟡 | XS | ✅ DONE | Commit `172581ff` — proto + `veza-common/{chat.rs, websocket.rs}` supprimés. |
|
||||
| 14 | **Ajouter E2E Playwright en CI** | 🟡 | M (3j) | 📋 DEFERRED v1.0.8 | Playwright existe, SKIPPED_TESTS.md documenté, mais pas trigger CI. |
|
||||
| 15 | **`docs/ENV_VARIABLES.md` — créer si manque, sync avec code** | 🟠 | S (1j) | 📝 PENDING (0.5j) | Seul item réel restant du top-15 avant tag v1.0.7 final. |
|
||||
|
||||
**Bilan** : 10 ✅ DONE · 3 ⚠️ FALSE-POSITIVE · 2 📋 DEFERRED v1.0.8 · 1 📝 PENDING (~0.5j).
|
||||
|
||||
### 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"
|
||||
|
||||
> **Mise à jour 2026-04-23** — la liste originale (#1, #2, #3, #4, #5, #7, #8, #9) a été traitée en une session, sauf les 3 false-positives §9.bis et les 2 deferrals. Ne reste qu'un item (§9.3).
|
||||
|
||||
1. ~~**Cleanup repo** (#1, #2, #7, #9)~~ — ✅ fait, 1 session 2026-04-23.
|
||||
2. ~~**Transactions manquantes** (#3)~~ — ✅ fait, commit `b5281bec`.
|
||||
3. ~~**Context propagation** (#4)~~ — ⚠️ false-positive, pas de travail à faire (§9.bis).
|
||||
4. ~~**Security headers** (#5)~~ — ⚠️ false-positive, middleware déjà complet (§9.bis).
|
||||
5. **OpenAPI typegen** (#8) — 📋 deferred v1.0.8, plan séparé requis.
|
||||
|
||||
### 9.bis Corrections post-tier 2 (2026-04-23)
|
||||
|
||||
Trois items du top-15 ont été reclassifiés après inspection directe du code :
|
||||
|
||||
**#4 — "Context propagation : 31× `context.Background()` dans handlers"**
|
||||
Grep réel : 31 hits dans `internal/handlers/`, mais **26 dans des fichiers `_test.go`** (legit, setup tests). Les 5 hits non-test sont tous légitimes :
|
||||
- `handlers/status_handler.go:184` — probe health externe, `ctx` dédié 400ms
|
||||
- `handlers/playback_websocket_handler.go:{142,218,245}` — pumps WebSocket (doivent survivre au cycle HTTP request, pas de parent ctx disponible post-Upgrade)
|
||||
- `handlers/health.go:422` — health check 5s, `ctx` dédié
|
||||
|
||||
Le chiffre "31" masquait des patterns corrects. **Aucun handler qui défait un timeout middleware**. Pas de travail à faire.
|
||||
|
||||
**#5 — "Ajouter CSP + X-Frame-Options headers"**
|
||||
Vérification `veza-backend-api/internal/middleware/security_headers.go` : le middleware existe déjà (BE-SEC-011 + MOD-P2-005) et couvre **tous** les headers OWASP A05 recommandés :
|
||||
- `Strict-Transport-Security` (prod only)
|
||||
- `X-Frame-Options: DENY` (default) / `SAMEORIGIN` (Swagger)
|
||||
- `Content-Security-Policy` — strict `default-src 'none'` par défaut, override Swagger
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `X-XSS-Protection`, `Referrer-Policy`, `Permissions-Policy`
|
||||
- `X-Permitted-Cross-Domain-Policies: none`
|
||||
- `Cross-Origin-{Embedder,Opener,Resource}-Policy`
|
||||
|
||||
Audit erroné. Pas de travail à faire.
|
||||
|
||||
**#10 — "Consolider `RespondWithAppError` dupliqué"**
|
||||
Vérification :
|
||||
- `internal/response/response.go:101` = implémentation réelle (17 lignes)
|
||||
- `internal/handlers/error_response.go:12` = wrapper **intentionnel** de 3 lignes qui délègue à `response.RespondWithAppError(c, appErr)`. Commenté `// Délègue au package response pour éviter duplication`.
|
||||
|
||||
Le wrapper existe pour permettre aux handlers d'importer depuis le package `handlers` sans traverser la frontière `response/` — pattern de couplage sain. Pas une duplication à consolider. Pas de travail à faire.
|
||||
|
||||
### 9.3 Chemin critique vers v1.0.7 final stable
|
||||
|
||||
> **Mise à jour 2026-04-23** — le plan 5-jours original a été compressé en 1 session (cleanup + BFG + transactions + wiring). Ne reste que l'item doc.
|
||||
|
||||
| Jour (historique) | Tâches planifiées v1 | Statut 2026-04-23 |
|
||||
| :-: | --- | --- |
|
||||
| J1 | Items #1, #2, #6, #7 — cleanup + rotation + BFG + retag | ✅ DONE |
|
||||
| J2 | Items #4, #10, #12, #13 | ⚠️ #4/#10 false-positive · ✅ #12/#13 done |
|
||||
| J3-4 | Item #3 — transactions marketplace | ✅ DONE (commit `b5281bec`) |
|
||||
| J5 | Items #5, #11, #15 + tag `v1.0.7` | ⚠️ #5 false-positive · ✅ #11 done · 📝 #15 reste (0.5j) |
|
||||
|
||||
**Reste à faire avant tag `v1.0.7` final** : item #15 (`docs/ENV_VARIABLES.md` sync) — **0.5j**. Et un quick-win 5min : ajouter `HLS_STREAMING` à `.env.template` (cf. FUNCTIONAL_AUDIT §4 stabilité item 5).
|
||||
|
||||
Ensuite v1.0.8 : OpenAPI typegen (#8, 5j), E2E CI (#14, 3j), item G subscription `pending_payment` (parké dans `docs/audit-2026-04/v107-plan.md`), wire MinIO/S3 dans path upload (2-3j, cf. FUNCTIONAL §4 item 2), STUN/TURN WebRTC si calls public (1-2j).
|
||||
|
||||
---
|
||||
|
||||
## 10. Verdict final
|
||||
|
||||
> **v2 (2026-04-20)** — application solide, dépôt sale.
|
||||
> **v3 (2026-04-23, post-cleanup + BFG)** — **application solide, dépôt propre**.
|
||||
|
||||
- **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). **Transactions marketplace `DELETE+loop` atomiques depuis `b5281bec`**. **UserRateLimiter wired dans `AuthMiddleware` depuis `ebf3276d`**.
|
||||
- **Code infra** : 3 variants Dockerfile (dev/prod), K8s avec disaster recovery, 5 workflows CI actifs (+ 19 disabled archivés `docs/archive/workflows/`), 6 compose env pinned (MinIO daté), HAProxy blue-green.
|
||||
- **Hygiène repo** : 2.3 GB → **66 MB** `.git` après BFG 2026-04-23 (−97%). Binaires Go, PNG racine, `.playwright-mcp`, audio uploads, `.env*`, TLS certs, kubectl vendoré, builds Incus, reports lint : **tous stripped de l'historique** + ajoutés à `.gitignore` (blocks J1 + J2 + J3).
|
||||
|
||||
**Score** : v1 disait "Moyen-Haute dette". v2 : "Basse dette code / Haute dette hygiène". **v3 : dette résiduelle mineure** — 1 item pending (`docs/ENV_VARIABLES.md`, 0.5j) + 3 false-positives classés + 2 deferrals v1.0.8.
|
||||
|
||||
**En une phrase** : **`v1.0.7-rc1` est prêt à devenir `v1.0.7` final** dès que `docs/ENV_VARIABLES.md` est synchronisé avec les 99 env vars du code. Le reste (OpenAPI typegen, E2E CI, MinIO upload path, STUN/TURN) part sur v1.0.8 avec des plans séparés.
|
||||
|
||||
---
|
||||
|
||||
## Annexe — diff v1 ↔ v2 ↔ v3
|
||||
|
||||
| Thème | v1 (2026-04-14) | v2 (2026-04-20) | v3 (2026-04-23, post-cleanup + BFG) |
|
||||
| -------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| HEAD | `45662aad1` (v1.0.0-mvp-24-g45662aad1) | `89a52944e` (v1.0.7-rc1) | post-BFG : main `6d51f52a`, chore `b5281bec` |
|
||||
| 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é** | ✅ **66 MB après BFG** (−97%) |
|
||||
| Hygiène binaires tracked | 3 binaires | 1 reste (`api` 99 MB racine) | ✅ **0 binaires** (BFG pass + `.gitignore` J3) |
|
||||
| Hygiène `uploads/*.mp3` 44 fichiers | Présent | **Non traité** | ✅ **stripped** (BFG pass, `uploads/` gitignoré J2) |
|
||||
| Hygiène 54 PNG racine | Présent | 48 restent | ✅ **stripped** (BFG pass, patterns gitignorés J2+J3) |
|
||||
| TLS certs committés + `.env*` | Présent | Présent | ✅ **stripped** (BFG pass) |
|
||||
| Transactions marketplace | Non auditée | 🔴 CRIT flaggée | ✅ **fixées** (commit `b5281bec`) |
|
||||
| UserRateLimiter | Non mentionné | Configuré mais non câblé | ✅ **wiré** (commit `ebf3276d`) |
|
||||
| Orphelin `internal/repository/` | Non mentionné | Flaggé | ✅ **supprimé** (commit `172581ff`) |
|
||||
| Orphelins Rust (`proto/chat`, `veza-common/{chat,ws}.rs`) | Non mentionné | Flaggé | ✅ **supprimés** (commit `172581ff`) |
|
||||
| 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 | (hors scope cleanup) |
|
||||
| Workflows CI | `.github/workflows/*` non consolidé | Consolidé (`ci.yml`) + **19 disabled qui traînent** | ✅ **19 archivés** dans `docs/archive/workflows/` |
|
||||
| `docs/audit-2026-04/` | Absent | **Nouveau** — axis-1-correctness + v107-plan | — |
|
||||
|
||||
**Score global** : v1 "Moyen-Haute dette" → v2 "Basse dette code / Haute dette hygiène" → **v3 "dette résiduelle mineure" (1 item pending, 3 false-positives classés, 2 deferrals v1.0.8)**.
|
||||
|
||||
---
|
||||
|
||||
*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.*
|
||||
288
FUNCTIONAL_AUDIT.md
Normal file
288
FUNCTIONAL_AUDIT.md
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## 7. Cleanup session post-rc1 (2026-04-23)
|
||||
|
||||
Une session cleanup + BFG a été exécutée 4 jours après cet audit. Cross-référence avec [AUDIT_REPORT.md §9](AUDIT_REPORT.md) :
|
||||
|
||||
- ✅ **10/15 items Top-15 traités** (cleanup #1/#2/#3/#6/#7/#9/#11/#12/#13, BFG inclus)
|
||||
- ⚠️ **3 false-positives identifiés** (#4 context propagation, #5 security headers, #10 `RespondWithAppError`) — voir `AUDIT_REPORT.md §9.bis` pour les preuves
|
||||
- 📋 **2 deferrals v1.0.8** (#8 OpenAPI typegen, #14 E2E Playwright CI)
|
||||
- 📝 **1 item pending** (#15 `docs/ENV_VARIABLES.md` sync, 0.5j)
|
||||
- **Repo `.git` : 1.5 GB → 66 MB** (−97%) après 2 passes git-filter-repo + force-push stages 1+2
|
||||
|
||||
Les 2 findings fonctionnels subsistants (WebRTC STUN/TURN + stockage uploads disque local) restent **post-v1.0.7-final** dans le scope v1.0.8 (2-3j chacun).
|
||||
|
||||
---
|
||||
|
||||
*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). §7 ajouté 2026-04-23 post-session cleanup.*
|
||||
12
apps/web/.size-limit.json
Normal file
12
apps/web/.size-limit.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[
|
||||
{
|
||||
"path": "dist/assets/index-*.js",
|
||||
"limit": "300 KB",
|
||||
"gzip": true
|
||||
},
|
||||
{
|
||||
"path": "dist/assets/*.css",
|
||||
"limit": "80 KB",
|
||||
"gzip": true
|
||||
}
|
||||
]
|
||||
14
apps/web/lostpixel.config.ts
Normal file
14
apps/web/lostpixel.config.ts
Normal 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',
|
||||
};
|
||||
45
apps/web/src/components/ui/testids.ts
Normal file
45
apps/web/src/components/ui/testids.ts
Normal 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;
|
||||
585
apps/web/src/schemas/__tests__/validation.property.test.ts
Normal file
585
apps/web/src/schemas/__tests__/validation.property.test.ts
Normal 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();
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
441
apps/web/src/utils/__tests__/formatters.property.test.ts
Normal file
441
apps/web/src/utils/__tests__/formatters.property.test.ts
Normal 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}`);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
26
apps/web/stryker.config.mjs
Normal file
26
apps/web/stryker.config.mjs
Normal 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,
|
||||
};
|
||||
|
|
@ -130,7 +130,7 @@ services:
|
|||
memory: 64M
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
|
||||
container_name: veza_minio
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
|
|
@ -151,7 +151,7 @@ services:
|
|||
- veza-net
|
||||
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
image: minio/mc:RELEASE.2025-09-07T05-25-40Z
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -316,7 +316,7 @@ services:
|
|||
retries: 3
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
|
||||
container_name: veza_minio
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
|
|
@ -334,7 +334,7 @@ services:
|
|||
retries: 3
|
||||
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
image: minio/mc:RELEASE.2025-09-07T05-25-40Z
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ services:
|
|||
- frontend
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
|
||||
container_name: veza_minio_staging
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
|
|
@ -176,7 +176,7 @@ services:
|
|||
retries: 5
|
||||
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
image: minio/mc:RELEASE.2025-09-07T05-25-40Z
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ services:
|
|||
|
||||
# MinIO - S3-compatible object storage (v0.501 Cloud Storage)
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
|
||||
container_name: veza_minio
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
|
|
@ -317,7 +317,7 @@ services:
|
|||
|
||||
# MinIO bucket initialization
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
image: minio/mc:RELEASE.2025-09-07T05-25-40Z
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
85
docs/testing/E2E_STABILITY_GUIDE.md
Normal file
85
docs/testing/E2E_STABILITY_GUIDE.md
Normal 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()` |
|
||||
|
|
@ -1,980 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
VEZA — Générateur de prompts Claude Code pour audit exhaustif de toutes les pages.
|
||||
Génère un prompt par route, prêt à être copié-collé dans Claude Code avec Playwright MCP.
|
||||
|
||||
Usage:
|
||||
python veza-prompt-generator.py # Génère tous les prompts dans ./prompts/
|
||||
python veza-prompt-generator.py --route /settings # Génère un seul prompt
|
||||
python veza-prompt-generator.py --list # Liste toutes les routes
|
||||
python veza-prompt-generator.py --batch 1 # Génère le batch 1 (routes groupées)
|
||||
python veza-prompt-generator.py --combined # Un seul fichier avec tous les prompts
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Configuration
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
OUTPUT_DIR = "./prompts"
|
||||
DOMAIN = "veza.fr"
|
||||
|
||||
TEST_ACCOUNTS = [
|
||||
{"role": "admin", "email": "admin@veza.music", "password": "Admin123!"},
|
||||
{"role": "creator", "email": "artist@veza.music", "password": "Artist123!"},
|
||||
{"role": "user", "email": "user@veza.music", "password": "User123!"},
|
||||
{"role": "moderator", "email": "mod@veza.music", "password": "Mod123!"},
|
||||
{"role": "user_new", "email": "new@veza.music", "password": "New123!"},
|
||||
]
|
||||
|
||||
E2E_TEST_DIR = "veza-e2e/"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Route definitions
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class Route:
|
||||
number: int
|
||||
path: str
|
||||
name: str
|
||||
category: str
|
||||
auth_required: bool = True
|
||||
role: str = "user"
|
||||
params: str = ""
|
||||
test_with_accounts: list = field(default_factory=list)
|
||||
specific_checks: list = field(default_factory=list)
|
||||
|
||||
|
||||
ROUTES = [
|
||||
# ── Pages publiques ──
|
||||
Route(1, "/login", "Connexion", "public", auth_required=False,
|
||||
specific_checks=[
|
||||
"Formulaire email + password fonctionnel",
|
||||
"Validation des champs (email invalide, champs vides)",
|
||||
"Messages d'erreur (mauvais credentials)",
|
||||
"Lien 'Mot de passe oublié' fonctionnel",
|
||||
"Lien 'Inscription' fonctionnel",
|
||||
"Redirection après login réussi",
|
||||
"Protection contre brute-force (rate limiting visible)",
|
||||
"Autocomplete attributes sur les inputs",
|
||||
"Accessibilité: labels, focus order, aria",
|
||||
]),
|
||||
Route(2, "/register", "Inscription", "public", auth_required=False,
|
||||
specific_checks=[
|
||||
"Tous les champs du formulaire (username, email, password, confirm)",
|
||||
"Validation temps réel (email format, password strength, username dispo)",
|
||||
"Messages d'erreur clairs pour chaque règle de validation",
|
||||
"Soumission et retour serveur (201, 409 duplicate, 422 validation)",
|
||||
"Lien vers /login fonctionnel",
|
||||
"CGU / checkbox conditions d'utilisation si présent",
|
||||
"Accessibilité: labels, focus, aria, autocomplete",
|
||||
]),
|
||||
Route(3, "/forgot-password", "Récupération mot de passe", "public", auth_required=False,
|
||||
specific_checks=[
|
||||
"Formulaire email fonctionnel",
|
||||
"Message de confirmation après soumission",
|
||||
"Gestion email inexistant (pas de leak d'info)",
|
||||
"Lien retour vers /login",
|
||||
"Rate limiting sur les soumissions",
|
||||
]),
|
||||
Route(4, "/verify-email", "Vérification email", "public", auth_required=False,
|
||||
specific_checks=[
|
||||
"Comportement avec token valide vs invalide vs expiré",
|
||||
"Message de succès / erreur approprié",
|
||||
"Redirection après vérification",
|
||||
"Gestion du cas sans token dans l'URL",
|
||||
]),
|
||||
Route(5, "/reset-password", "Réinitialisation mot de passe", "public", auth_required=False,
|
||||
specific_checks=[
|
||||
"Formulaire nouveau password + confirmation",
|
||||
"Validation password strength côté client",
|
||||
"Comportement avec token valide vs invalide vs expiré",
|
||||
"Message de succès + redirection vers /login",
|
||||
"Autocomplete attributes",
|
||||
]),
|
||||
Route(6, "/launch", "Landing page pré-lancement", "public", auth_required=False,
|
||||
specific_checks=[
|
||||
"Rendu visuel complet (hero, sections, CTA)",
|
||||
"Tous les liens/CTA fonctionnels",
|
||||
"Responsive (mobile/tablet/desktop)",
|
||||
"Performance de chargement",
|
||||
"SEO: title, meta description, OG tags",
|
||||
]),
|
||||
Route(7, "/design-system", "Démo du design system", "public", auth_required=False,
|
||||
specific_checks=[
|
||||
"Tous les composants se rendent sans erreur",
|
||||
"Pas d'erreurs console",
|
||||
"Interactivité des composants démo (boutons, toggles, modals)",
|
||||
]),
|
||||
Route(8, "/u/:username", "Profil public utilisateur", "public", auth_required=False,
|
||||
params="username",
|
||||
specific_checks=[
|
||||
"Affichage avec username existant (ex: tester avec chaque compte)",
|
||||
"Page 404 avec username inexistant",
|
||||
"Infos publiques affichées (avatar, bio, tracks publiques)",
|
||||
"Pas de fuite d'infos privées (email, settings)",
|
||||
"Boutons follow/unfollow si connecté",
|
||||
"Liste des tracks/playlists publiques",
|
||||
"Liens fonctionnels vers les tracks",
|
||||
]),
|
||||
Route(9, "/playlists/shared/:token", "Playlist partagée", "public", auth_required=False,
|
||||
params="token",
|
||||
specific_checks=[
|
||||
"Affichage avec token valide",
|
||||
"Gestion token invalide / expiré",
|
||||
"Lecture des tracks depuis le partage",
|
||||
"Infos playlist (titre, description, nombre de tracks)",
|
||||
]),
|
||||
|
||||
# ── Navigation principale ──
|
||||
Route(10, "/dashboard", "Tableau de bord", "main_nav",
|
||||
test_with_accounts=["user", "creator", "admin"],
|
||||
specific_checks=[
|
||||
"Widgets / cartes de stats se chargent",
|
||||
"Données cohérentes avec le rôle (creator voit ses stats artiste)",
|
||||
"Liens rapides fonctionnels",
|
||||
"Activité récente / feed",
|
||||
"Pas d'erreurs réseau dans la console",
|
||||
"Responsive layout",
|
||||
]),
|
||||
Route(11, "/discover", "Découverte musicale", "main_nav",
|
||||
specific_checks=[
|
||||
"Sections de découverte (trending, new releases, genres)",
|
||||
"Cartes de tracks/albums cliquables",
|
||||
"Lecture depuis la page discover",
|
||||
"Filtres / catégories fonctionnels",
|
||||
"Infinite scroll ou pagination",
|
||||
"État vide si aucun contenu",
|
||||
]),
|
||||
Route(12, "/feed", "Fil d'actualité", "main_nav",
|
||||
specific_checks=[
|
||||
"Posts / activités des artistes suivis",
|
||||
"Infinite scroll ou pagination",
|
||||
"Interactions (like, comment, share)",
|
||||
"État vide (aucun artiste suivi)",
|
||||
"Chargement sans erreurs réseau",
|
||||
]),
|
||||
Route(13, "/library", "Bibliothèque musicale", "main_nav",
|
||||
test_with_accounts=["user", "creator"],
|
||||
specific_checks=[
|
||||
"Liste des tracks / albums / playlists de l'utilisateur",
|
||||
"Tri et filtres fonctionnels",
|
||||
"Recherche dans la bibliothèque",
|
||||
"Actions sur les items (play, add to playlist, delete)",
|
||||
"État vide pour un nouveau compte",
|
||||
"Pagination / infinite scroll",
|
||||
]),
|
||||
Route(14, "/queue", "File de lecture", "main_nav",
|
||||
specific_checks=[
|
||||
"Affichage de la queue actuelle",
|
||||
"Drag & drop pour réordonner",
|
||||
"Suppression d'items de la queue",
|
||||
"Bouton clear queue",
|
||||
"État vide",
|
||||
"Persistance après navigation",
|
||||
]),
|
||||
Route(15, "/search", "Recherche globale", "main_nav",
|
||||
specific_checks=[
|
||||
"Barre de recherche fonctionnelle",
|
||||
"Résultats par catégorie (tracks, artists, playlists, albums)",
|
||||
"Recherche en temps réel / debounce",
|
||||
"Résultats cliquables et navigation correcte",
|
||||
"État 'aucun résultat'",
|
||||
"Historique de recherche si présent",
|
||||
]),
|
||||
|
||||
# ── Playlists & Tracks ──
|
||||
Route(16, "/playlists", "Liste des playlists", "playlists",
|
||||
specific_checks=[
|
||||
"Liste des playlists de l'utilisateur",
|
||||
"Bouton créer une playlist",
|
||||
"Cartes playlist cliquables",
|
||||
"Infos affichées (titre, nombre de tracks, durée, cover)",
|
||||
"Actions (edit, delete, share)",
|
||||
"État vide",
|
||||
]),
|
||||
Route(17, "/playlists/favoris", "Playlist favoris", "playlists",
|
||||
specific_checks=[
|
||||
"Liste des tracks favorites",
|
||||
"Bouton unfavorite fonctionnel",
|
||||
"Lecture depuis les favoris",
|
||||
"Tri (date ajoutée, titre, artiste)",
|
||||
"État vide",
|
||||
]),
|
||||
Route(18, "/playlists/:id", "Détail playlist", "playlists",
|
||||
params="id",
|
||||
specific_checks=[
|
||||
"Header playlist (titre, description, cover, auteur)",
|
||||
"Liste des tracks avec numérotation",
|
||||
"Boutons play / shuffle",
|
||||
"Actions par track (play, add to queue, remove)",
|
||||
"Boutons share / edit / delete (si owner)",
|
||||
"Playlist inexistante → 404",
|
||||
"Playlist privée d'un autre user → 403 ou 404",
|
||||
]),
|
||||
Route(19, "/playlists/:id/edit", "Édition playlist", "playlists",
|
||||
params="id",
|
||||
specific_checks=[
|
||||
"Formulaire pré-rempli (titre, description)",
|
||||
"Upload / changement de cover",
|
||||
"Réordonnement des tracks (drag & drop)",
|
||||
"Ajout / suppression de tracks",
|
||||
"Sauvegarde fonctionnelle",
|
||||
"Validation (titre requis)",
|
||||
"Accès interdit si pas owner → redirect ou 403",
|
||||
]),
|
||||
Route(20, "/tracks/:id", "Détail track", "playlists",
|
||||
params="id",
|
||||
specific_checks=[
|
||||
"Infos track (titre, artiste, album, durée, cover)",
|
||||
"Bouton play fonctionnel",
|
||||
"Boutons like / add to playlist / share",
|
||||
"Waveform / visualisation audio si présent",
|
||||
"Commentaires si présent",
|
||||
"Tracks similaires / recommandations",
|
||||
"Track inexistante → 404",
|
||||
"Métadonnées (genre, BPM, key si affichés)",
|
||||
]),
|
||||
|
||||
# ── Social & Communication ──
|
||||
Route(21, "/social", "Communauté", "social",
|
||||
specific_checks=[
|
||||
"Liste des utilisateurs / artistes",
|
||||
"Recherche / filtres",
|
||||
"Boutons follow / unfollow",
|
||||
"Profils cliquables",
|
||||
"Compteurs followers/following",
|
||||
"Sections (trending artists, new members, etc.)",
|
||||
]),
|
||||
Route(22, "/chat", "Messagerie", "social",
|
||||
test_with_accounts=["user", "creator"],
|
||||
specific_checks=[
|
||||
"Liste des conversations",
|
||||
"Envoi de message",
|
||||
"Réception en temps réel (WebSocket)",
|
||||
"Création de nouvelle conversation",
|
||||
"Recherche dans les conversations",
|
||||
"État vide (aucune conversation)",
|
||||
"Indicateurs read/unread",
|
||||
"Envoi de médias si supporté",
|
||||
]),
|
||||
Route(23, "/chat/join/:token", "Rejoindre un chat", "social",
|
||||
params="token",
|
||||
specific_checks=[
|
||||
"Token valide → rejoint le chat + redirect",
|
||||
"Token invalide → message d'erreur",
|
||||
"Token expiré → message d'erreur",
|
||||
"Déjà membre → redirect vers le chat existant",
|
||||
]),
|
||||
Route(24, "/live", "Streams en direct", "social",
|
||||
specific_checks=[
|
||||
"Liste des streams en cours",
|
||||
"Cartes stream (titre, artiste, viewers, thumbnail)",
|
||||
"Clic → page de visionnage",
|
||||
"État vide (aucun stream)",
|
||||
"Compteurs viewers",
|
||||
]),
|
||||
Route(25, "/live/go-live", "Lancer un stream", "social",
|
||||
role="creator",
|
||||
test_with_accounts=["creator", "admin"],
|
||||
specific_checks=[
|
||||
"Formulaire de configuration stream (titre, description, catégorie)",
|
||||
"Prévisualisation caméra/micro",
|
||||
"Bouton Go Live",
|
||||
"Vérification permissions (role creator/admin requis)",
|
||||
"Utilisateur standard → redirect ou message d'erreur",
|
||||
"Configuration RTMP/HLS si affichée",
|
||||
]),
|
||||
Route(26, "/listen-together/:sessionId", "Écoute collaborative", "social",
|
||||
params="sessionId",
|
||||
specific_checks=[
|
||||
"Session valide → rejoint l'écoute",
|
||||
"Session invalide → erreur",
|
||||
"Synchronisation audio entre participants",
|
||||
"Liste des participants",
|
||||
"Chat intégré si présent",
|
||||
"Contrôles de lecture (host only vs all)",
|
||||
]),
|
||||
|
||||
# ── Marketplace & Commerce ──
|
||||
Route(27, "/marketplace", "Place de marché", "marketplace",
|
||||
specific_checks=[
|
||||
"Grille / liste de produits",
|
||||
"Filtres (catégorie, prix, type)",
|
||||
"Recherche produits",
|
||||
"Cartes produits (image, titre, prix, vendeur)",
|
||||
"Pagination / infinite scroll",
|
||||
"Tri (prix, date, popularité)",
|
||||
]),
|
||||
Route(28, "/marketplace/products/:id", "Détail produit", "marketplace",
|
||||
params="id",
|
||||
specific_checks=[
|
||||
"Infos produit (images, titre, description, prix)",
|
||||
"Bouton acheter / ajouter au panier",
|
||||
"Avis / ratings si présent",
|
||||
"Infos vendeur",
|
||||
"Produit inexistant → 404",
|
||||
"Produits similaires",
|
||||
]),
|
||||
Route(29, "/wishlist", "Liste de souhaits", "marketplace",
|
||||
specific_checks=[
|
||||
"Liste des produits wishlistés",
|
||||
"Bouton retirer de la wishlist",
|
||||
"Bouton acheter directement",
|
||||
"État vide",
|
||||
"Infos produit à jour (prix, disponibilité)",
|
||||
]),
|
||||
Route(30, "/purchases", "Historique d'achats", "marketplace",
|
||||
specific_checks=[
|
||||
"Liste des achats passés",
|
||||
"Détails par achat (date, montant, produit, statut)",
|
||||
"Téléchargement si digital",
|
||||
"État vide",
|
||||
"Pagination si beaucoup d'achats",
|
||||
]),
|
||||
Route(31, "/checkout/complete", "Confirmation de commande", "marketplace",
|
||||
specific_checks=[
|
||||
"Message de confirmation",
|
||||
"Récapitulatif de la commande",
|
||||
"Liens vers les achats / téléchargements",
|
||||
"Comportement si accès direct sans commande → redirect",
|
||||
]),
|
||||
Route(32, "/sell", "Dashboard vendeur", "marketplace",
|
||||
role="creator",
|
||||
test_with_accounts=["creator", "admin"],
|
||||
specific_checks=[
|
||||
"Stats de vente (revenus, nombre de ventes)",
|
||||
"Liste des produits en vente",
|
||||
"Bouton ajouter un produit",
|
||||
"Gestion des produits (edit, delete, toggle visibility)",
|
||||
"Accès interdit pour les non-creators",
|
||||
]),
|
||||
|
||||
# ── Créateur & Analytics ──
|
||||
Route(33, "/analytics", "Tableau de bord analytics", "creator",
|
||||
role="creator",
|
||||
test_with_accounts=["creator", "admin"],
|
||||
specific_checks=[
|
||||
"Graphiques de stats (plays, listeners, revenue)",
|
||||
"Période sélectionnable (7j, 30j, 90j, 1an)",
|
||||
"Top tracks / Top playlists",
|
||||
"Données démographiques si présent",
|
||||
"Export de données si présent",
|
||||
"État vide pour un nouveau creator",
|
||||
"Accès interdit pour user standard",
|
||||
]),
|
||||
Route(34, "/cloud", "Stockage cloud", "creator",
|
||||
role="creator",
|
||||
test_with_accounts=["creator"],
|
||||
specific_checks=[
|
||||
"Explorateur de fichiers",
|
||||
"Upload de fichiers audio",
|
||||
"Progress bar upload",
|
||||
"Organisation en dossiers",
|
||||
"Actions (rename, delete, move, download)",
|
||||
"Quota de stockage affiché",
|
||||
"Types de fichiers acceptés / rejetés",
|
||||
]),
|
||||
Route(35, "/gear", "Inventaire équipement", "creator",
|
||||
role="creator",
|
||||
specific_checks=[
|
||||
"Liste de l'équipement",
|
||||
"Ajout / édition / suppression d'items",
|
||||
"Catégories (instruments, software, hardware)",
|
||||
"Photos d'équipement si supporté",
|
||||
"État vide",
|
||||
]),
|
||||
Route(36, "/distribution", "Distribution externe", "creator",
|
||||
role="creator",
|
||||
specific_checks=[
|
||||
"Liste des plateformes de distribution",
|
||||
"Statut de distribution par track/album",
|
||||
"Bouton distribuer",
|
||||
"Configuration des métadonnées requises",
|
||||
"Historique des distributions",
|
||||
]),
|
||||
|
||||
# ── Compte & Paramètres ──
|
||||
Route(37, "/profile", "Profil (redirect)", "account",
|
||||
specific_checks=[
|
||||
"Redirection vers /u/:username du user connecté",
|
||||
"Redirection correcte pour chaque rôle",
|
||||
]),
|
||||
Route(38, "/settings", "Paramètres du compte", "account",
|
||||
test_with_accounts=["user", "creator", "admin"],
|
||||
specific_checks=[
|
||||
"Onglet Account: changement de password fonctionnel",
|
||||
"Onglet Account: setup/disable 2FA (Authenticator + SMS)",
|
||||
"Onglet Account: statut 2FA correct",
|
||||
"Onglet Account: Delete Account (validation 'DELETE', password requis)",
|
||||
"Onglet Préférences: thème (radio buttons mutuellement exclusifs)",
|
||||
"Onglet Préférences: langue (changement effectif)",
|
||||
"Onglet Préférences: timezone",
|
||||
"Onglet Notifications: tous les toggles fonctionnels",
|
||||
"Onglet Confidentialité: tous les toggles fonctionnels",
|
||||
"Onglet Playback: audio quality, crossfade, etc.",
|
||||
"Bouton Save Config fonctionnel (pas d'erreur validation)",
|
||||
"Accessibilité: labels sur tous les checkboxes/toggles",
|
||||
"Pas de mélange i18n (tout FR ou tout EN)",
|
||||
"Pas de fuite d'infos (email dans search bar, VAPID key)",
|
||||
]),
|
||||
Route(39, "/settings/sessions", "Sessions actives", "account",
|
||||
specific_checks=[
|
||||
"Liste des sessions actives (device, IP, date)",
|
||||
"Session courante identifiée",
|
||||
"Bouton révoquer une session",
|
||||
"Bouton révoquer toutes les autres sessions",
|
||||
"Confirmation avant révocation",
|
||||
]),
|
||||
Route(40, "/notifications", "Centre de notifications", "account",
|
||||
specific_checks=[
|
||||
"Liste des notifications",
|
||||
"Marquage lu/non-lu",
|
||||
"Bouton marquer tout comme lu",
|
||||
"Filtres par type de notification",
|
||||
"Clic sur notification → navigation correcte",
|
||||
"État vide",
|
||||
"Pagination / infinite scroll",
|
||||
]),
|
||||
Route(41, "/subscription", "Abonnements & plans", "account",
|
||||
specific_checks=[
|
||||
"Affichage des plans disponibles",
|
||||
"Plan actuel mis en évidence",
|
||||
"Boutons upgrade / downgrade",
|
||||
"Comparaison des features par plan",
|
||||
"Historique de facturation si présent",
|
||||
"Gestion du moyen de paiement",
|
||||
]),
|
||||
|
||||
# ── Apprentissage & Support ──
|
||||
Route(42, "/education", "Formation & ressources", "learning",
|
||||
specific_checks=[
|
||||
"Liste des cours / ressources",
|
||||
"Catégories / filtres",
|
||||
"Progression si présent",
|
||||
"Contenu vidéo / texte se charge",
|
||||
"Liens fonctionnels",
|
||||
]),
|
||||
Route(43, "/support", "Support & aide", "learning",
|
||||
specific_checks=[
|
||||
"FAQ / base de connaissances",
|
||||
"Formulaire de contact / ticket",
|
||||
"Chat support si présent",
|
||||
"Recherche dans l'aide",
|
||||
"Liens vers documentation",
|
||||
]),
|
||||
|
||||
# ── Administration ──
|
||||
Route(44, "/admin", "Dashboard admin", "admin", role="admin",
|
||||
test_with_accounts=["admin"],
|
||||
specific_checks=[
|
||||
"Stats globales plateforme (users, tracks, revenue)",
|
||||
"Graphiques / KPIs",
|
||||
"Accès interdit pour non-admin → redirect ou 403",
|
||||
"Liens rapides vers sous-sections admin",
|
||||
]),
|
||||
Route(45, "/admin/moderation", "Modération contenu", "admin", role="admin",
|
||||
test_with_accounts=["admin", "moderator"],
|
||||
specific_checks=[
|
||||
"Queue de modération (signalements)",
|
||||
"Actions (approve, reject, ban)",
|
||||
"Filtres par type de contenu / statut",
|
||||
"Détail du signalement",
|
||||
"Historique des actions de modération",
|
||||
]),
|
||||
Route(46, "/admin/platform", "Administration plateforme", "admin", role="admin",
|
||||
test_with_accounts=["admin"],
|
||||
specific_checks=[
|
||||
"Configuration plateforme",
|
||||
"Gestion des features flags",
|
||||
"Stats système (storage, bandwidth)",
|
||||
"Logs si présent",
|
||||
]),
|
||||
Route(47, "/admin/transfers", "Gestion transferts/paiements", "admin", role="admin",
|
||||
test_with_accounts=["admin"],
|
||||
specific_checks=[
|
||||
"Liste des transferts / paiements",
|
||||
"Statuts (pending, completed, failed)",
|
||||
"Détail par transfert",
|
||||
"Actions (approve, reject, retry)",
|
||||
"Filtres et recherche",
|
||||
]),
|
||||
Route(48, "/admin/roles", "Gestion des rôles", "admin", role="admin",
|
||||
test_with_accounts=["admin"],
|
||||
specific_checks=[
|
||||
"Liste des rôles existants",
|
||||
"Permissions par rôle",
|
||||
"Assignation de rôles aux utilisateurs",
|
||||
"Création / modification de rôles",
|
||||
"Protection contre la suppression du rôle admin",
|
||||
]),
|
||||
|
||||
# ── Développeur ──
|
||||
Route(49, "/developer", "Dashboard développeur", "developer",
|
||||
specific_checks=[
|
||||
"Clés API (création, révocation, copie)",
|
||||
"Documentation API intégrée ou liens",
|
||||
"Stats d'utilisation API",
|
||||
"Webhooks configurés",
|
||||
"Rate limits affichés",
|
||||
]),
|
||||
Route(50, "/webhooks", "Gestion webhooks", "developer",
|
||||
specific_checks=[
|
||||
"Liste des webhooks configurés",
|
||||
"Ajout d'un webhook (URL, events, secret)",
|
||||
"Test de webhook",
|
||||
"Logs de delivery (succès/échec)",
|
||||
"Actions (edit, delete, toggle active)",
|
||||
]),
|
||||
|
||||
# ── Redirections ──
|
||||
Route(51, "/", "Redirect → /launch", "redirect", auth_required=False,
|
||||
specific_checks=[
|
||||
"Redirection automatique vers /launch",
|
||||
"Status code 301/302 approprié",
|
||||
"Pas de flash de contenu avant redirect",
|
||||
]),
|
||||
Route(52, "/tracks", "Redirect → /library", "redirect",
|
||||
specific_checks=[
|
||||
"Redirection automatique vers /library",
|
||||
"Fonctionne quand connecté",
|
||||
]),
|
||||
Route(53, "/community", "Redirect → /social", "redirect",
|
||||
specific_checks=[
|
||||
"Redirection automatique vers /social",
|
||||
]),
|
||||
Route(54, "/favorites", "Redirect → /playlists/favoris", "redirect",
|
||||
specific_checks=[
|
||||
"Redirection automatique vers /playlists/favoris",
|
||||
]),
|
||||
Route(55, "/home", "Redirect → /dashboard", "redirect",
|
||||
specific_checks=[
|
||||
"Redirection automatique vers /dashboard",
|
||||
]),
|
||||
|
||||
# ── Pages d'erreur ──
|
||||
Route(56, "/404", "Page non trouvée", "error", auth_required=False,
|
||||
specific_checks=[
|
||||
"Affichage du message 404",
|
||||
"Lien retour vers l'accueil",
|
||||
"Design cohérent avec le reste du site",
|
||||
"Pas d'erreurs console",
|
||||
]),
|
||||
Route(57, "/500", "Erreur serveur", "error", auth_required=False,
|
||||
specific_checks=[
|
||||
"Affichage du message d'erreur serveur",
|
||||
"Lien retour / bouton réessayer",
|
||||
"Pas de stack trace exposée",
|
||||
]),
|
||||
Route(58, "/random-nonexistent-path", "Catch-all → /404", "error", auth_required=False,
|
||||
specific_checks=[
|
||||
"Redirection vers la page 404",
|
||||
"URL arbitraire correctement interceptée",
|
||||
]),
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Prompt template
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def build_test_accounts_block() -> str:
|
||||
lines = []
|
||||
lines.append("Comptes de test :")
|
||||
lines.append("")
|
||||
lines.append("┌───────────┬───────────────────┬──────────────┐")
|
||||
lines.append("│ Rôle │ Email │ Mot de passe │")
|
||||
lines.append("├───────────┼───────────────────┼──────────────┤")
|
||||
for i, acc in enumerate(TEST_ACCOUNTS):
|
||||
role = acc["role"].ljust(9)
|
||||
email = acc["email"].ljust(17)
|
||||
pwd = acc["password"].ljust(12)
|
||||
lines.append(f"│ {role} │ {email} │ {pwd} │")
|
||||
if i < len(TEST_ACCOUNTS) - 1:
|
||||
lines.append("├───────────┼───────────────────┼──────────────┤")
|
||||
lines.append("└───────────┴───────────────────┴──────────────┘")
|
||||
lines.append(f"Domaine : {DOMAIN}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_prompt(route: Route) -> str:
|
||||
"""Génère le prompt Claude Code pour une route donnée."""
|
||||
|
||||
# Déterminer les comptes à utiliser
|
||||
if route.test_with_accounts:
|
||||
accounts_to_use = route.test_with_accounts
|
||||
elif route.role == "admin":
|
||||
accounts_to_use = ["admin"]
|
||||
elif route.role == "creator":
|
||||
accounts_to_use = ["creator"]
|
||||
elif not route.auth_required:
|
||||
accounts_to_use = ["(pas de connexion)", "user"]
|
||||
else:
|
||||
accounts_to_use = ["user", "creator"]
|
||||
|
||||
accounts_str = ", ".join(accounts_to_use)
|
||||
|
||||
# Checks spécifiques
|
||||
checks_block = ""
|
||||
if route.specific_checks:
|
||||
checks_lines = [f" - {c}" for c in route.specific_checks]
|
||||
checks_block = "\n".join(checks_lines)
|
||||
|
||||
# Paramètres dynamiques
|
||||
params_note = ""
|
||||
if route.params:
|
||||
params_note = f"""
|
||||
Note sur les paramètres dynamiques :
|
||||
Cette route utilise le paramètre `{route.params}`.
|
||||
- Teste avec une valeur VALIDE (existante en base)
|
||||
- Teste avec une valeur INVALIDE (inexistante) → comportement d'erreur attendu
|
||||
- Teste avec une valeur malformée (injection, caractères spéciaux)
|
||||
"""
|
||||
|
||||
prompt = f"""ok maintenant il faut dresser une liste exhaustive de toutes les erreurs qui existent dans la page "{route.name}" ({route.path}).
|
||||
Il faut tester chaque fonctionnalité une par une avec le MCP Playwright pour simuler un utilisateur réel.
|
||||
|
||||
{build_test_accounts_block()}
|
||||
|
||||
Comptes à utiliser pour cette page : {accounts_str}
|
||||
{params_note}
|
||||
─── INSTRUCTIONS ───
|
||||
|
||||
1. AUDIT EXHAUSTIF avec Playwright MCP :
|
||||
|
||||
Pour la page {route.path} ("{route.name}"), teste systématiquement :
|
||||
|
||||
a) CHARGEMENT & RENDU
|
||||
- La page se charge sans erreur (pas de crash, pas de blank screen)
|
||||
- Pas d'erreurs dans la console navigateur (JS errors, failed network requests)
|
||||
- Tous les éléments visuels attendus sont présents et visibles
|
||||
- Le layout est correct (pas d'overflow, pas d'éléments qui se chevauchent)
|
||||
|
||||
b) FONCTIONNALITÉS SPÉCIFIQUES À CETTE PAGE
|
||||
{checks_block}
|
||||
|
||||
c) RÉSEAU & API
|
||||
- Tous les appels API réussissent (pas de 4xx/5xx inattendus)
|
||||
- Les données affichées correspondent aux réponses API
|
||||
- Gestion correcte du loading state
|
||||
- Gestion correcte des erreurs réseau
|
||||
|
||||
d) SÉCURITÉ
|
||||
- Pas de fuite d'informations sensibles (tokens, emails dans l'URL, clés API)
|
||||
- Les données d'un autre utilisateur ne sont pas accessibles
|
||||
- Les actions protégées nécessitent bien l'authentification
|
||||
- CSRF / XSS : vérifier les inputs utilisateur
|
||||
{" - Vérifier que les rôles non-autorisés reçoivent bien un 403 ou redirect" if route.role != "user" else ""}
|
||||
|
||||
e) ACCESSIBILITÉ (a11y)
|
||||
- Tous les éléments interactifs ont un accessible name descriptif (pas juste "Checkbox" ou "Button")
|
||||
- Les labels sont associés aux inputs
|
||||
- Le focus order est logique (Tab navigation)
|
||||
- Les aria-labels/aria-describedby sont présents et pertinents
|
||||
- Contraste suffisant sur les textes
|
||||
|
||||
f) INTERNATIONALISATION (i18n)
|
||||
- Pas de mélange de langues (tout FR ou tout EN, pas les deux)
|
||||
- Les traductions sont complètes (pas de clés i18n brutes affichées)
|
||||
|
||||
g) RESPONSIVE
|
||||
- Tester en viewport mobile (375px), tablet (768px), desktop (1280px)
|
||||
- Pas d'éléments qui débordent
|
||||
- Navigation mobile fonctionnelle
|
||||
|
||||
2. FORMAT DU RAPPORT :
|
||||
|
||||
Produis un rapport structuré exactement comme suit :
|
||||
|
||||
```
|
||||
Rapport exhaustif des erreurs — Page {route.name} ({route.path})
|
||||
Testé avec le(s) compte(s) : {accounts_str}
|
||||
Date : [date du test]
|
||||
|
||||
───
|
||||
ERREURS CRITIQUES (bloquantes)
|
||||
|
||||
BUG #1 — [Titre court]
|
||||
- Sévérité: CRITIQUE
|
||||
- Section: [section de la page]
|
||||
- Repro: [étapes de reproduction]
|
||||
- Erreur: [description technique]
|
||||
- Source: [fichier:ligne si identifiable]
|
||||
- Impact: [impact utilisateur]
|
||||
|
||||
───
|
||||
ERREURS HAUTES
|
||||
|
||||
...
|
||||
|
||||
───
|
||||
ERREURS MOYENNES
|
||||
|
||||
...
|
||||
|
||||
───
|
||||
ERREURS FAIBLES
|
||||
|
||||
...
|
||||
|
||||
───
|
||||
RÉSUMÉ
|
||||
|
||||
┌────────────────────────────┬─────────┬──────────────┐
|
||||
│ Catégorie │ Nombre │ Sévérité max │
|
||||
├────────────────────────────┼─────────┼──────────────┤
|
||||
│ ... │ ... │ ... │
|
||||
└────────────────────────────┴─────────┴──────────────┘
|
||||
```
|
||||
|
||||
3. CORRECTION DES BUGS :
|
||||
|
||||
Après avoir dressé la liste, corrige TOUS les problèmes trouvés pour que la page soit
|
||||
entièrement fonctionnelle. Pour chaque fix :
|
||||
- Identifie le fichier source exact
|
||||
- Applique le correctif
|
||||
- Vérifie avec Playwright que le bug est résolu
|
||||
|
||||
4. TRANSFORMATION EN TESTS E2E :
|
||||
|
||||
Transforme toute la suite de vérification que tu viens de faire pour la page {route.path} en suite de
|
||||
tests Playwright que tu peux ajouter directement à ceux existants dans {E2E_TEST_DIR}.
|
||||
|
||||
Spécifications des tests :
|
||||
- Fichier : {E2E_TEST_DIR}tests/{_route_to_filename(route.path)}.spec.ts
|
||||
- Framework : Playwright Test (@playwright/test)
|
||||
- Pattern : Page Object Model si la page est complexe
|
||||
- Chaque bug trouvé ET corrigé = au moins 1 test de non-régression
|
||||
- Chaque fonctionnalité testée manuellement = 1 test automatisé
|
||||
- Tests organisés par describe() : "Chargement", "Fonctionnalités", "Sécurité", "a11y", "i18n"
|
||||
- Les tests doivent être indépendants (setup/teardown propre)
|
||||
- Utiliser les test accounts définis ci-dessus pour l'auth dans les fixtures
|
||||
|
||||
Structure attendue :
|
||||
```typescript
|
||||
import {{ test, expect }} from '@playwright/test';
|
||||
|
||||
test.describe('{route.name} ({route.path})', () => {{
|
||||
test.describe('Chargement & Rendu', () => {{
|
||||
test('la page se charge sans erreur', async ({{ page }}) => {{
|
||||
// ...
|
||||
}});
|
||||
}});
|
||||
|
||||
test.describe('Fonctionnalités', () => {{
|
||||
// Un test par fonctionnalité
|
||||
}});
|
||||
|
||||
test.describe('Sécurité', () => {{
|
||||
// Tests de sécurité
|
||||
}});
|
||||
|
||||
test.describe('Accessibilité', () => {{
|
||||
// Tests a11y
|
||||
}});
|
||||
|
||||
test.describe('Régression', () => {{
|
||||
// Un test par bug corrigé
|
||||
}});
|
||||
}});
|
||||
```
|
||||
|
||||
Assure-toi que les nouveaux tests s'intègrent avec la config Playwright existante
|
||||
et ne dupliquent pas des tests déjà présents.
|
||||
"""
|
||||
return prompt.strip()
|
||||
|
||||
|
||||
def _route_to_filename(path: str) -> str:
|
||||
"""Convertit un path comme /admin/moderation en admin-moderation."""
|
||||
clean = path.strip("/").replace("/", "-").replace(":", "")
|
||||
if not clean:
|
||||
clean = "root-redirect"
|
||||
return clean
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Output functions
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def write_single_prompt(route: Route, output_dir: str) -> str:
|
||||
"""Écrit un prompt dans un fichier et retourne le chemin."""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
filename = f"{route.number:02d}-{_route_to_filename(route.path)}.md"
|
||||
filepath = os.path.join(output_dir, filename)
|
||||
prompt = build_prompt(route)
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(prompt)
|
||||
|
||||
return filepath
|
||||
|
||||
|
||||
def write_combined(routes: list[Route], output_dir: str) -> str:
|
||||
"""Écrit tous les prompts dans un seul fichier."""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
filepath = os.path.join(output_dir, "ALL-PROMPTS.md")
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(f"# VEZA — Prompts d'audit exhaustif pour les {len(routes)} routes\n")
|
||||
f.write(f"# Généré le {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||
f.write(f"# Usage : copier-coller chaque section dans Claude Code une par une.\n\n")
|
||||
|
||||
for route in routes:
|
||||
f.write(f"\n{'='*80}\n")
|
||||
f.write(f"# ROUTE {route.number:02d}/{len(routes)} — {route.path} ({route.name})\n")
|
||||
f.write(f"{'='*80}\n\n")
|
||||
f.write(build_prompt(route))
|
||||
f.write("\n\n")
|
||||
|
||||
return filepath
|
||||
|
||||
|
||||
def list_routes(routes: list[Route]):
|
||||
"""Affiche toutes les routes."""
|
||||
categories = {}
|
||||
for r in routes:
|
||||
categories.setdefault(r.category, []).append(r)
|
||||
|
||||
cat_names = {
|
||||
"public": "Pages publiques",
|
||||
"main_nav": "Navigation principale",
|
||||
"playlists": "Playlists & Tracks",
|
||||
"social": "Social & Communication",
|
||||
"marketplace": "Marketplace & Commerce",
|
||||
"creator": "Créateur & Analytics",
|
||||
"account": "Compte & Paramètres",
|
||||
"learning": "Apprentissage & Support",
|
||||
"admin": "Administration",
|
||||
"developer": "Développeur",
|
||||
"redirect": "Redirections",
|
||||
"error": "Pages d'erreur",
|
||||
}
|
||||
|
||||
for cat, cat_routes in categories.items():
|
||||
print(f"\n ── {cat_names.get(cat, cat)} ──")
|
||||
for r in cat_routes:
|
||||
auth = "🔓" if not r.auth_required else f"🔒 {r.role}"
|
||||
print(f" {r.number:2d}. {r.path:<30s} {r.name:<35s} {auth}")
|
||||
|
||||
print(f"\n Total : {len(routes)} routes")
|
||||
|
||||
|
||||
def get_batch(routes: list[Route], batch_num: int, batch_size: int = 5) -> list[Route]:
|
||||
"""Retourne un sous-ensemble de routes par batch."""
|
||||
start = (batch_num - 1) * batch_size
|
||||
end = start + batch_size
|
||||
return routes[start:end]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# CLI
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="VEZA — Générateur de prompts Claude Code pour audit exhaustif",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Exemples :
|
||||
python veza-prompt-generator.py # Tous les prompts → ./prompts/
|
||||
python veza-prompt-generator.py --route /settings # Un seul prompt
|
||||
python veza-prompt-generator.py --route /admin # Un seul prompt
|
||||
python veza-prompt-generator.py --list # Liste les routes
|
||||
python veza-prompt-generator.py --batch 3 # Routes 11-15
|
||||
python veza-prompt-generator.py --batch 3 --size 10 # Routes 21-30
|
||||
python veza-prompt-generator.py --combined # Tout dans un fichier
|
||||
python veza-prompt-generator.py --category admin # Toutes les routes admin
|
||||
python veza-prompt-generator.py --out ./my-prompts # Dossier custom
|
||||
python veza-prompt-generator.py --print /settings # Affiche dans le terminal
|
||||
""",
|
||||
)
|
||||
parser.add_argument("--route", type=str, help="Génère le prompt pour une route spécifique (ex: /settings)")
|
||||
parser.add_argument("--list", action="store_true", help="Liste toutes les routes")
|
||||
parser.add_argument("--batch", type=int, help="Numéro de batch (1-indexed)")
|
||||
parser.add_argument("--size", type=int, default=5, help="Taille d'un batch (défaut: 5)")
|
||||
parser.add_argument("--combined", action="store_true", help="Génère un seul fichier avec tous les prompts")
|
||||
parser.add_argument("--category", type=str, help="Filtre par catégorie (public, admin, social, ...)")
|
||||
parser.add_argument("--out", type=str, default=OUTPUT_DIR, help=f"Dossier de sortie (défaut: {OUTPUT_DIR})")
|
||||
parser.add_argument("--print", dest="print_route", type=str, help="Affiche le prompt dans le terminal au lieu de l'écrire")
|
||||
parser.add_argument("--critical-only", action="store_true", help="Uniquement les routes avec auth/rôle spécifique")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# ── List ──
|
||||
if args.list:
|
||||
list_routes(ROUTES)
|
||||
return
|
||||
|
||||
# ── Print single ──
|
||||
if args.print_route:
|
||||
route = next((r for r in ROUTES if r.path == args.print_route), None)
|
||||
if not route:
|
||||
print(f"❌ Route '{args.print_route}' non trouvée.", file=sys.stderr)
|
||||
print(" Utilise --list pour voir toutes les routes.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(build_prompt(route))
|
||||
return
|
||||
|
||||
# ── Single route ──
|
||||
if args.route:
|
||||
route = next((r for r in ROUTES if r.path == args.route), None)
|
||||
if not route:
|
||||
print(f"❌ Route '{args.route}' non trouvée.", file=sys.stderr)
|
||||
print(" Utilise --list pour voir toutes les routes.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
filepath = write_single_prompt(route, args.out)
|
||||
print(f"✅ Prompt généré : {filepath}")
|
||||
return
|
||||
|
||||
# ── Filter by category ──
|
||||
routes = ROUTES
|
||||
if args.category:
|
||||
routes = [r for r in ROUTES if r.category == args.category]
|
||||
if not routes:
|
||||
print(f"❌ Catégorie '{args.category}' non trouvée.", file=sys.stderr)
|
||||
cats = sorted(set(r.category for r in ROUTES))
|
||||
print(f" Catégories disponibles : {', '.join(cats)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if args.critical_only:
|
||||
routes = [r for r in routes if r.role != "user" or not r.auth_required]
|
||||
|
||||
# ── Batch ──
|
||||
if args.batch:
|
||||
routes = get_batch(routes, args.batch, args.size)
|
||||
if not routes:
|
||||
total_batches = (len(ROUTES) + args.size - 1) // args.size
|
||||
print(f"❌ Batch {args.batch} vide. Batches disponibles : 1-{total_batches}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"📦 Batch {args.batch} ({len(routes)} routes)")
|
||||
|
||||
# ── Combined ──
|
||||
if args.combined:
|
||||
filepath = write_combined(routes, args.out)
|
||||
print(f"✅ Fichier combiné généré : {filepath} ({len(routes)} prompts)")
|
||||
return
|
||||
|
||||
# ── All individual files ──
|
||||
os.makedirs(args.out, exist_ok=True)
|
||||
for route in routes:
|
||||
filepath = write_single_prompt(route, args.out)
|
||||
print(f" ✅ {route.number:2d}. {route.path:<30s} → {filepath}")
|
||||
|
||||
print(f"\n🎉 {len(routes)} prompts générés dans {args.out}/")
|
||||
print(f" Copie chaque prompt dans Claude Code un par un.")
|
||||
total_batches = (len(routes) + args.size - 1) // args.size
|
||||
print(f" Ou utilise --batch N (1-{total_batches}) pour y aller par groupes de {args.size}.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
help
Normal file
0
help
Normal file
50
loadtests/regression/compare.mjs
Normal file
50
loadtests/regression/compare.mjs
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
238
scripts/bfg-cleanup.sh
Executable file
238
scripts/bfg-cleanup.sh
Executable file
|
|
@ -0,0 +1,238 @@
|
|||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# BFG history cleanup for Veza monorepo
|
||||
# ============================================================
|
||||
# Goal: strip committed audio (.mp3/.wav), certs (.pem/.key/.crt),
|
||||
# Go binaries, and AI session artefacts from git history, then
|
||||
# compact .git from ~2.3 GB down to an expected <500 MB.
|
||||
#
|
||||
# WHEN TO RUN: after commits 98ee449f4 + 1f00fb762 (untrack debris
|
||||
# + dev key regen) have been pushed to origin and reviewed.
|
||||
#
|
||||
# CHOICE: this script uses `git-filter-repo` (modern, fast, pure
|
||||
# Python). BFG (Java) is supported as a fallback — set
|
||||
# USE_BFG=1 to force it.
|
||||
#
|
||||
# ============================================================
|
||||
# SAFETY MODEL
|
||||
# ============================================================
|
||||
# This script NEVER force-pushes by itself. It:
|
||||
# 1. Verifies prereqs
|
||||
# 2. Clones repo as bare mirror to /tmp/veza-bfg.git
|
||||
# 3. Strips blobs > SIZE_THRESHOLD
|
||||
# 4. Strips files matching FILE_PATTERNS
|
||||
# 5. Runs aggressive gc
|
||||
# 6. Prints size-before / size-after
|
||||
# 7. Prints the exact force-push commands for YOU to run manually
|
||||
#
|
||||
# You verify the bare clone by hand before force-pushing. No surprises.
|
||||
#
|
||||
# ============================================================
|
||||
# PREREQS
|
||||
# ============================================================
|
||||
# git-filter-repo: pip install --user git-filter-repo
|
||||
# OR: https://github.com/newren/git-filter-repo
|
||||
# (fallback) BFG: https://rtyley.github.io/bfg-repo-cleaner/
|
||||
# Requires Java 8+. `brew install bfg` or download .jar
|
||||
#
|
||||
# ============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------- CONFIG ----------
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BARE_CLONE="${BARE_CLONE:-/tmp/veza-bfg.git}"
|
||||
SIZE_THRESHOLD="${SIZE_THRESHOLD:-5M}"
|
||||
USE_BFG="${USE_BFG:-0}"
|
||||
|
||||
# Files to strip from ALL history (even if they're <SIZE_THRESHOLD).
|
||||
# Match syntax differs: git-filter-repo uses glob, BFG uses bare name.
|
||||
FILE_PATTERNS_FILTERREPO=(
|
||||
# Audio uploads (44 files, up to 26 MB each)
|
||||
"veza-backend-api/uploads/*.mp3"
|
||||
"veza-backend-api/uploads/*.wav"
|
||||
"veza-backend-api/uploads/*.flac"
|
||||
"veza-backend-api/uploads/*.ogg"
|
||||
"veza-backend-api/uploads/*.m4a"
|
||||
# TLS + JWT secrets (match at any depth)
|
||||
"**/*.pem"
|
||||
"**/*.key"
|
||||
"**/*.crt"
|
||||
# Go binaries historically committed
|
||||
"veza-backend-api/api"
|
||||
"veza-backend-api/main"
|
||||
"veza-backend-api/veza-api"
|
||||
"veza-backend-api/seed"
|
||||
"veza-backend-api/seed-v2"
|
||||
"veza-backend-api/server"
|
||||
"veza-backend-api/modern-server"
|
||||
"veza-backend-api/encrypt_oauth_tokens"
|
||||
"veza-backend-api/migrate_tool"
|
||||
# AI session artefacts
|
||||
"CLAUDE_CONTEXT.txt"
|
||||
"UI_CONTEXT_SUMMARY.md"
|
||||
# Root PNG blobs (all prefixes that were ever committed)
|
||||
"design-system-*.png"
|
||||
"forgot-password-*.png"
|
||||
"register-*.png"
|
||||
"reset-password-*.png"
|
||||
"settings-*.png"
|
||||
"storybook-*.png"
|
||||
"dashboard-*.png"
|
||||
"login-*.png"
|
||||
"audit-*.png"
|
||||
# Stale generated scripts
|
||||
"generate_page_fix_prompts.sh"
|
||||
# Apps/web dead reports
|
||||
"apps/web/AUDIT_ISSUES.json"
|
||||
"apps/web/audit_remediation.json"
|
||||
"apps/web/lint_comprehensive.json"
|
||||
"apps/web/storybook-roadmap.json"
|
||||
"apps/web/e2e-results.json"
|
||||
)
|
||||
|
||||
# BFG equivalent list (bare filenames, no path)
|
||||
FILE_PATTERNS_BFG=(
|
||||
"*.mp3" "*.wav" "*.flac" "*.ogg" "*.m4a"
|
||||
"*.pem" "*.key" "*.crt"
|
||||
"CLAUDE_CONTEXT.txt" "UI_CONTEXT_SUMMARY.md"
|
||||
"generate_page_fix_prompts.sh"
|
||||
)
|
||||
|
||||
# ---------- HELPERS ----------
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
section() { echo ""; echo "━━━ $* ━━━"; }
|
||||
|
||||
check_tool() {
|
||||
if command -v git-filter-repo >/dev/null 2>&1 && [[ "$USE_BFG" != "1" ]]; then
|
||||
TOOL="filter-repo"
|
||||
elif command -v bfg >/dev/null 2>&1; then
|
||||
TOOL="bfg"
|
||||
elif command -v java >/dev/null 2>&1 && [[ -f "${BFG_JAR:-/usr/local/lib/bfg.jar}" ]]; then
|
||||
TOOL="bfg-jar"
|
||||
else
|
||||
die "Install git-filter-repo (pip install --user git-filter-repo) or BFG (https://rtyley.github.io/bfg-repo-cleaner/)"
|
||||
fi
|
||||
echo "Using: $TOOL"
|
||||
}
|
||||
|
||||
human_size() {
|
||||
du -sh "$1" 2>/dev/null | awk '{print $1}'
|
||||
}
|
||||
|
||||
# ---------- SECTION 1: PREREQS ----------
|
||||
section "1. Prereqs"
|
||||
check_tool
|
||||
|
||||
[[ -d "$REPO_ROOT/.git" ]] || die "REPO_ROOT ($REPO_ROOT) is not a git repo"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Refuse to run if working tree is dirty
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
die "Working tree has uncommitted changes. Commit or stash first."
|
||||
fi
|
||||
|
||||
CURRENT_BRANCH="$(git branch --show-current)"
|
||||
echo "Current branch: $CURRENT_BRANCH"
|
||||
echo "Current .git size: $(human_size .git)"
|
||||
|
||||
read -r -p "Proceed with bare mirror clone to $BARE_CLONE? [y/N] " ANSWER
|
||||
[[ "$ANSWER" == "y" || "$ANSWER" == "Y" ]] || die "Aborted by user"
|
||||
|
||||
# ---------- SECTION 2: BARE MIRROR CLONE ----------
|
||||
section "2. Bare mirror clone"
|
||||
if [[ -e "$BARE_CLONE" ]]; then
|
||||
read -r -p "$BARE_CLONE already exists. Delete and recreate? [y/N] " ANSWER
|
||||
[[ "$ANSWER" == "y" || "$ANSWER" == "Y" ]] || die "Aborted"
|
||||
rm -rf "$BARE_CLONE"
|
||||
fi
|
||||
|
||||
git clone --mirror "$REPO_ROOT" "$BARE_CLONE"
|
||||
BEFORE_SIZE="$(human_size "$BARE_CLONE")"
|
||||
echo "Bare clone size BEFORE: $BEFORE_SIZE"
|
||||
|
||||
# ---------- SECTION 3: STRIP ----------
|
||||
section "3. Strip history"
|
||||
|
||||
cd "$BARE_CLONE"
|
||||
|
||||
if [[ "$TOOL" == "filter-repo" ]]; then
|
||||
# Strip blobs bigger than threshold
|
||||
git filter-repo --strip-blobs-bigger-than "$SIZE_THRESHOLD" --force
|
||||
|
||||
# Strip specific path patterns
|
||||
PATH_ARGS=()
|
||||
for p in "${FILE_PATTERNS_FILTERREPO[@]}"; do
|
||||
if [[ "$p" == "!"* ]]; then continue; fi # skip negations for now
|
||||
PATH_ARGS+=(--path-glob "$p")
|
||||
done
|
||||
# filter-repo uses --invert-paths to DELETE matched paths
|
||||
git filter-repo --invert-paths "${PATH_ARGS[@]}" --force
|
||||
|
||||
elif [[ "$TOOL" == "bfg" ]]; then
|
||||
# BFG: strip by size
|
||||
bfg --strip-blobs-bigger-than "$SIZE_THRESHOLD" --no-blob-protection .
|
||||
|
||||
# BFG: strip by filename (no path — matches filename anywhere in history)
|
||||
for p in "${FILE_PATTERNS_BFG[@]}"; do
|
||||
bfg --delete-files "$p" --no-blob-protection .
|
||||
done
|
||||
|
||||
elif [[ "$TOOL" == "bfg-jar" ]]; then
|
||||
java -jar "${BFG_JAR}" --strip-blobs-bigger-than "$SIZE_THRESHOLD" --no-blob-protection .
|
||||
for p in "${FILE_PATTERNS_BFG[@]}"; do
|
||||
java -jar "${BFG_JAR}" --delete-files "$p" --no-blob-protection .
|
||||
done
|
||||
fi
|
||||
|
||||
# ---------- SECTION 4: GC ----------
|
||||
section "4. Aggressive gc"
|
||||
git reflog expire --expire=now --all
|
||||
git gc --prune=now --aggressive
|
||||
|
||||
AFTER_SIZE="$(human_size "$BARE_CLONE")"
|
||||
echo ""
|
||||
echo "━━━ RESULT ━━━"
|
||||
echo "BEFORE: $BEFORE_SIZE"
|
||||
echo "AFTER: $AFTER_SIZE"
|
||||
echo ""
|
||||
|
||||
# ---------- SECTION 5: NEXT STEPS ----------
|
||||
section "5. Next steps (manual)"
|
||||
cat <<MANUAL
|
||||
|
||||
The bare clone at $BARE_CLONE is ready. To finalize:
|
||||
|
||||
1. INSPECT — check a few refs to make sure history makes sense:
|
||||
cd $BARE_CLONE
|
||||
git log --oneline -20 main
|
||||
git log --oneline -5 chore/v1.0.7-cleanup
|
||||
git tag | head -20
|
||||
|
||||
2. VERIFY size reduction is reasonable:
|
||||
du -sh $BARE_CLONE
|
||||
|
||||
3. FORCE PUSH to origin (rewrites all refs + tags — all collaborators
|
||||
must re-clone):
|
||||
cd $BARE_CLONE
|
||||
git push --force --all origin
|
||||
git push --force --tags origin
|
||||
|
||||
4. RE-CLONE your working copy (the old one has pre-BFG history):
|
||||
cd "$(dirname "$REPO_ROOT")"
|
||||
mv veza veza-prebfg-backup
|
||||
git clone <origin-url> veza
|
||||
|
||||
Or if you trust this machine's local blob state:
|
||||
cd $REPO_ROOT
|
||||
git reflog expire --expire=now --all
|
||||
git gc --prune=now --aggressive
|
||||
|
||||
5. REGENERATE local dev secrets that live outside git:
|
||||
./scripts/generate-jwt-keys.sh
|
||||
./scripts/generate-ssl-cert.sh
|
||||
|
||||
6. DELETE the bare clone once everything is verified stable:
|
||||
rm -rf $BARE_CLONE
|
||||
|
||||
MANUAL
|
||||
62
scripts/coverage-trend.mjs
Executable file
62
scripts/coverage-trend.mjs
Executable 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
135
scripts/flaky-detection.mjs
Executable 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
|
||||
}
|
||||
7
scripts/visual-update-baselines.sh
Executable file
7
scripts/visual-update-baselines.sh
Executable 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."
|
||||
54
tests/e2e/audit/accessibility/keyboard-nav.spec.ts
Normal file
54
tests/e2e/audit/accessibility/keyboard-nav.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
59
tests/e2e/fixtures/auth.fixture.ts
Normal file
59
tests/e2e/fixtures/auth.fixture.ts
Normal 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';
|
||||
82
tests/e2e/fixtures/factories.ts
Normal file
82
tests/e2e/fixtures/factories.ts
Normal 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;
|
||||
}
|
||||
70
tests/e2e/helpers/selectors.ts
Normal file
70
tests/e2e/helpers/selectors.ts
Normal 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;
|
||||
|
|
@ -1,376 +0,0 @@
|
|||
//go:build ignore
|
||||
// +build ignore
|
||||
|
||||
// NOTE: Disabled (build ignore). Chat server removed; re-enable when chat service is reimplemented.
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"veza-backend-api/internal/services"
|
||||
)
|
||||
|
||||
// ChatHandlers handles chat-related API endpoints
|
||||
type ChatHandlers struct {
|
||||
chatService *services.ChatService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewChatHandlers creates new chat handlers
|
||||
func NewChatHandlers(chatService *services.ChatService, logger *zap.Logger) *ChatHandlers {
|
||||
return &ChatHandlers{
|
||||
chatService: chatService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// InitChatHandlers initializes chat handlers
|
||||
func InitChatHandlers(chatService *services.ChatService, logger *zap.Logger) {
|
||||
handlers := NewChatHandlers(chatService, logger)
|
||||
|
||||
// Store handlers globally for route registration
|
||||
ChatHandlersInstance = handlers
|
||||
}
|
||||
|
||||
// ChatHandlersInstance holds the global chat handlers instance
|
||||
var ChatHandlersInstance *ChatHandlers
|
||||
|
||||
// CreateMessage creates a new message in a room
|
||||
func (h *ChatHandlers) CreateMessage(c *gin.Context) {
|
||||
userID := c.GetInt64("user_id")
|
||||
roomID, err := strconv.ParseInt(c.Param("room_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid room ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Content string `json:"content" binding:"required"`
|
||||
Type services.MessageType `json:"type"`
|
||||
ParentID *int64 `json:"parent_id,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Type == "" {
|
||||
req.Type = services.MessageTypeText
|
||||
}
|
||||
|
||||
message, err := h.chatService.CreateMessage(c.Request.Context(), roomID, userID, req.Content, req.Type, req.ParentID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to create message", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create message"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMessages retrieves messages for a room
|
||||
func (h *ChatHandlers) GetMessages(c *gin.Context) {
|
||||
roomID, err := strconv.ParseInt(c.Param("room_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid room ID"})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
beforeIDStr := c.Query("before_id")
|
||||
|
||||
var beforeID *int64
|
||||
if beforeIDStr != "" {
|
||||
if id, err := strconv.ParseInt(beforeIDStr, 10, 64); err == nil {
|
||||
beforeID = &id
|
||||
}
|
||||
}
|
||||
|
||||
messages, err := h.chatService.GetMessages(c.Request.Context(), roomID, page, limit, beforeID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get messages", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get messages"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"messages": messages,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// AddReaction adds a reaction to a message
|
||||
func (h *ChatHandlers) AddReaction(c *gin.Context) {
|
||||
userID := c.GetInt64("user_id")
|
||||
messageID, err := strconv.ParseInt(c.Param("message_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Emoji string `json:"emoji" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
reaction, err := h.chatService.AddReaction(c.Request.Context(), messageID, userID, req.Emoji)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to add reaction", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add reaction"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"reaction": reaction,
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveReaction removes a reaction from a message
|
||||
func (h *ChatHandlers) RemoveReaction(c *gin.Context) {
|
||||
userID := c.GetInt64("user_id")
|
||||
messageID, err := strconv.ParseInt(c.Param("message_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message ID"})
|
||||
return
|
||||
}
|
||||
|
||||
emoji := c.Param("emoji")
|
||||
if emoji == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Emoji is required"})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.chatService.RemoveReaction(c.Request.Context(), messageID, userID, emoji)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to remove reaction", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove reaction"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Reaction removed",
|
||||
})
|
||||
}
|
||||
|
||||
// CreateRoom creates a new chat room
|
||||
func (h *ChatHandlers) CreateRoom(c *gin.Context) {
|
||||
userID := c.GetInt64("user_id")
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Type services.RoomType `json:"type"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Type == "" {
|
||||
req.Type = services.RoomTypePublic
|
||||
}
|
||||
|
||||
room, err := h.chatService.CreateRoom(c.Request.Context(), req.Name, req.Description, req.Type, req.IsPrivate, userID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to create room", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create room"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"room": room,
|
||||
})
|
||||
}
|
||||
|
||||
// GetRooms retrieves available rooms
|
||||
func (h *ChatHandlers) GetRooms(c *gin.Context) {
|
||||
userID := c.GetInt64("user_id")
|
||||
includePrivate := c.DefaultQuery("include_private", "false") == "true"
|
||||
|
||||
rooms, err := h.chatService.GetRooms(c.Request.Context(), userID, includePrivate)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get rooms", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get rooms"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"rooms": rooms,
|
||||
})
|
||||
}
|
||||
|
||||
// JoinRoom adds a user to a room
|
||||
func (h *ChatHandlers) JoinRoom(c *gin.Context) {
|
||||
userID := c.GetInt64("user_id")
|
||||
roomID, err := strconv.ParseInt(c.Param("room_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid room ID"})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.chatService.JoinRoom(c.Request.Context(), roomID, userID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to join room", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to join room"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Successfully joined room",
|
||||
})
|
||||
}
|
||||
|
||||
// LeaveRoom removes a user from a room
|
||||
func (h *ChatHandlers) LeaveRoom(c *gin.Context) {
|
||||
userID := c.GetInt64("user_id")
|
||||
roomID, err := strconv.ParseInt(c.Param("room_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid room ID"})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.chatService.LeaveRoom(c.Request.Context(), roomID, userID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to leave room", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to leave room"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Successfully left room",
|
||||
})
|
||||
}
|
||||
|
||||
// CreateDirectMessage creates a DM room between two users
|
||||
func (h *ChatHandlers) CreateDirectMessage(c *gin.Context) {
|
||||
userID := c.GetInt64("user_id")
|
||||
|
||||
var req struct {
|
||||
UserID int64 `json:"user_id" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
room, err := h.chatService.CreateDirectMessage(c.Request.Context(), userID, req.UserID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to create DM", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create direct message"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"room": room,
|
||||
})
|
||||
}
|
||||
|
||||
// SearchMessages searches for messages in a room
|
||||
func (h *ChatHandlers) SearchMessages(c *gin.Context) {
|
||||
roomID, err := strconv.ParseInt(c.Param("room_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid room ID"})
|
||||
return
|
||||
}
|
||||
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Search query is required"})
|
||||
return
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
|
||||
messages, err := h.chatService.SearchMessages(c.Request.Context(), roomID, query, limit)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to search messages", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search messages"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"messages": messages,
|
||||
"query": query,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// EditMessage edits an existing message
|
||||
func (h *ChatHandlers) EditMessage(c *gin.Context) {
|
||||
userID := c.GetInt64("user_id")
|
||||
messageID, err := strconv.ParseInt(c.Param("message_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Content string `json:"content" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
message, err := h.chatService.EditMessage(c.Request.Context(), messageID, userID, req.Content)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to edit message", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to edit message"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteMessage deletes a message
|
||||
func (h *ChatHandlers) DeleteMessage(c *gin.Context) {
|
||||
userID := c.GetInt64("user_id")
|
||||
messageID, err := strconv.ParseInt(c.Param("message_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message ID"})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.chatService.DeleteMessage(c.Request.Context(), messageID, userID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to delete message", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete message"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Message deleted successfully",
|
||||
})
|
||||
}
|
||||
|
|
@ -1,278 +0,0 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"veza-backend-api/internal/services"
|
||||
)
|
||||
|
||||
// RBACServiceInterface defines the interface for RBAC operations
|
||||
// This allows for easier testing with mocks
|
||||
type RBACServiceInterface interface {
|
||||
CreateRole(ctx context.Context, name, description string, permissions []uuid.UUID) (*services.Role, error)
|
||||
GetRoleByID(ctx context.Context, roleID uuid.UUID) (*services.Role, error)
|
||||
GetAllRoles(ctx context.Context) ([]*services.Role, error)
|
||||
AssignRoleToUser(ctx context.Context, userID, roleID uuid.UUID) error
|
||||
RemoveRoleFromUser(ctx context.Context, userID, roleID uuid.UUID) error
|
||||
GetUserRoles(ctx context.Context, userID uuid.UUID) ([]*services.Role, error)
|
||||
GetUserPermissions(ctx context.Context, userID uuid.UUID) ([]services.Permission, error)
|
||||
CheckPermission(ctx context.Context, userID uuid.UUID, resource, action string) (bool, error)
|
||||
CreatePermission(ctx context.Context, name, description, resource, action string) (*services.Permission, error)
|
||||
}
|
||||
|
||||
// RBACHandlers handles RBAC-related API endpoints
|
||||
type RBACHandlers struct {
|
||||
rbacService RBACServiceInterface
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewRBACHandlers creates new RBAC handlers
|
||||
func NewRBACHandlers(rbacService *services.RBACService, logger *zap.Logger) *RBACHandlers {
|
||||
return &RBACHandlers{
|
||||
rbacService: rbacService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// NewRBACHandlersWithInterface creates new RBAC handlers with an interface (for testing)
|
||||
func NewRBACHandlersWithInterface(rbacService RBACServiceInterface, logger *zap.Logger) *RBACHandlers {
|
||||
return &RBACHandlers{
|
||||
rbacService: rbacService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// InitRBACHandlers initializes RBAC handlers
|
||||
func InitRBACHandlers(rbacService *services.RBACService, logger *zap.Logger) {
|
||||
handlers := NewRBACHandlers(rbacService, logger)
|
||||
|
||||
// Store handlers globally for route registration
|
||||
RBACHandlersInstance = handlers
|
||||
}
|
||||
|
||||
// RBACHandlersInstance holds the global RBAC handlers instance
|
||||
var RBACHandlersInstance *RBACHandlers
|
||||
|
||||
// CreateRole creates a new role
|
||||
func (h *RBACHandlers) CreateRole(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Permissions []uuid.UUID `json:"permissions"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
role, err := h.rbacService.CreateRole(c.Request.Context(), req.Name, req.Description, req.Permissions)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to create role", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create role"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"role": role,
|
||||
})
|
||||
}
|
||||
|
||||
// GetRole gets a role by ID
|
||||
func (h *RBACHandlers) GetRole(c *gin.Context) {
|
||||
roleID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role ID"})
|
||||
return
|
||||
}
|
||||
|
||||
role, err := h.rbacService.GetRoleByID(c.Request.Context(), roleID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get role", zap.Error(err))
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Role not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"role": role,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAllRoles gets all roles
|
||||
func (h *RBACHandlers) GetAllRoles(c *gin.Context) {
|
||||
roles, err := h.rbacService.GetAllRoles(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get roles", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get roles"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"roles": roles,
|
||||
})
|
||||
}
|
||||
|
||||
// AssignRoleToUser assigns a role to a user
|
||||
func (h *RBACHandlers) AssignRoleToUser(c *gin.Context) {
|
||||
userID, err := uuid.Parse(c.Param("user_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
RoleID uuid.UUID `json:"role_id" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.rbacService.AssignRoleToUser(c.Request.Context(), userID, req.RoleID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to assign role to user", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to assign role to user"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Role assigned to user successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveRoleFromUser removes a role from a user
|
||||
func (h *RBACHandlers) RemoveRoleFromUser(c *gin.Context) {
|
||||
userID, err := uuid.Parse(c.Param("user_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
|
||||
roleID, err := uuid.Parse(c.Param("role_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role ID"})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.rbacService.RemoveRoleFromUser(c.Request.Context(), userID, roleID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to remove role from user", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove role from user"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Role removed from user successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserRoles gets all roles for a user
|
||||
func (h *RBACHandlers) GetUserRoles(c *gin.Context) {
|
||||
userID, err := uuid.Parse(c.Param("user_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
|
||||
roles, err := h.rbacService.GetUserRoles(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get user roles", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user roles"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"roles": roles,
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserPermissions gets all permissions for a user
|
||||
func (h *RBACHandlers) GetUserPermissions(c *gin.Context) {
|
||||
userID, err := uuid.Parse(c.Param("user_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
|
||||
permissions, err := h.rbacService.GetUserPermissions(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get user permissions", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user permissions"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"permissions": permissions,
|
||||
})
|
||||
}
|
||||
|
||||
// CheckPermission checks if a user has a specific permission
|
||||
func (h *RBACHandlers) CheckPermission(c *gin.Context) {
|
||||
userID, err := uuid.Parse(c.Param("user_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
|
||||
resource := c.Query("resource")
|
||||
action := c.Query("action")
|
||||
|
||||
if resource == "" || action == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Resource and action are required"})
|
||||
return
|
||||
}
|
||||
|
||||
hasPermission, err := h.rbacService.CheckPermission(c.Request.Context(), userID, resource, action)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to check permission", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permission"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"has_permission": hasPermission,
|
||||
"resource": resource,
|
||||
"action": action,
|
||||
})
|
||||
}
|
||||
|
||||
// CreatePermission creates a new permission
|
||||
func (h *RBACHandlers) CreatePermission(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Resource string `json:"resource" binding:"required"`
|
||||
Action string `json:"action" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
permission, err := h.rbacService.CreatePermission(c.Request.Context(), req.Name, req.Description, req.Resource, req.Action)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to create permission", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create permission"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"permission": permission,
|
||||
})
|
||||
}
|
||||
|
|
@ -1,488 +0,0 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"veza-backend-api/internal/services"
|
||||
)
|
||||
|
||||
// MockRBACService is a mock implementation of RBACService for testing
|
||||
type MockRBACService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockRBACService) CreateRole(ctx context.Context, name, description string, permissions []uuid.UUID) (*services.Role, error) {
|
||||
args := m.Called(ctx, name, description, permissions)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*services.Role), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockRBACService) GetRoleByID(ctx context.Context, roleID uuid.UUID) (*services.Role, error) {
|
||||
args := m.Called(ctx, roleID)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*services.Role), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockRBACService) GetAllRoles(ctx context.Context) ([]*services.Role, error) {
|
||||
args := m.Called(ctx)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]*services.Role), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockRBACService) AssignRoleToUser(ctx context.Context, userID, roleID uuid.UUID) error {
|
||||
args := m.Called(ctx, userID, roleID)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRBACService) RemoveRoleFromUser(ctx context.Context, userID, roleID uuid.UUID) error {
|
||||
args := m.Called(ctx, userID, roleID)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRBACService) GetUserRoles(ctx context.Context, userID uuid.UUID) ([]*services.Role, error) {
|
||||
args := m.Called(ctx, userID)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]*services.Role), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockRBACService) GetUserPermissions(ctx context.Context, userID uuid.UUID) ([]services.Permission, error) {
|
||||
args := m.Called(ctx, userID)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]services.Permission), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockRBACService) CheckPermission(ctx context.Context, userID uuid.UUID, resource, action string) (bool, error) {
|
||||
args := m.Called(ctx, userID, resource, action)
|
||||
return args.Bool(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockRBACService) CreatePermission(ctx context.Context, name, description, resource, action string) (*services.Permission, error) {
|
||||
args := m.Called(ctx, name, description, resource, action)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*services.Permission), args.Error(1)
|
||||
}
|
||||
|
||||
// setupTestRBACRouter creates a test router with RBAC handlers
|
||||
func setupTestRBACRouter(mockService *MockRBACService) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
logger := zaptest.NewLogger(&testing.T{})
|
||||
handler := NewRBACHandlersWithInterface(mockService, logger)
|
||||
|
||||
api := router.Group("/api/v1")
|
||||
{
|
||||
roles := api.Group("/roles")
|
||||
{
|
||||
roles.POST("", handler.CreateRole)
|
||||
roles.GET("", handler.GetAllRoles)
|
||||
roles.GET("/:id", handler.GetRole)
|
||||
}
|
||||
|
||||
permissions := api.Group("/permissions")
|
||||
{
|
||||
permissions.POST("", handler.CreatePermission)
|
||||
}
|
||||
|
||||
users := api.Group("/users")
|
||||
{
|
||||
users.POST("/:user_id/roles", handler.AssignRoleToUser)
|
||||
users.DELETE("/:user_id/roles/:role_id", handler.RemoveRoleFromUser)
|
||||
users.GET("/:user_id/roles", handler.GetUserRoles)
|
||||
users.GET("/:user_id/permissions", handler.GetUserPermissions)
|
||||
users.GET("/:user_id/permissions/check", handler.CheckPermission)
|
||||
}
|
||||
}
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func TestRBACHandlers_CreateRole_Success(t *testing.T) {
|
||||
mockService := new(MockRBACService)
|
||||
router := setupTestRBACRouter(mockService)
|
||||
|
||||
roleID := uuid.New()
|
||||
expectedRole := &services.Role{
|
||||
ID: roleID,
|
||||
Name: "test-role",
|
||||
Description: "Test role description",
|
||||
Permissions: []services.Permission{},
|
||||
IsSystem: false,
|
||||
}
|
||||
|
||||
mockService.On("CreateRole", mock.Anything, "test-role", "Test role description", []uuid.UUID{}).Return(expectedRole, nil)
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"name": "test-role",
|
||||
"description": "Test role description",
|
||||
"permissions": []uuid.UUID{},
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/roles", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
mockService.AssertExpectations(t)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, response["success"].(bool))
|
||||
}
|
||||
|
||||
func TestRBACHandlers_CreateRole_InvalidJSON(t *testing.T) {
|
||||
mockService := new(MockRBACService)
|
||||
router := setupTestRBACRouter(mockService)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/roles", bytes.NewBuffer([]byte("invalid json")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestRBACHandlers_CreateRole_MissingName(t *testing.T) {
|
||||
mockService := new(MockRBACService)
|
||||
router := setupTestRBACRouter(mockService)
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"description": "Test role description",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/roles", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestRBACHandlers_GetRole_Success(t *testing.T) {
|
||||
mockService := new(MockRBACService)
|
||||
router := setupTestRBACRouter(mockService)
|
||||
|
||||
roleID := uuid.New()
|
||||
expectedRole := &services.Role{
|
||||
ID: roleID,
|
||||
Name: "test-role",
|
||||
Description: "Test role description",
|
||||
Permissions: []services.Permission{},
|
||||
IsSystem: false,
|
||||
}
|
||||
|
||||
mockService.On("GetRoleByID", mock.Anything, roleID).Return(expectedRole, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/roles/"+roleID.String(), nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
mockService.AssertExpectations(t)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, response["success"].(bool))
|
||||
}
|
||||
|
||||
func TestRBACHandlers_GetRole_InvalidID(t *testing.T) {
|
||||
mockService := new(MockRBACService)
|
||||
router := setupTestRBACRouter(mockService)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/roles/invalid-id", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestRBACHandlers_GetRole_NotFound(t *testing.T) {
|
||||
mockService := new(MockRBACService)
|
||||
router := setupTestRBACRouter(mockService)
|
||||
|
||||
roleID := uuid.New()
|
||||
mockService.On("GetRoleByID", mock.Anything, roleID).Return(nil, assert.AnError)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/roles/"+roleID.String(), nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
mockService.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestRBACHandlers_GetAllRoles_Success(t *testing.T) {
|
||||
mockService := new(MockRBACService)
|
||||
router := setupTestRBACRouter(mockService)
|
||||
|
||||
expectedRoles := []*services.Role{
|
||||
{
|
||||
ID: uuid.New(),
|
||||
Name: "role1",
|
||||
Description: "Role 1",
|
||||
Permissions: []services.Permission{},
|
||||
IsSystem: false,
|
||||
},
|
||||
{
|
||||
ID: uuid.New(),
|
||||
Name: "role2",
|
||||
Description: "Role 2",
|
||||
Permissions: []services.Permission{},
|
||||
IsSystem: false,
|
||||
},
|
||||
}
|
||||
|
||||
mockService.On("GetAllRoles", mock.Anything).Return(expectedRoles, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/roles", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
mockService.AssertExpectations(t)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, response["success"].(bool))
|
||||
}
|
||||
|
||||
func TestRBACHandlers_AssignRoleToUser_Success(t *testing.T) {
|
||||
mockService := new(MockRBACService)
|
||||
router := setupTestRBACRouter(mockService)
|
||||
|
||||
userID := uuid.New()
|
||||
roleID := uuid.New()
|
||||
|
||||
mockService.On("AssignRoleToUser", mock.Anything, userID, roleID).Return(nil)
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"role_id": roleID.String(),
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/users/"+userID.String()+"/roles", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
mockService.AssertExpectations(t)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, response["success"].(bool))
|
||||
}
|
||||
|
||||
func TestRBACHandlers_AssignRoleToUser_InvalidUserID(t *testing.T) {
|
||||
mockService := new(MockRBACService)
|
||||
router := setupTestRBACRouter(mockService)
|
||||
|
||||
roleID := uuid.New()
|
||||
reqBody := map[string]interface{}{
|
||||
"role_id": roleID.String(),
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/users/invalid-id/roles", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestRBACHandlers_RemoveRoleFromUser_Success(t *testing.T) {
|
||||
mockService := new(MockRBACService)
|
||||
router := setupTestRBACRouter(mockService)
|
||||
|
||||
userID := uuid.New()
|
||||
roleID := uuid.New()
|
||||
|
||||
mockService.On("RemoveRoleFromUser", mock.Anything, userID, roleID).Return(nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("DELETE", "/api/v1/users/"+userID.String()+"/roles/"+roleID.String(), nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
mockService.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestRBACHandlers_GetUserRoles_Success(t *testing.T) {
|
||||
mockService := new(MockRBACService)
|
||||
router := setupTestRBACRouter(mockService)
|
||||
|
||||
userID := uuid.New()
|
||||
expectedRoles := []*services.Role{
|
||||
{
|
||||
ID: uuid.New(),
|
||||
Name: "admin",
|
||||
Description: "Admin role",
|
||||
Permissions: []services.Permission{},
|
||||
IsSystem: true,
|
||||
},
|
||||
}
|
||||
|
||||
mockService.On("GetUserRoles", mock.Anything, userID).Return(expectedRoles, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/users/"+userID.String()+"/roles", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
mockService.AssertExpectations(t)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, response["success"].(bool))
|
||||
}
|
||||
|
||||
func TestRBACHandlers_GetUserPermissions_Success(t *testing.T) {
|
||||
mockService := new(MockRBACService)
|
||||
router := setupTestRBACRouter(mockService)
|
||||
|
||||
userID := uuid.New()
|
||||
expectedPermissions := []services.Permission{
|
||||
{
|
||||
ID: uuid.New(),
|
||||
Name: "read:tracks",
|
||||
Description: "Read tracks",
|
||||
Resource: "tracks",
|
||||
Action: "read",
|
||||
},
|
||||
}
|
||||
|
||||
mockService.On("GetUserPermissions", mock.Anything, userID).Return(expectedPermissions, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/users/"+userID.String()+"/permissions", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
mockService.AssertExpectations(t)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, response["success"].(bool))
|
||||
}
|
||||
|
||||
func TestRBACHandlers_CheckPermission_Success(t *testing.T) {
|
||||
mockService := new(MockRBACService)
|
||||
router := setupTestRBACRouter(mockService)
|
||||
|
||||
userID := uuid.New()
|
||||
mockService.On("CheckPermission", mock.Anything, userID, "tracks", "read").Return(true, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/users/"+userID.String()+"/permissions/check?resource=tracks&action=read", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
mockService.AssertExpectations(t)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, response["success"].(bool))
|
||||
assert.True(t, response["has_permission"].(bool))
|
||||
}
|
||||
|
||||
func TestRBACHandlers_CheckPermission_MissingParams(t *testing.T) {
|
||||
mockService := new(MockRBACService)
|
||||
router := setupTestRBACRouter(mockService)
|
||||
|
||||
userID := uuid.New()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/users/"+userID.String()+"/permissions/check?resource=tracks", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestRBACHandlers_CreatePermission_Success(t *testing.T) {
|
||||
mockService := new(MockRBACService)
|
||||
router := setupTestRBACRouter(mockService)
|
||||
|
||||
permissionID := uuid.New()
|
||||
expectedPermission := &services.Permission{
|
||||
ID: permissionID,
|
||||
Name: "read:tracks",
|
||||
Description: "Read tracks",
|
||||
Resource: "tracks",
|
||||
Action: "read",
|
||||
}
|
||||
|
||||
mockService.On("CreatePermission", mock.Anything, "read:tracks", "Read tracks", "tracks", "read").Return(expectedPermission, nil)
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"name": "read:tracks",
|
||||
"description": "Read tracks",
|
||||
"resource": "tracks",
|
||||
"action": "read",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/permissions", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
mockService.AssertExpectations(t)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, response["success"].(bool))
|
||||
}
|
||||
|
||||
func TestRBACHandlers_CreatePermission_MissingFields(t *testing.T) {
|
||||
mockService := new(MockRBACService)
|
||||
router := setupTestRBACRouter(mockService)
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"name": "read:tracks",
|
||||
// Missing required fields: resource, action
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/permissions", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
|
@ -75,6 +75,13 @@ func (c *Config) initMiddlewares() error {
|
|||
c.AuthMiddleware.SetPresenceService(c.PresenceService)
|
||||
}
|
||||
|
||||
// BE-SVC-002: Wire per-user rate limiter into the auth middleware so it
|
||||
// fires automatically after every successful RequireAuth on any route.
|
||||
// Previously UserRateLimiter was created but never mounted (dead wiring).
|
||||
if c.UserRateLimiter != nil {
|
||||
c.AuthMiddleware.SetUserRateLimiter(c.UserRateLimiter)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1114,37 +1114,47 @@ func (s *Service) AddProductPreview(ctx context.Context, productID uuid.UUID, se
|
|||
|
||||
// UpdateProductImages replaces all images for a product (v0.401 M1)
|
||||
func (s *Service) UpdateProductImages(ctx context.Context, productID uuid.UUID, sellerID uuid.UUID, images []ProductImageInput) ([]ProductImage, error) {
|
||||
var product Product
|
||||
if err := s.db.First(&product, "id = ?", productID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrProductNotFound
|
||||
// Wrap DELETE + loop-CREATE in a transaction so a failure mid-loop
|
||||
// doesn't leave the product with zero images (the delete would
|
||||
// otherwise already be committed).
|
||||
var result []ProductImage
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var product Product
|
||||
if err := tx.First(&product, "id = ?", productID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrProductNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if product.SellerID != sellerID {
|
||||
return ErrInvalidSeller
|
||||
}
|
||||
if err := tx.Where("product_id = ?", productID).Delete(&ProductImage{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result = make([]ProductImage, 0, len(images))
|
||||
for i, img := range images {
|
||||
if img.URL == "" {
|
||||
continue
|
||||
}
|
||||
pi := &ProductImage{
|
||||
ProductID: productID,
|
||||
URL: img.URL,
|
||||
SortOrder: img.SortOrder,
|
||||
}
|
||||
if pi.SortOrder == 0 && i > 0 {
|
||||
pi.SortOrder = i
|
||||
}
|
||||
if err := tx.Create(pi).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result = append(result, *pi)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if product.SellerID != sellerID {
|
||||
return nil, ErrInvalidSeller
|
||||
}
|
||||
if err := s.db.Where("product_id = ?", productID).Delete(&ProductImage{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]ProductImage, 0, len(images))
|
||||
for i, img := range images {
|
||||
if img.URL == "" {
|
||||
continue
|
||||
}
|
||||
pi := &ProductImage{
|
||||
ProductID: productID,
|
||||
URL: img.URL,
|
||||
SortOrder: img.SortOrder,
|
||||
}
|
||||
if pi.SortOrder == 0 && i > 0 {
|
||||
pi.SortOrder = i
|
||||
}
|
||||
if err := s.db.Create(pi).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, *pi)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
|
@ -1159,35 +1169,45 @@ func (s *Service) GetProductLicenses(ctx context.Context, productID uuid.UUID) (
|
|||
|
||||
// SetProductLicenses replaces all licenses for a product (v0.401 M2)
|
||||
func (s *Service) SetProductLicenses(ctx context.Context, productID uuid.UUID, sellerID uuid.UUID, licenses []ProductLicenseInput) ([]ProductLicense, error) {
|
||||
var product Product
|
||||
if err := s.db.First(&product, "id = ?", productID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrProductNotFound
|
||||
// Same DELETE+LOOP-CREATE atomicity concern as UpdateProductImages:
|
||||
// a failure mid-loop would leave the product with zero licenses,
|
||||
// making it unsellable until a retry.
|
||||
var result []ProductLicense
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var product Product
|
||||
if err := tx.First(&product, "id = ?", productID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrProductNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if product.SellerID != sellerID {
|
||||
return ErrInvalidSeller
|
||||
}
|
||||
if err := tx.Where("product_id = ?", productID).Delete(&ProductLicense{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result = make([]ProductLicense, 0, len(licenses))
|
||||
for _, in := range licenses {
|
||||
if in.LicenseType == "" || in.PriceCents < 0 {
|
||||
continue
|
||||
}
|
||||
pl := &ProductLicense{
|
||||
ProductID: productID,
|
||||
LicenseType: in.LicenseType,
|
||||
PriceCents: in.PriceCents,
|
||||
TermsText: in.TermsText,
|
||||
}
|
||||
if err := tx.Create(pl).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result = append(result, *pl)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if product.SellerID != sellerID {
|
||||
return nil, ErrInvalidSeller
|
||||
}
|
||||
if err := s.db.Where("product_id = ?", productID).Delete(&ProductLicense{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]ProductLicense, 0, len(licenses))
|
||||
for _, in := range licenses {
|
||||
if in.LicenseType == "" || in.PriceCents < 0 {
|
||||
continue
|
||||
}
|
||||
pl := &ProductLicense{
|
||||
ProductID: productID,
|
||||
LicenseType: in.LicenseType,
|
||||
PriceCents: in.PriceCents,
|
||||
TermsText: in.TermsText,
|
||||
}
|
||||
if err := s.db.Create(pl).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, *pl)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ type AuthMiddleware struct {
|
|||
presenceService PresenceUpdater // v0.301: Optional, updates last_seen_at on auth
|
||||
tokenBlacklist TokenBlacklistChecker // VEZA-SEC-006: Optional, nil if Redis unavailable
|
||||
twoFactorChecker TwoFactorChecker // SFIX-001: Optional, for MFA enforcement
|
||||
userRateLimiter *UserRateLimiter // BE-SVC-002: Optional, per-user rate limiting applied post-auth
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
|
|
@ -102,6 +103,15 @@ func (am *AuthMiddleware) SetTwoFactorChecker(tfc TwoFactorChecker) {
|
|||
am.twoFactorChecker = tfc
|
||||
}
|
||||
|
||||
// SetUserRateLimiter wires the per-user rate limiter so it runs automatically
|
||||
// after every successful RequireAuth / RequireAuthWithMFA call. Centralising it
|
||||
// here avoids sprinkling UserRateLimiter.Middleware() across every protected
|
||||
// route group. nil is fine — limiter simply skipped when absent.
|
||||
// (BE-SVC-002)
|
||||
func (am *AuthMiddleware) SetUserRateLimiter(url *UserRateLimiter) {
|
||||
am.userRateLimiter = url
|
||||
}
|
||||
|
||||
// isSessionCheckRequest returns true for GET /auth/me (or path ending with /auth/me).
|
||||
// Used to avoid WARN logs when the frontend probes session without a token (expected case).
|
||||
func isSessionCheckRequest(path string) bool {
|
||||
|
|
@ -335,15 +345,30 @@ func (am *AuthMiddleware) authenticate(c *gin.Context) (uuid.UUID, bool) {
|
|||
// RequireAuth middleware qui exige une authentification
|
||||
func (am *AuthMiddleware) RequireAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if userID, ok := am.authenticate(c); ok {
|
||||
// v0.301 Lot P1: Update presence (last_seen_at) on each authenticated request
|
||||
if am.presenceService != nil {
|
||||
go func() {
|
||||
_ = am.presenceService.UpdatePresence(context.Background(), userID, "online")
|
||||
}()
|
||||
}
|
||||
c.Next()
|
||||
userID, ok := am.authenticate(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// v0.301 Lot P1: Update presence (last_seen_at) on each authenticated request
|
||||
if am.presenceService != nil {
|
||||
go func() {
|
||||
_ = am.presenceService.UpdatePresence(context.Background(), userID, "online")
|
||||
}()
|
||||
}
|
||||
|
||||
// BE-SVC-002: Per-user rate limiting runs after auth so user_id is set.
|
||||
// Limiter writes 429 + X-RateLimit-* headers and aborts the chain if
|
||||
// the user exceeds their window; c.Next() below only fires when
|
||||
// the limiter lets the request through.
|
||||
if am.userRateLimiter != nil {
|
||||
am.userRateLimiter.Middleware()(c)
|
||||
if c.IsAborted() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>,
|
||||
}
|
||||
|
|
@ -5,21 +5,22 @@
|
|||
pub mod user;
|
||||
pub mod track;
|
||||
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 media;
|
||||
pub mod system;
|
||||
pub mod websocket;
|
||||
pub mod files;
|
||||
|
||||
// Re-export specific types for convenience
|
||||
pub use user::{User, Session};
|
||||
pub use track::Track;
|
||||
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 media::{StreamingRequest, StreamingResponse, AudioChunk, StreamingQuality};
|
||||
pub use system::{AuditLog, HealthCheck, Metrics, Migration};
|
||||
pub use websocket::{WebSocketMessage, PresenceStatus, CallType};
|
||||
pub use files::{FileUploadRequest, FileUploadResponse, FileMetadata};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
Loading…
Reference in a new issue