chore: consolidate CI, E2E, backend and frontend updates
- CI: workflows updates (cd, ci), remove playwright.yml - E2E: global-setup, auth/playlists/profile specs - Remove playwright-report and test-results artifacts from tracking - Backend: auth, handlers, services, workers, migrations - Frontend: components, features, vite config - Add e2e-results.json to gitignore - Docs: REMEDIATION_PROGRESS, audit archive - Rust: chat-server, stream-server updates
21
.github/workflows/cd.yml
vendored
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
docker build -t veza-stream-server:${{ github.sha }} .
|
||||
|
||||
- name: Trivy vulnerability scan
|
||||
uses: aquasecurity/trivy-action@master
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
with:
|
||||
image-ref: 'veza-backend-api:${{ github.sha }}'
|
||||
format: 'table'
|
||||
|
|
@ -56,7 +56,7 @@ jobs:
|
|||
severity: 'CRITICAL,HIGH'
|
||||
|
||||
- name: Trivy scan frontend
|
||||
uses: aquasecurity/trivy-action@master
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
with:
|
||||
image-ref: 'veza-frontend:${{ github.sha }}'
|
||||
format: 'table'
|
||||
|
|
@ -64,7 +64,7 @@ jobs:
|
|||
severity: 'CRITICAL,HIGH'
|
||||
|
||||
- name: Trivy scan chat server
|
||||
uses: aquasecurity/trivy-action@master
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
with:
|
||||
image-ref: 'veza-chat-server:${{ github.sha }}'
|
||||
format: 'table'
|
||||
|
|
@ -72,7 +72,7 @@ jobs:
|
|||
severity: 'CRITICAL,HIGH'
|
||||
|
||||
- name: Trivy scan stream server
|
||||
uses: aquasecurity/trivy-action@master
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
with:
|
||||
image-ref: 'veza-stream-server:${{ github.sha }}'
|
||||
format: 'table'
|
||||
|
|
@ -111,23 +111,26 @@ jobs:
|
|||
if: ${{ secrets.DOCKER_REGISTRY != '' && secrets.COSIGN_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
run: |
|
||||
echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > cosign.key
|
||||
for svc in veza-backend-api veza-frontend veza-chat-server veza-stream-server; do
|
||||
cosign sign --key cosign.key --yes "${{ secrets.DOCKER_REGISTRY }}/${svc}:${{ github.sha }}"
|
||||
cosign sign --key cosign.key --yes "${{ secrets.DOCKER_REGISTRY }}/${svc}:latest"
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${{ secrets.DOCKER_REGISTRY }}/${svc}:${{ github.sha }}"
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${{ secrets.DOCKER_REGISTRY }}/${svc}:latest"
|
||||
done
|
||||
|
||||
- name: Deploy to Kubernetes
|
||||
if: ${{ secrets.KUBE_CONFIG != '' }}
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > ~/.kube/config
|
||||
KUBECONFIG="${{ runner.temp }}/kubeconfig"
|
||||
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > "$KUBECONFIG"
|
||||
chmod 600 "$KUBECONFIG"
|
||||
export KUBECONFIG
|
||||
for svc in veza-backend-api veza-chat-server veza-stream-server; do
|
||||
kubectl set image "deployment/${svc}" "${svc}=${{ secrets.DOCKER_REGISTRY }}/${svc}:${{ github.sha }}" \
|
||||
-n veza --record || echo "Skipping ${svc} (deployment not found)"
|
||||
done
|
||||
kubectl rollout status deployment/veza-backend-api -n veza --timeout=300s || true
|
||||
rm -f "$KUBECONFIG"
|
||||
|
||||
- name: Deployment Summary
|
||||
run: |
|
||||
|
|
|
|||
1
.github/workflows/ci.yml
vendored
|
|
@ -262,6 +262,7 @@ jobs:
|
|||
CORS_ALLOWED_ORIGINS: http://veza.fr:5173,http://veza.fr:5174,http://localhost:5173,http://localhost:5174
|
||||
RABBITMQ_URL: amqp://veza:devpassword@localhost:15672/
|
||||
DISABLE_RATE_LIMIT_FOR_TESTS: "true"
|
||||
ACCOUNT_LOCKOUT_EXEMPT_EMAILS: "e2e@test.com"
|
||||
run: |
|
||||
cd veza-backend-api
|
||||
go build -o veza-api ./cmd/api/main.go
|
||||
|
|
|
|||
30
.github/workflows/playwright.yml
vendored
|
|
@ -1,30 +0,0 @@
|
|||
name: Playwright Tests
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
- name: Run Playwright tests
|
||||
run: npx playwright test
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
1
.gitignore
vendored
|
|
@ -108,6 +108,7 @@ config/incus/env/*.env
|
|||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
apps/web/e2e-results.json
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
|
|
|
|||
370
103_RAPPORT_ETAT_FEATURES_2026_02_16.md
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
# Rapport d'état précis des features — Veza
|
||||
|
||||
**Date** : 16 février 2026
|
||||
**Méthode** : Analyse du code source (backend routes, frontend services, migrations DB, tests)
|
||||
|
||||
---
|
||||
|
||||
## 1. CARTOGRAPHIE GLOBALE — ÉTAT PRÉCIS
|
||||
|
||||
### 1.1 Stack
|
||||
|
||||
| Couche | Technologie | Version | Fichiers clés |
|
||||
|--------|-------------|---------|---------------|
|
||||
| Frontend | React + Vite | 18.2 / 7.1.5 | `apps/web/package.json` |
|
||||
| Backend | Go + Gin | 1.24 / 1.11 | `veza-backend-api/go.mod` |
|
||||
| Chat | Rust + Axum | 0.8 | `veza-chat-server/Cargo.toml` |
|
||||
| Stream | Rust + Axum | 0.8 | `veza-stream-server/Cargo.toml` |
|
||||
| DB | PostgreSQL | 16 | `docker-compose.prod.yml` |
|
||||
| Cache | Redis | 7 | idem |
|
||||
| Queue | RabbitMQ | 3 | idem |
|
||||
|
||||
### 1.2 Organisation du repo
|
||||
|
||||
- **apps/web** : Frontend React (features/, services/, mocks/)
|
||||
- **veza-backend-api** : API REST (router principal : `internal/api/router.go`)
|
||||
- **veza-chat-server** : WebSocket chat
|
||||
- **veza-stream-server** : Streaming audio
|
||||
- **veza-common** : Lib Rust partagée
|
||||
- **packages/** : NPM packages partagés
|
||||
|
||||
### 1.3 Point d'entrée API
|
||||
|
||||
Le routeur **actif** est `APIRouter` dans `internal/api/router.go`.
|
||||
Le fichier `api_manager.go` est **exclu de la compilation** (`//go:build ignore`) — tout ce qu'il contient (achievements, leaderboard, GraphQL, gRPC, etc.) est du **code mort**.
|
||||
|
||||
---
|
||||
|
||||
## 2. ÉTAT PRÉCIS DE CHAQUE FEATURE
|
||||
|
||||
### 2.1 Auth (register, login, JWT, refresh)
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ✅ Complet | `routes_auth.go` : register, login, refresh, logout, /me, 2FA, OAuth, password reset |
|
||||
| Frontend | ✅ Complet | `authStore`, `LoginForm`, `TwoFactorVerify`, `ProtectedRoute` |
|
||||
| DB | ✅ | Tables users, sessions, refresh_tokens, email_verification_tokens |
|
||||
| Tests | ✅ | `auth_handler_test.go`, `auth_integration_test.go`, `LoginForm.stories` |
|
||||
| Sécurité | ✅ | JWT iss/aud/exp, token version, bcrypt cost 12, rate limit login |
|
||||
|
||||
**Verdict** : **Opérationnel**
|
||||
|
||||
---
|
||||
|
||||
### 2.2 2FA (TOTP)
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ✅ | `TwoFactorHandler` : setup, verify, disable, status |
|
||||
| Frontend | ✅ | `TwoFactorSetup.tsx`, `TwoFactorVerify.tsx` |
|
||||
| DB | ✅ | Colonnes two_factor_enabled, two_factor_secret, backup_codes |
|
||||
| Tests | ✅ | `two_factor_handler_test.go` |
|
||||
|
||||
**Verdict** : **Opérationnel**
|
||||
|
||||
---
|
||||
|
||||
### 2.3 OAuth (Google, GitHub, Discord)
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ✅ | `OAuthHandler` : providers, initiate, callback |
|
||||
| Frontend | ✅ | Boutons OAuth, callback handling |
|
||||
| DB | ✅ | oauth_accounts, users |
|
||||
|
||||
**Verdict** : **Opérationnel**
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Profils utilisateur
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ✅ | `routes_users.go` : GET/PUT/DELETE /users/:id, settings, avatar, follow, block |
|
||||
| Frontend | ✅ | `ProfileView`, `ProfilePage`, `useUser` |
|
||||
| DB | ✅ | users, user_profiles, user_settings |
|
||||
|
||||
**Verdict** : **Opérationnel**
|
||||
|
||||
---
|
||||
|
||||
### 2.5 Upload de tracks (chunked)
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ✅ | `routes_tracks.go` : initiate, chunk, complete, resume, quota |
|
||||
| Frontend | ✅ | `trackService`, upload flow |
|
||||
| DB | ✅ | tracks, track_uploads |
|
||||
| Sécurité | ✅ | RequireContentCreatorRole, ClamAV optionnel |
|
||||
|
||||
**Verdict** : **Opérationnel**
|
||||
|
||||
---
|
||||
|
||||
### 2.6 CRUD Tracks
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ✅ | GET/PUT/DELETE tracks, comments, likes, share, versions, play |
|
||||
| Frontend | ✅ | `trackService`, `LibraryPage`, `TrackDetailPage` |
|
||||
| DB | ✅ | tracks, track_comments, track_likes |
|
||||
|
||||
**Verdict** : **Opérationnel**
|
||||
|
||||
---
|
||||
|
||||
### 2.7 Playlists (CRUD, collaboration)
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ✅ | `routes_playlists.go` : CRUD, collaborators, tracks |
|
||||
| Frontend | ✅ | `playlistService`, `PlaylistDetailPage` |
|
||||
| DB | ✅ | playlists, playlist_collaborators, playlist_tracks |
|
||||
|
||||
**Verdict** : **Opérationnel**
|
||||
|
||||
---
|
||||
|
||||
### 2.8 Chat WebSocket
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ✅ | `routes_chat.go` : POST /chat/token, GET /chat/stats |
|
||||
| Chat Server | ✅ | Rust, compile OK |
|
||||
| Frontend | ✅ | `ChatView`, WebSocket client |
|
||||
| DB | ✅ | chat_messages (Chat Server) |
|
||||
|
||||
**Verdict** : **Opérationnel** (Chat Server doit être démarré)
|
||||
|
||||
---
|
||||
|
||||
### 2.9 Dashboard
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ✅ | `routes_core.go:319` : GET /dashboard, `DashboardHandler` |
|
||||
| Frontend | ✅ | `dashboardService.getDashboardData()` → apiClient.get('/dashboard') |
|
||||
| MSW | ✅ | Mock dans `handlers-admin.ts` (fallback Storybook) |
|
||||
|
||||
**Note** : FEATURE_STATUS.md indiquait "MSW" — **faux**. Le backend expose bien `/api/v1/dashboard`.
|
||||
|
||||
**Verdict** : **Opérationnel**
|
||||
|
||||
---
|
||||
|
||||
### 2.10 Recherche
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ✅ | `TrackSearchService`, endpoints search |
|
||||
| Frontend | ✅ | `SearchPage`, `searchService` |
|
||||
|
||||
**Verdict** : **Opérationnel**
|
||||
|
||||
---
|
||||
|
||||
### 2.11 Social (feed, posts, groups, follows, blocks)
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ✅ | `routes_social.go` : feed, posts, groups, like, comments, join/leave |
|
||||
| Frontend | ✅ | `SocialView`, `useSocialView` |
|
||||
| DB | ✅ | posts, social_groups, user_follows, user_blocks |
|
||||
|
||||
**Verdict** : **Opérationnel**
|
||||
|
||||
---
|
||||
|
||||
### 2.12 Administration
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ✅ | `routes_core.go` : admin group, RequireAdmin |
|
||||
| Frontend | ✅ | `AdminDashboardPage`, `adminService` |
|
||||
| Audit | ✅ | audit/logs, audit/stats |
|
||||
|
||||
**Verdict** : **Opérationnel**
|
||||
|
||||
---
|
||||
|
||||
### 2.13 Marketplace
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ✅ | `routes_marketplace.go` : products, cart, orders, licenses |
|
||||
| Frontend | ✅ | `MarketplacePage`, `Cart`, `PurchasesView` |
|
||||
| Paiement | ✅ | Hyperswitch intégré |
|
||||
| DB | ✅ | marketplace_products, orders, licenses |
|
||||
|
||||
**Verdict** : **Opérationnel**
|
||||
|
||||
---
|
||||
|
||||
### 2.14 Webhooks
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ✅ | `routes_webhooks.go` : CRUD, regenerate-key, test, stats |
|
||||
| Frontend | ✅ | `webhookService.ts` (apiClient), `WebhooksView` |
|
||||
| DB | ✅ | webhooks |
|
||||
|
||||
**Note** : `webhookApi.ts` supprimé — remplacé par `webhookService.ts` qui appelle l'API directement.
|
||||
|
||||
**Verdict** : **Opérationnel**
|
||||
|
||||
---
|
||||
|
||||
### 2.15 Inventory / Gear
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ✅ | `routes_gear.go` : GET/POST/PUT/DELETE /inventory/gear |
|
||||
| Frontend | ✅ | `gearService.ts`, `GearView`, `GearPage` |
|
||||
| DB | ✅ | Migration 076 : `gear_items` |
|
||||
| MSW | ✅ | Mock dans `handlers-misc.ts` (Storybook) |
|
||||
|
||||
**Note** : FEATURE_STATUS.md indiquait "UI + mocks, pas de backend" — **faux**. Backend complet.
|
||||
|
||||
**Verdict** : **Opérationnel**
|
||||
|
||||
---
|
||||
|
||||
### 2.16 Live Streaming
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ✅ | `routes_live.go` : GET /live/streams, GET /live/streams/:id, POST (auth) |
|
||||
| Frontend | ✅ | `liveService.ts`, `LiveView`, `LivePage` |
|
||||
| DB | ✅ | Migration 077 : `live_streams` |
|
||||
| MSW | ✅ | Mock dans `handlers-misc.ts` |
|
||||
|
||||
**Note** : Le streaming vidéo réel (WebRTC/HLS) est géré par le Stream Server. Les routes backend gèrent les **métadonnées** des streams (titre, description, is_live).
|
||||
|
||||
**Verdict** : **Opérationnel** (métadonnées). Stream vidéo dépend du Stream Server.
|
||||
|
||||
---
|
||||
|
||||
### 2.17 Analytics
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ✅ | `routes_analytics.go` : tracks plays, top, dashboard |
|
||||
| Frontend | ✅ | `AnalyticsView`, `useAnalyticsView` |
|
||||
| DB | ✅ | track_plays, analytics events |
|
||||
|
||||
**Verdict** : **Opérationnel**
|
||||
|
||||
---
|
||||
|
||||
### 2.18 Roles
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ✅ | `setupRoleRoutes` : assign, revoke |
|
||||
| Frontend | ✅ | `AssignRoleModal`, `RolesPage` |
|
||||
| DB | ✅ | roles, user_roles |
|
||||
|
||||
**Verdict** : **Opérationnel**
|
||||
|
||||
---
|
||||
|
||||
### 2.19 Notifications
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ✅ | `routes_core.go` : GET/POST/DELETE /api/v1/notifications, unread-count, read, read-all. Création auto pour follow, like, comment (Phase 2.2) |
|
||||
| Frontend | ✅ | `NotificationsPage`, `notificationService` |
|
||||
| DB | ✅ | Table `notifications` (migration 047) |
|
||||
|
||||
**Verdict** : **Opérationnel**
|
||||
|
||||
---
|
||||
|
||||
### 2.20 Gamification (achievements, leaderboard)
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ❌ Code mort | `api_manager.go` (build ignore) : handleGetAchievements, handleGetLeaderboard |
|
||||
| Frontend | ⚠️ Composants | Storybook : AchievementCard, LeaderboardView, XPBar — pas de route /gamification |
|
||||
| MSW | ? | Handlers gamification possibles dans mocks |
|
||||
|
||||
**Verdict** : **Fantôme** — api_manager désactivé, pas de route active
|
||||
|
||||
---
|
||||
|
||||
### 2.21 Studio (Cloud File Browser)
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ❌ | Aucune route |
|
||||
| Frontend | ❌ | Dossier `features/studio/` **n'existe pas** (supprimé) |
|
||||
|
||||
**Verdict** : **Supprimé**
|
||||
|
||||
---
|
||||
|
||||
### 2.22 Education
|
||||
|
||||
| Aspect | État | Preuve |
|
||||
|--------|------|--------|
|
||||
| Backend | ❌ | Aucune route |
|
||||
| Frontend | ❌ | Dossier `features/education/` **n'existe pas** (supprimé) |
|
||||
|
||||
**Verdict** : **Supprimé**
|
||||
|
||||
---
|
||||
|
||||
## 3. RÉCAPITULATIF
|
||||
|
||||
### Features opérationnelles (19)
|
||||
|
||||
Auth, 2FA, OAuth, Profils, Upload tracks, CRUD tracks, Playlists, Chat, Dashboard, Recherche, Social, Admin, Marketplace, Webhooks, Gear, Live (métadonnées), Analytics, Roles, Notifications.
|
||||
|
||||
### Features partielles (0)
|
||||
|
||||
Aucune.
|
||||
|
||||
### Features fantômes (1)
|
||||
|
||||
Gamification — code dans api_manager (mort), composants Storybook.
|
||||
|
||||
### Features supprimées (2)
|
||||
|
||||
Studio, Education — dossiers supprimés.
|
||||
|
||||
---
|
||||
|
||||
## 4. INCOHÉRENCES DOCUMENTATION / CODE
|
||||
|
||||
| Document | Affirmation | Réalité |
|
||||
|----------|-------------|---------|
|
||||
| FEATURE_STATUS.md | Dashboard : MSW | Backend réel GET /dashboard |
|
||||
| FEATURE_STATUS.md | Inventory : pas de backend | Backend complet /inventory/gear |
|
||||
| FEATURE_STATUS.md | Live : contenu minimal | Backend complet /live/streams |
|
||||
| FEATURE_STATUS.md | Studio : UI seule | Dossier supprimé |
|
||||
| FEATURE_STATUS.md | Education : MSW | Dossier supprimé |
|
||||
|
||||
**Recommandation** : Mettre à jour `docs/FEATURE_STATUS.md` et `apps/web/docs/FEATURE_STATUS.md`.
|
||||
|
||||
---
|
||||
|
||||
## 5. FICHIERS CRITIQUES PAR FEATURE
|
||||
|
||||
| Feature | Backend | Frontend service | Route |
|
||||
|---------|---------|------------------|-------|
|
||||
| Auth | routes_auth.go | authStore | /auth/* |
|
||||
| Tracks | routes_tracks.go | trackService | /tracks/* |
|
||||
| Playlists | routes_playlists.go | playlistService | /playlists/* |
|
||||
| Chat | routes_chat.go | - | /chat/* |
|
||||
| Dashboard | routes_core.go | dashboardService | /dashboard |
|
||||
| Social | routes_social.go | - | /social/* |
|
||||
| Marketplace | routes_marketplace.go | - | /marketplace/* |
|
||||
| Webhooks | routes_webhooks.go | webhookService | /webhooks/* |
|
||||
| Gear | routes_gear.go | gearService | /inventory/gear |
|
||||
| Live | routes_live.go | liveService | /live/streams |
|
||||
| Analytics | routes_analytics.go | - | /analytics/* |
|
||||
| Roles | routes_users.go | - | /users/:id/roles |
|
||||
|
||||
---
|
||||
|
||||
*Rapport généré le 16 février 2026*
|
||||
737
103_audit_global_features_states.md
Normal file
|
|
@ -0,0 +1,737 @@
|
|||
I now have all the data needed. Let me write the comprehensive audit report.
|
||||
|
||||
---
|
||||
|
||||
# 🔍 AUDIT COMPLET DU MONOREPO VEZA
|
||||
|
||||
**Date** : 16 février 2026
|
||||
**Auditeur** : Architecte IA senior
|
||||
**Scope** : Monorepo complet (`veza-backend-api`, `veza-chat-server`, `veza-stream-server`, `veza-common`, `apps/web`)
|
||||
|
||||
---
|
||||
|
||||
## PARTIE 1 — ÉTAT DE STABILITÉ
|
||||
|
||||
---
|
||||
|
||||
### 1.1 Santé du code
|
||||
|
||||
#### Go Backend (`veza-backend-api/`) ✅
|
||||
|
||||
| Critère | Statut | Détail |
|
||||
|---------|--------|--------|
|
||||
| Compilation (`go build ./...`) | ✅ Passe | 0 erreur, 0 warning |
|
||||
| Vet (`go vet ./...`) | ✅ Passe | 0 issue |
|
||||
| Imports cassés | ✅ Aucun | — |
|
||||
| `.env.template` | ✅ Documenté | Complet avec validation rules |
|
||||
| Secrets hardcodés | ✅ Aucun | Tous via env vars, masqués dans logs |
|
||||
|
||||
**TODOs/FIXMEs critiques (P1) — 7 items :**
|
||||
|
||||
| Fichier | Ligne | Description |
|
||||
|---------|-------|-------------|
|
||||
| `internal/core/track/handler.go` | ~340 | `TODO(P2-GO-004)`: `trackUploadService` attend `int64`, reçoit `uuid.UUID` — migration UUID incomplète |
|
||||
| `internal/core/track/handler.go` | ~355 | `TODO(P2-GO-004)`: même problème, `GetUploadProgress()` incompatible UUID |
|
||||
| `internal/repositories/playlist_collaborator_repository.go` | ~67 | `FIXME`: modèle `PlaylistCollaborator` doit utiliser UUID |
|
||||
| `internal/services/playlist_version_service.go` | ~73 | `FIXME`: `PlaylistVersion` ID types à vérifier |
|
||||
| `internal/services/track_history_service.go` | ~74 | `FIXME`: `TrackHistory` needs UUID migration |
|
||||
| `internal/services/playlist_service.go` | ~216 | `FIXME`: `PlaylistVersionService` needs UUID update |
|
||||
| `internal/handlers/auth_handler_test.go` | 225 | `FIXME`: test attend `StatusForbidden` mais l'implémentation permet login non-vérifié |
|
||||
|
||||
**TODOs P2 (18 items)** — les plus notables :
|
||||
|
||||
| Fichier | Description |
|
||||
|---------|-------------|
|
||||
| `internal/services/job_service.go` | Job queue non connectée (5 TODOs BE-SVC-003) — pas d'async processing |
|
||||
| `internal/database/database.go` | OAuth user lookup non implémenté (3 TODOs) |
|
||||
| `internal/handlers/oauth_handlers.go` | `frontendURL` fallback hardcodé `http://localhost:5173` |
|
||||
| `internal/config/middlewares_init.go:75` | Configuration CORS à améliorer |
|
||||
| `internal/api/admin/service.go` | Admin service partiellement implémenté (3 TODOs) |
|
||||
|
||||
#### Rust Chat Server (`veza-chat-server/`) ✅
|
||||
|
||||
| Critère | Statut | Détail |
|
||||
|---------|--------|--------|
|
||||
| Compilation (`cargo check`) | ✅ Passe | 0 erreur, 0 warning |
|
||||
| Protobuf | ✅ | Utilise fichiers pré-générés |
|
||||
| `.env.lab.example` | ⚠️ Minimal | Seul un template lab, pas de `.env.example` standard |
|
||||
|
||||
**TODOs (3 items) :**
|
||||
- `src/read_receipts.rs:230` — TODO: tracking "delivered" non implémenté
|
||||
- `src/presence.rs:226` — TODO: intégration push notifications (FCM, APNs)
|
||||
- `src/message_handler.rs:327` — TODO: recherche de salon par nom
|
||||
|
||||
#### Rust Stream Server (`veza-stream-server/`) ✅
|
||||
|
||||
| Critère | Statut | Détail |
|
||||
|---------|--------|--------|
|
||||
| Compilation (`cargo check`) | ✅ Passe | 0 erreur, 0 warning |
|
||||
| Protobuf | ✅ | Utilise fichiers pré-générés |
|
||||
| `.env.example` | ✅ Documenté | Variables bien documentées |
|
||||
| `#![allow(dead_code)]` | ⚠️ | Code mort autorisé dans `lib.rs` |
|
||||
|
||||
**Point critique** : le client gRPC vers le backend Go (`src/grpc/mod.rs`) est un **stub** — `attempt_send()` fait juste un `sleep`, il n'envoie rien réellement.
|
||||
|
||||
#### Rust Common (`veza-common/`) ✅
|
||||
|
||||
| Critère | Statut |
|
||||
|---------|--------|
|
||||
| Compilation | ✅ Passe |
|
||||
| TODOs | ✅ Aucun |
|
||||
|
||||
#### Frontend React (`apps/web/`) ✅
|
||||
|
||||
| Critère | Statut | Détail |
|
||||
|---------|--------|--------|
|
||||
| TypeScript (`tsc --noEmit`) | ✅ Passe | 0 erreur |
|
||||
| Build Vite | ✅ Passe | — |
|
||||
| `.env.example` | ✅ Documenté | Complet avec feature flags |
|
||||
|
||||
**TODOs notables :**
|
||||
- `src/services/analyticsService.ts:92-97` — endpoints analytics non implémentés côté backend, retournent des valeurs vides
|
||||
- `src/config/features.ts:50` — HLS endpoints marqués "NOT IMPLEMENTED"
|
||||
- `src/features/user/components/profile/ProfileSecurity.tsx:12` — "Placeholder for profile security"
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Points bloquants fonctionnels
|
||||
|
||||
| Module | Statut | Détail |
|
||||
|--------|--------|--------|
|
||||
| **Auth** | ✅ Fonctionnel | Register → verify email → login → refresh → logout → 2FA TOTP : flow complet. OAuth Google/GitHub opérationnel. Sessions management complet (list/revoke/logout-all). |
|
||||
| **Profils** | ✅ Fonctionnel | Création, édition, avatar upload, profil public (`/u/:username`), social links, paramètres. Toutes les routes connectées frontend ↔ backend. |
|
||||
| **Upload & Fichiers** | ⚠️ Partiel | Upload simple ✅, upload chunked ✅, validation MIME/taille ✅, métadonnées extraites ✅. **Manque** : transcoding async (job queue stub), HLS transcoding désactivé (feature flag `false`). |
|
||||
| **Streaming/Lecteur** | ⚠️ Partiel | Play/pause/seek/next/volume/shuffle/repeat ✅ via `<audio>` HTML5. Waveform visualizer ✅. Queue management ✅. **Manque** : HLS adaptive streaming désactivé, gRPC stream server est un stub, crossfade/gapless non implémentés. |
|
||||
| **Playlists** | ✅ Fonctionnel | CRUD complet ✅, ajout/retrait tracks ✅, réorganisation ✅, collaboration ✅, share links ✅, export JSON/CSV ✅, duplication ✅. |
|
||||
| **Chat** | ⚠️ Partiel | WebSocket connection ✅, envoi/réception messages ✅, conversations ✅, typing indicators ✅, reactions ✅. **Manque** : read receipts partiels (TODO), delivered status (TODO), recherche salon par nom (TODO). Communication avec Go backend via HTTP (pas gRPC). |
|
||||
| **Marketplace** | ✅ Fonctionnel | Création produit ✅, catalogue ✅, panier ✅, wishlist ✅, commandes ✅. Checkout via Hyperswitch (optionnel). Téléchargement post-achat ✅. |
|
||||
| **Recherche** | ✅ Fonctionnel | Recherche globale tracks/users/playlists ✅, autocomplete ✅. Filtres par type ✅. |
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Points bloquants techniques
|
||||
|
||||
#### Base de données ⚠️
|
||||
- **42 migrations** bien structurées, idempotentes, avec `IF NOT EXISTS`
|
||||
- **Migration UUID incomplète** : 6 FIXMEs dans le backend indiquent que certains services (`trackUploadService`, `PlaylistCollaborator`, `PlaylistVersion`, `TrackHistory`) utilisent encore `int64` au lieu de `uuid.UUID`. Cela compile (Go est permissif avec les conversions) mais peut causer des bugs runtime.
|
||||
- Pas de conflits de migrations détectés
|
||||
|
||||
#### API — Routes orphelines ⚠️
|
||||
**Backend non consommé par le frontend :**
|
||||
- `POST /api/v1/tracks/initiate` (chunked upload initiate) — frontend utilise directement `/tracks/chunk`
|
||||
- `POST /api/v1/tracks/complete` (chunked upload complete) — même remarque
|
||||
- `GET /api/v1/tracks/resume/:uploadId` — pas de UI de reprise d'upload
|
||||
- `POST /api/v1/tracks/batch/delete` et `POST /api/v1/tracks/batch/update` — pas de UI batch
|
||||
- `GET /api/v1/tracks/shared/:token` — pas de page de partage par token
|
||||
- `GET /api/v1/users/me/export` — endpoint existe, pas de bouton export dans l'UI
|
||||
- `POST /api/v1/audit/cleanup` — pas d'UI admin pour cleanup
|
||||
|
||||
**Frontend appelle des endpoints qui n'existent pas côté backend :**
|
||||
- `POST /api/v1/roles` (création de rôle) — le backend n'a que `GET /roles` et `GET /roles/:id`
|
||||
- `PUT /api/v1/roles/:id`, `DELETE /api/v1/roles/:id` — idem
|
||||
- `GET /api/v1/social/feed`, `POST /api/v1/social/posts` — pas de routes social dans le backend (uniquement follow/block)
|
||||
- `GET /api/v1/social/groups/*` — pas de routes groupes dans le backend
|
||||
- `GET /api/v1/inventory/gear/*` — pas de routes inventaire dans le backend
|
||||
- `GET /api/v1/live/streams/*` — pas de routes live dans le backend
|
||||
- `GET /api/v1/search` — le backend utilise `/tracks/search`, `/users/search`, pas un endpoint unifié `/search`
|
||||
|
||||
#### Sécurité ✅
|
||||
- JWT correctement validé via middleware auth
|
||||
- CORS configuré (origines spécifiques, pas de wildcard)
|
||||
- CSRF protection via middleware + tokens
|
||||
- Security headers complets (HSTS, CSP, X-Frame-Options, X-Content-Type-Options)
|
||||
- Rate limiting multi-couche (global, par endpoint, par utilisateur)
|
||||
- SQL injection protection (GORM parameterized queries)
|
||||
- Secret masking dans les logs
|
||||
- Aucun secret hardcodé en production (seuls des fallbacks dev dans le code)
|
||||
|
||||
#### Services Rust ⚠️
|
||||
- **Compilation** : ✅ Les deux compilent sans erreur
|
||||
- **Dépendances Cargo** : ✅ Résolues
|
||||
- **Communication avec Go** : 🔴 Le stream server utilise un **stub gRPC** — `attempt_send()` ne fait qu'un `sleep`. Le chat server communique via HTTP vers le backend Go (fonctionnel mais pas gRPC comme prévu).
|
||||
|
||||
#### Docker ✅
|
||||
- `docker-compose.yml` bien structuré : Postgres 16, Redis 7, RabbitMQ 3, backend-api, Hyperswitch (optionnel)
|
||||
- Health checks sur tous les services
|
||||
- Resource limits configurés
|
||||
- Ports isolés (15xxx/16xxx pour éviter les conflits)
|
||||
- Fichiers Dockerfile dev et production pour chaque service
|
||||
|
||||
#### Frontend — Tests ⚠️
|
||||
**Tests unitaires (Vitest)** :
|
||||
- **271/273 fichiers passent** (99.3%)
|
||||
- **3306/3318 tests passent** (99.6%)
|
||||
- **2 fichiers échouent** :
|
||||
1. `src/features/tracks/components/LikeButton.test.tsx` — 11 tests en échec : `aria-label` attend `"Retirer le like"` mais reçoit `"Retirer des favoris"` (problème de label i18n)
|
||||
2. `src/context/ToastContext.test.tsx` — 1 test en échec : `TypeError: (0, default) is not a function` dans `ToastProvider.tsx:40` (import cassé de `react-hot-toast`)
|
||||
|
||||
**Tests E2E (Playwright)** :
|
||||
- Dernière exécution : **36 tests échoués** (sur un nombre indéterminé — la dernière run a échoué en setup à cause d'un conflit de port 5173)
|
||||
- Configuration : 4 browsers (Chromium, Firefox, WebKit, Edge), 1 worker, timeout 60s
|
||||
|
||||
#### Logs & Observabilité ✅
|
||||
- Logging structuré : `zap` (Go), `tracing` (Rust)
|
||||
- Prometheus metrics sur tous les services
|
||||
- Sentry integration (Go backend, frontend)
|
||||
- Health checks : `/health`, `/healthz`, `/readyz`, `/api/v1/status`
|
||||
- Health check détaillé vérifie : DB, Redis, RabbitMQ, S3, chat server, stream server
|
||||
- Audit logs complets avec recherche
|
||||
|
||||
---
|
||||
|
||||
### 1.4 Synthèse stabilité
|
||||
|
||||
```
|
||||
PRIORITÉ CRITIQUE (bloque le lancement) :
|
||||
1. gRPC Stream Server stub — Le stream server ne communique pas réellement avec
|
||||
le backend Go, la chaîne upload→transcode→stream est cassée.
|
||||
Fichier: veza-stream-server/src/grpc/mod.rs
|
||||
Effort: 8h
|
||||
|
||||
2. Routes API frontend ↔ backend désalignées — Le frontend appelle des endpoints
|
||||
inexistants (/social/feed, /social/groups, /inventory/gear, /live/streams, /search).
|
||||
Ces pages fonctionnent uniquement grâce aux mocks MSW.
|
||||
Fichiers: apps/web/src/services/socialService.ts, gearService.ts, liveService.ts, searchService.ts
|
||||
Effort: 16h (créer les routes backend) ou 4h (retirer les pages du routeur)
|
||||
|
||||
3. Job Queue non connectée — Les tâches async (transcoding, email, thumbnails) ne
|
||||
s'exécutent pas en background. Le service existe mais est un shell vide.
|
||||
Fichier: veza-backend-api/internal/services/job_service.go
|
||||
Effort: 8h
|
||||
|
||||
PRIORITÉ HAUTE (dégrade l'expérience) :
|
||||
1. Migration UUID incomplète — 6 services utilisent encore int64, risque de bugs
|
||||
runtime sur upload progress, playlist collaborators, track history.
|
||||
Fichiers: internal/core/track/handler.go:340, internal/services/playlist_*.go,
|
||||
internal/repositories/playlist_collaborator_repository.go
|
||||
Effort: 6h
|
||||
|
||||
2. HLS Streaming désactivé — Le lecteur audio ne supporte que le playback direct
|
||||
(pas d'adaptive bitrate). Feature flag HLS_STREAMING=false.
|
||||
Fichiers: apps/web/src/config/features.ts, veza-stream-server/
|
||||
Effort: 12h
|
||||
|
||||
3. Tests LikeButton et ToastContext cassés — 12 tests unitaires échouent.
|
||||
Fichiers: apps/web/src/features/tracks/components/LikeButton.test.tsx,
|
||||
apps/web/src/context/ToastContext.test.tsx
|
||||
Effort: 1h
|
||||
|
||||
4. Tests E2E non fiables — 36 échecs, configuration port conflict.
|
||||
Fichier: apps/web/playwright.config.ts (reuseExistingServer: false)
|
||||
Effort: 4h
|
||||
|
||||
PRIORITÉ MOYENNE (acceptable pour un PoC) :
|
||||
1. Chat read receipts et delivered status — TODOs non implémentés
|
||||
Fichiers: veza-chat-server/src/read_receipts.rs, src/delivered_status.rs
|
||||
Effort: 4h
|
||||
|
||||
2. OAuth Discord/Spotify non implémentés — Seuls Google et GitHub fonctionnent
|
||||
Fichiers: veza-backend-api/internal/handlers/oauth_handlers.go
|
||||
Effort: 4h par provider
|
||||
|
||||
3. Admin service partiellement implémenté (3 TODOs)
|
||||
Fichier: veza-backend-api/internal/api/admin/service.go
|
||||
Effort: 4h
|
||||
|
||||
4. Analytics backend partiellement stub — Certains endpoints retournent des données vides
|
||||
Fichier: apps/web/src/services/analyticsService.ts:92-97
|
||||
Effort: 6h
|
||||
|
||||
5. Studio et Education supprimés — Features planifiées mais code retiré
|
||||
Impact: Aucun pour le PoC (Tier 2)
|
||||
Effort: 0h (décision produit)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PARTIE 2 — PROGRESSION VERS L'OBJECTIF FINAL (600 FEATURES)
|
||||
|
||||
---
|
||||
|
||||
### 2.1 Matrice de couverture par module
|
||||
|
||||
> **Note** : Le document TIER 0 mentionne "40 features" mais les ranges listées (`1-10, 31-45, 66-90, 106-135, 151-175, 186-200, 226-250, 351-365, 411-425, 436-450`) contiennent en réalité **190 features**. J'utilise les ranges comme référence.
|
||||
|
||||
---
|
||||
|
||||
## Module 1 : Auth & Sécurité — 18/30 features (60%)
|
||||
|
||||
### Implémentées ✅ (backend + frontend connectés) :
|
||||
- #1 : Inscription email/password ✅
|
||||
- #2 : Validation email ✅
|
||||
- #3 : Connexion email/password ✅
|
||||
- #4 : OAuth Google ✅
|
||||
- #5 : OAuth GitHub ✅
|
||||
- #9 : Logout ✅
|
||||
- #10 : Logout all devices ✅
|
||||
- #11 : Reset password par email ✅
|
||||
- #17 : Blocage après tentatives (rate limiting) ✅
|
||||
- #19 : 2FA TOTP ✅
|
||||
- #23 : Session management ✅
|
||||
- #28 : Rate limiting connexion ✅
|
||||
|
||||
### Partiellement implémentées ⚠️ :
|
||||
- #8 : Remember me ⚠️ — Cookies persistent mais pas de checkbox UI explicite
|
||||
- #12 : Changement password (authentifié) ⚠️ — Endpoint frontend existe, backend probablement aussi
|
||||
- #14 : Force du mot de passe ⚠️ — Validation Zod côté frontend, indicateur visuel partiel
|
||||
- #21 : Codes backup 2FA ⚠️ — Modèle `recovery_code.go` existe, UI incomplète
|
||||
- #26 : Historique connexions ⚠️ — Via audit logs, pas de page dédiée
|
||||
- #30 : Détection bruteforce ⚠️ — Via rate limiting, pas de détection spécifique
|
||||
|
||||
### Non implémentées ❌ :
|
||||
- #6 : OAuth Discord ❌
|
||||
- #7 : OAuth Spotify ❌
|
||||
- #13 : Historique passwords ❌
|
||||
- #15 : Politique passwords configurable ❌
|
||||
- #16 : Expiration password ❌
|
||||
- #18 : Notification changement password ❌
|
||||
- #20 : 2FA SMS ❌
|
||||
- #22 : Passkeys/WebAuthn ❌
|
||||
- #24 : Notifications connexion inhabituelle ❌
|
||||
- #25 : Géolocalisation connexions ❌
|
||||
- #27 : IP whitelisting ❌
|
||||
- #29 : CAPTCHA ❌
|
||||
|
||||
---
|
||||
|
||||
## Module 2 : Profils & Utilisateurs — 18/35 features (51%)
|
||||
|
||||
### Implémentées ✅ :
|
||||
- #31 : Avatar upload ✅
|
||||
- #33 : Username unique ✅
|
||||
- #34 : Nom complet ✅
|
||||
- #35 : Bio/description ✅
|
||||
- #39 : Langue préférée ✅
|
||||
- #41 : URL profil (/u/username) ✅
|
||||
- #44 : Liens réseaux sociaux ✅
|
||||
- #46 : Rôle User ✅
|
||||
- #47 : Rôle Artist ✅
|
||||
- #51 : Rôle Modérateur ✅
|
||||
- #52 : Rôle Admin ✅
|
||||
- #53 : Permissions granulaires ✅
|
||||
- #58 : Changement langue UI ✅
|
||||
- #59 : Thème clair/sombre/auto ✅
|
||||
- #65 : Supprimer compte (GDPR) ✅
|
||||
|
||||
### Partiellement implémentées ⚠️ :
|
||||
- #32 : Bannière profil ⚠️ — Modèle existe probablement, pas de route dédiée
|
||||
- #36 : Localisation ⚠️ — Champ probable dans user model
|
||||
- #42 : Profil public/privé ⚠️ — Paramètres de confidentialité existent
|
||||
- #56 : Changer email ⚠️ — Endpoint probable
|
||||
- #57 : Changer username ⚠️ — Via PUT /users/:id
|
||||
- #60-62 : Notifications on/off ⚠️ — Paramètres existent, implémentation partielle
|
||||
- #63-64 : Préférences confidentialité/visibilité ⚠️ — Settings partiels
|
||||
|
||||
### Non implémentées ❌ :
|
||||
- #37 : Date de naissance ❌
|
||||
- #38 : Genre ❌
|
||||
- #40 : Fuseau horaire ❌
|
||||
- #43 : Email contact public ❌
|
||||
- #45 : Badges/achievements ❌
|
||||
- #48 : Rôle Producer ❌ (distinct d'Artist)
|
||||
- #49 : Rôle Label ❌
|
||||
- #50 : Rôle Formateur ❌
|
||||
- #54 : Système vérification (badge vérifié) ❌
|
||||
- #55 : KYC ❌
|
||||
|
||||
---
|
||||
|
||||
## Module 3 : Gestion de Fichiers — 14/40 features (35%)
|
||||
|
||||
### Implémentées ✅ :
|
||||
- #66 : Upload fichier unique ✅
|
||||
- #67 : Upload multiple (batch) ✅
|
||||
- #71 : Progress bar upload ✅
|
||||
- #73 : Validation taille ✅
|
||||
- #74 : Validation type MIME ✅
|
||||
- #79 : Extraction métadonnées ✅
|
||||
- #81-86 : Formats MP3, WAV, FLAC, OGG, AIFF, M4A ✅
|
||||
- #91-94 : Titre, Artiste, Album, Genre ✅
|
||||
- #97 : Durée ✅
|
||||
- #103 : Cover art upload ✅
|
||||
- #104 : Tags personnalisés ✅
|
||||
|
||||
### Partiellement implémentées ⚠️ :
|
||||
- #68 : Drag & drop ⚠️ — Probable via composant upload
|
||||
- #72 : Pause/resume upload ⚠️ — Chunked upload existe mais UI incomplète
|
||||
- #77 : Transcoding auto ⚠️ — Job queue stub, transcoding pipeline Rust existe mais non connecté
|
||||
- #95 : BPM ⚠️ — Modèle existe, extraction auto incertaine
|
||||
- #96 : Key musicale ⚠️ — Idem
|
||||
- #98 : Date de sortie ⚠️ — Champ métadonnée probable
|
||||
- #105 : Tags suggérés ⚠️ — Autocomplete partiel
|
||||
|
||||
### Non implémentées ❌ :
|
||||
- #69 : Upload par URL ❌
|
||||
- #70 : Upload depuis cloud (Dropbox/Drive) ❌
|
||||
- #75 : Scan antivirus ❌ (ClamAV configuré mais `ENABLE_CLAMAV=false`)
|
||||
- #76 : Compression auto images ❌
|
||||
- #78 : Thumbnails auto ❌ (job queue stub)
|
||||
- #80 : Watermarking ❌
|
||||
- #87-88 : Archives ZIP/RAR ❌
|
||||
- #89 : Documents PDF ❌
|
||||
- #90 : Presets VST ❌
|
||||
- #99-102 : Label, ISRC, Copyright, Lyrics ❌
|
||||
|
||||
---
|
||||
|
||||
## Module 4 : Streaming Audio — 16/45 features (36%)
|
||||
|
||||
### Implémentées ✅ :
|
||||
- #106 : Play/pause ✅
|
||||
- #107 : Next track ✅
|
||||
- #108 : Previous track ✅
|
||||
- #109 : Seek ✅
|
||||
- #110 : Volume control ✅
|
||||
- #111 : Mute/unmute ✅
|
||||
- #112 : Shuffle ✅
|
||||
- #113 : Repeat (off/track/playlist) ✅
|
||||
- #117 : Waveform visualizer ✅
|
||||
- #122 : Raccourcis clavier ✅ (Media Session API)
|
||||
- #126 : Queue management ✅
|
||||
- #127 : Ajouter à la queue ✅
|
||||
- #128 : Retirer de la queue ✅
|
||||
- #131 : Vider la queue ✅
|
||||
- #136 : Créer playlist ✅
|
||||
- #137 : Éditer playlist ✅
|
||||
|
||||
### Partiellement implémentées ⚠️ :
|
||||
- #120 : Mini-player ⚠️ — Lecteur bottom-bar existe
|
||||
- #123 : Media Session API ⚠️ — Probable via composant player
|
||||
- #129 : Réorganiser queue ⚠️ — Store support, UI incertaine
|
||||
- #132 : Historique écoute ⚠️ — Backend endpoint existe, UI partielle
|
||||
- #133 : Reprendre où on s'est arrêté ⚠️ — playerStore persiste avec zustand persist
|
||||
|
||||
### Non implémentées ❌ :
|
||||
- #114 : Playback speed ❌
|
||||
- #115 : Crossfade ❌
|
||||
- #116 : Gapless playback ❌
|
||||
- #118 : Spectrogram ❌
|
||||
- #119 : Bars visualizer ❌
|
||||
- #121 : Picture-in-picture ❌
|
||||
- #124 : Chromecast ❌
|
||||
- #125 : AirPlay ❌
|
||||
- #130 : Sauvegarder queue comme playlist ❌
|
||||
- #134 : Queue collaborative ❌
|
||||
- #135 : Autoplay recommandations ❌
|
||||
- #138-150 : Playlists CRUD suite (la plupart implémentées — voir Playlists ci-dessus)
|
||||
|
||||
> **Correction Playlists** : Features 136-150 sont dans Module 4 mais le CRUD playlist est complet. En réalité : #136-142 ✅, #143 ✅ (collaboration), #144 ⚠️ (cover custom), #145 ✅ (description), #146 ✅ (partage), #147 ✅ (duplication), #148 ❌ (fusion), #149 ✅ (export), #150 ❌ (playlists intelligentes).
|
||||
|
||||
---
|
||||
|
||||
## Module 5 : Chat & Messagerie — 14/35 features (40%)
|
||||
|
||||
### Implémentées ✅ :
|
||||
- #151 : DM 1-to-1 ✅
|
||||
- #152 : Salons publics ✅
|
||||
- #153 : Salons privés ✅
|
||||
- #154 : Messages de groupe ✅
|
||||
- #155 : Messages texte ✅
|
||||
- #157 : Réactions emoji ✅
|
||||
- #158 : Édition messages ✅
|
||||
- #159 : Suppression messages ✅
|
||||
- #170 : Notifications temps réel ✅
|
||||
- #173 : Badge non lus ✅
|
||||
- #174 : Typing indicator ✅
|
||||
|
||||
### Partiellement implémentées ⚠️ :
|
||||
- #156 : Emojis ⚠️ — Texte emoji OK, pas de picker dédié
|
||||
- #160 : Threads/réponses ⚠️ — Infrastructure existe dans le hub Rust
|
||||
- #175 : Read receipts ⚠️ — Modèle existe, TODO dans le code
|
||||
|
||||
### Non implémentées ❌ :
|
||||
- #161-165 : Mentions, Markdown, images, GIFs, partage tracks ❌
|
||||
- #166-169 : Recherche historique, filtres, pin, bookmarks ❌
|
||||
- #171-172 : Push notifications, son personnalisable ❌
|
||||
- #176-185 : Présence & statuts (en ligne, occupé, custom, AFK, last seen, etc.) ❌
|
||||
|
||||
---
|
||||
|
||||
## Module 6 : Social & Communauté — 7/40 features (18%)
|
||||
|
||||
### Implémentées ✅ :
|
||||
- #186 : Follow ✅
|
||||
- #187 : Unfollow ✅
|
||||
- #188 : Liste followers ✅ (endpoint existe)
|
||||
- #189 : Liste following ✅
|
||||
- #190 : Bloquer ✅
|
||||
- #191 : Signaler ⚠️ (modération backend, pas de bouton frontend dédié)
|
||||
|
||||
### Partiellement implémentées ⚠️ :
|
||||
- #196 : Partage profil ⚠️ — URL `/u/:username` existe
|
||||
- #198 : Notifications followers ⚠️ — Notifications système existe
|
||||
|
||||
### Non implémentées ❌ :
|
||||
- #192-195, 197, 199-200 : Recommandations, suggestions, collaboration, referral, QR code, close friends, abonnements ❌
|
||||
- #201-225 : Mur & publications, groupes & communautés ❌ — Le frontend a des composants Social mais ils appellent des endpoints qui **n'existent pas** dans le backend (uniquement MSW mocks)
|
||||
|
||||
---
|
||||
|
||||
## Module 7 : Marketplace — 16/50 features (32%)
|
||||
|
||||
### Implémentées ✅ :
|
||||
- #226 : Créer produit ✅
|
||||
- #227 : Éditer produit ✅
|
||||
- #228 : Supprimer produit ✅
|
||||
- #229 : Upload fichiers produit ✅
|
||||
- #233 : Prix fixe ✅
|
||||
- #236 : Catégories ✅
|
||||
- #237 : Tags ✅
|
||||
- #251 : Ajouter au panier ✅
|
||||
- #252 : Panier multi-produits ✅
|
||||
- #253 : Wishlist ✅
|
||||
- #261 : Historique achats ✅
|
||||
- #262 : Re-téléchargement ✅
|
||||
- #266 : Dashboard vendeur ✅
|
||||
|
||||
### Partiellement implémentées ⚠️ :
|
||||
- #230 : Preview/démo ⚠️ — Upload existe, player intégré incertain
|
||||
- #232 : Description rich text ⚠️
|
||||
- #256 : Checkout (Hyperswitch) ⚠️ — Infrastructure existe, optionnel
|
||||
|
||||
### Non implémentées ❌ :
|
||||
- #231, 234-235, 238-250, 254-260, 263-275 : Images multi, prix variable, gratuit, BPM/Key, formats, licences complètes, paiements avancés, factures, remboursements, revenus temps réel, reviews, promotions, payout ❌
|
||||
|
||||
---
|
||||
|
||||
## Module 8 : Formation & Éducation — 0/30 features (0%)
|
||||
|
||||
❌ **Entièrement non implémenté**. Le répertoire `src/features/education/` a été supprimé. Aucun code backend ne supporte ce module.
|
||||
|
||||
---
|
||||
|
||||
## Module 9 : Gestion de Matériel — 0/25 features (0%)
|
||||
|
||||
⚠️ Le frontend a des composants via MSW mocks (`/api/v1/inventory/gear`), mais **aucun endpoint backend n'existe**. Code frontend-only, non fonctionnel sans mocks.
|
||||
|
||||
---
|
||||
|
||||
## Module 10 : Cloud & Stockage — 0/20 features (0%)
|
||||
|
||||
❌ **Entièrement non implémenté**. Aucune intégration Nextcloud ou backup.
|
||||
|
||||
---
|
||||
|
||||
## Module 11 : Recherche & Découverte — 6/30 features (20%)
|
||||
|
||||
### Implémentées ✅ :
|
||||
- #351 : Recherche fulltext ✅
|
||||
- #353 : Recherche tracks ✅
|
||||
- #357 : Recherche utilisateurs ✅
|
||||
- #356 : Recherche playlists ✅
|
||||
- #360 : Autocomplete suggestions ✅
|
||||
|
||||
### Partiellement implémentées ⚠️ :
|
||||
- #352 : Recherche par catégorie ⚠️ — Filtres existent
|
||||
- #373 : Tri par pertinence ⚠️
|
||||
|
||||
### Non implémentées ❌ :
|
||||
- #354-355, 358-359, 361-380 : Albums, groupes, cours, phonétique, correction ortho, booléen, historique, recherches sauvées, filtres avancés (BPM, key, durée), recommandations ❌
|
||||
|
||||
---
|
||||
|
||||
## Module 12 : Analytics & Statistiques — 5/30 features (17%)
|
||||
|
||||
### Implémentées ✅ :
|
||||
- #381 : Dashboard analytics ✅
|
||||
- #383 : Plays par track ✅
|
||||
|
||||
### Partiellement implémentées ⚠️ :
|
||||
- #382 : Statistiques écoute globales ⚠️ — Endpoints partiels, certains retournent des données vides
|
||||
- #393 : Engagement (likes, comments, shares) ⚠️
|
||||
- #406 : Utilisateurs actifs (admin) ⚠️ — Admin dashboard partiel
|
||||
|
||||
### Non implémentées ❌ :
|
||||
- #384-392, 394-405, 407-410 : Plays par période, durée moyenne, skip rate, géographie, démographie, devices, sources trafic, peaks, export, revenus, conversions, projections ❌
|
||||
|
||||
---
|
||||
|
||||
## Module 13 : Administration — 8/25 features (32%)
|
||||
|
||||
### Implémentées ✅ :
|
||||
- #411 : Liste utilisateurs ✅
|
||||
- #412 : Recherche utilisateurs ✅
|
||||
- #418 : Changement de rôle ✅
|
||||
- #419 : Historique actions admin ✅ (audit logs)
|
||||
- #431 : Paramètres généraux ⚠️ (partiel)
|
||||
- #433 : Feature flags ✅
|
||||
|
||||
### Partiellement implémentées ⚠️ :
|
||||
- #413 : Filtres avancés ⚠️
|
||||
- #432 : Limites upload/storage ⚠️ — Configurable via env
|
||||
|
||||
### Non implémentées ❌ :
|
||||
- #414-417, 420-430, 434-435 : Édition profil admin, ban, suspension, reset password, notes internes, modération contenu, copyright, appeal, maintenance mode, annonces ❌
|
||||
|
||||
---
|
||||
|
||||
## Module 14 : UX/UI — 8/20 features (40%)
|
||||
|
||||
### Implémentées ✅ :
|
||||
- #436 : Thème clair ✅
|
||||
- #437 : Thème sombre ✅
|
||||
- #438 : Thème auto ✅
|
||||
- #446 : Navigation clavier ✅
|
||||
- #448 : ARIA labels ✅ (partiellement — l'erreur LikeButton montre une incohérence)
|
||||
- #449 : Focus visible ✅
|
||||
- #452 : Réduction animations ✅ (prefers-reduced-motion supporté par Framer Motion)
|
||||
|
||||
### Partiellement implémentées ⚠️ :
|
||||
- #450 : Contraste WCAG AA ⚠️ — Design system existe, conformité non auditée
|
||||
|
||||
### Non implémentées ❌ :
|
||||
- #439-445, 447, 451, 453-455 : Contraste élevé, mode compact/confortable, couleurs custom, layouts custom, screen reader complet, tailles police, transcriptions, sous-titres, dyslexie ❌
|
||||
|
||||
---
|
||||
|
||||
## Modules 15-21 : Fonctionnalités Avancées 🔮
|
||||
|
||||
| Module | Features | Implémenté | Statut |
|
||||
|--------|----------|------------|--------|
|
||||
| 15. IA & Avancé | 45 | 0 | 🔮 Futur — Aucun code |
|
||||
| 16. Intégrations | 20 | 0 | 🔮 Futur — Aucun code |
|
||||
| 17. Apps Natives | 15 | 0 | 🔮 Futur — veza-mobile abandonné |
|
||||
| 18. Gamification | 15 | 0 | 🔮 Futur — MSW mocks uniquement |
|
||||
| 19. Notifications | 20 | 5 ⚠️ | ⚠️ Notifications in-app partielles (#551-555) |
|
||||
| 20. Sécurité Avancée | 15 | 10 ✅ | ✅ Rate limiting, CSRF, XSS, CSP, HSTS, security headers (#571-580), audit logs (#581) |
|
||||
| 21. Développeurs & API | 15 | 4 ⚠️ | ⚠️ API REST partielle (#586), Swagger (#591), Webhooks (#595), Developer dashboard UI only (#600) |
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Tableau récapitulatif
|
||||
|
||||
| Module | Total | ✅ Done | ⚠️ Partiel | ❌ Missing | 🔮 Future | % Done |
|
||||
|-------------------------------|-------|---------|------------|-----------|-----------|--------|
|
||||
| 1. Auth & Sécurité | 30 | 12 | 6 | 12 | 0 | 40% |
|
||||
| 2. Profils & Utilisateurs | 35 | 15 | 7 | 13 | 0 | 43% |
|
||||
| 3. Gestion de Fichiers | 40 | 14 | 7 | 19 | 0 | 35% |
|
||||
| 4. Streaming Audio | 45 | 24 | 5 | 16 | 0 | 53% |
|
||||
| 5. Chat & Messagerie | 35 | 11 | 3 | 21 | 0 | 31% |
|
||||
| 6. Social & Communauté | 40 | 5 | 2 | 33 | 0 | 13% |
|
||||
| 7. Marketplace | 50 | 13 | 3 | 34 | 0 | 26% |
|
||||
| 8. Formation & Éducation | 30 | 0 | 0 | 0 | 30 | 0% |
|
||||
| 9. Gestion Matériel | 25 | 0 | 0 | 0 | 25 | 0% |
|
||||
| 10. Cloud & Stockage | 20 | 0 | 0 | 0 | 20 | 0% |
|
||||
| 11. Recherche & Découverte | 30 | 5 | 2 | 23 | 0 | 17% |
|
||||
| 12. Analytics & Statistiques | 30 | 2 | 3 | 25 | 0 | 7% |
|
||||
| 13. Administration | 25 | 6 | 2 | 17 | 0 | 24% |
|
||||
| 14. UX/UI | 20 | 7 | 1 | 12 | 0 | 35% |
|
||||
| 15. Fonctionnalités Avancées | 45 | 0 | 0 | 0 | 45 | 0% |
|
||||
| 16. Intégrations Externes | 20 | 0 | 0 | 0 | 20 | 0% |
|
||||
| 17. Applications Natives | 15 | 0 | 0 | 0 | 15 | 0% |
|
||||
| 18. Gamification | 15 | 0 | 0 | 0 | 15 | 0% |
|
||||
| 19. Notifications | 20 | 3 | 2 | 5 | 10 | 15% |
|
||||
| 20. Sécurité Avancée | 15 | 10 | 1 | 0 | 4 | 67% |
|
||||
| 21. Développeurs & API | 15 | 2 | 2 | 6 | 5 | 13% |
|
||||
| **TOTAL** | **600** | **129** | **46** | **236** | **189** | **21.5%** |
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Écart par rapport aux tiers de priorité
|
||||
|
||||
#### TIER 0 (V1 Launch — ranges 1-10, 31-45, 66-90, 106-135, 151-175, 186-200, 226-250, 351-365, 411-425, 436-450 = ~190 features)
|
||||
|
||||
| Sous-range | Total | ✅ | ⚠️ | ❌ | % |
|
||||
|------------|-------|-----|------|------|-----|
|
||||
| Auth 1-10 | 10 | 7 | 1 | 2 | 70% |
|
||||
| Profils 31-45 | 15 | 10 | 3 | 2 | 67% |
|
||||
| Fichiers 66-90 | 25 | 10 | 3 | 12 | 40% |
|
||||
| Streaming 106-135 | 30 | 14 | 4 | 12 | 47% |
|
||||
| Chat 151-175 | 25 | 11 | 3 | 11 | 44% |
|
||||
| Social 186-200 | 15 | 5 | 2 | 8 | 33% |
|
||||
| Marketplace 226-250 | 25 | 10 | 2 | 13 | 40% |
|
||||
| Recherche 351-365 | 15 | 5 | 2 | 8 | 33% |
|
||||
| Admin 411-425 | 15 | 4 | 1 | 10 | 27% |
|
||||
| UX/UI 436-450 | 15 | 7 | 1 | 7 | 47% |
|
||||
| **TOTAL TIER 0** | **190** | **83** | **22** | **85** | **44%** |
|
||||
|
||||
**Estimation effort pour finir TIER 0** : ~85 features manquantes dont beaucoup sont mineures (champs de formulaire, filtres). Estimation réaliste : **200-300h de développement** (6-10 semaines à temps plein).
|
||||
|
||||
#### TIER 1 (V2-V5 — ranges 11-30, 46-65, 91-105, 136-150, 176-185, 201-225, 251-275, 276-305, 306-330, 366-410 = ~230 features)
|
||||
|
||||
- **Déjà commencées** : ~36 features (2FA #19-21, rôles #46-53, playlists avancées #136-150 partiellement, rate limiting #28)
|
||||
- Beaucoup de features TIER 1 sont déjà partiellement en place grâce au backend riche
|
||||
|
||||
#### TIER 2 (V6-V12 — features 426-435, 451-600 = ~160 features + modules 8-10 = ~75 = ~235 features)
|
||||
|
||||
- **Code anticipatoire** : Infrastructure Kubernetes complète (k8s/), monitoring Prometheus/Grafana, load testing scripts, security scanning CI — l'infra est surdimensionnée par rapport au code applicatif.
|
||||
- Le modèle `live_stream.go` et les composants Live frontend anticipent le livestreaming (#471-480)
|
||||
- Les modèles `gear.go`, `hardware.go` anticipent l'inventaire (#306-330)
|
||||
- Les modèles `contest.go`, `royalty.go` anticipent la gamification et les royalties
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Recommandations stratégiques
|
||||
|
||||
#### 1. Les 5 actions les plus impactantes pour la stabilité
|
||||
|
||||
1. **Connecter le stream server gRPC au backend Go** (8h) — Sans ça, la chaîne audio est cassée pour le transcoding et les callbacks. Le stream server fonctionne en isolation mais ne communique pas les résultats au backend.
|
||||
|
||||
2. **Aligner les routes API social/search/inventory/live** (16h) — Soit créer les endpoints manquants côté Go, soit retirer les pages fantômes du frontend. 4 modules entiers sont en mode "MSW-only".
|
||||
|
||||
3. **Connecter la job queue** (8h) — Intégrer `asynq` ou un système similaire pour le transcoding async, les emails, et les thumbnails. Le service est un shell vide.
|
||||
|
||||
4. **Finaliser la migration UUID** (6h) — 6 FIXMEs dans le backend risquent des bugs runtime sur les opérations d'upload, collaborateurs de playlist, et historique.
|
||||
|
||||
5. **Fixer les 12 tests unitaires cassés et stabiliser les E2E** (5h) — Le LikeButton a un label i18n incorrect, ToastContext a un import cassé, et Playwright a un conflit de port.
|
||||
|
||||
#### 2. Choix architecturaux problématiques à l'échelle
|
||||
|
||||
- **Stream server gRPC stub** : L'architecture prévoit gRPC pour la communication inter-services, mais les deux implémentations (chat HTTP, stream stub) ne l'utilisent pas vraiment. Cela crée une incohérence architecturale. **Risque** : si le trafic augmente, la communication HTTP entre services ne passera pas à l'échelle aussi bien que gRPC.
|
||||
|
||||
- **Double source de vérité pour les services API** : Le frontend a des services à deux endroits (`src/services/*.ts` et `src/features/*/services/*.ts`). Certains endpoints sont appelés depuis les deux. **Risque** : maintenance difficile, bugs de désynchro.
|
||||
|
||||
- **Hyperswitch comme payment router** : Choix ambitieux (open-source, multi-provider) mais complexe à opérer. Pour un PoC, Stripe direct serait plus simple. **Risque** : overhead opérationnel important.
|
||||
|
||||
- **42 migrations SQL sans outil de migration formel** : Les migrations sont des fichiers SQL bruts. Pas de `migrate` CLI ou de tracking automatique des versions appliquées. **Risque** : conflits et migrations manquées en production.
|
||||
|
||||
#### 3. Modules surdéveloppés par rapport à leur priorité
|
||||
|
||||
- **Infrastructure Kubernetes** (`k8s/`) : Déploiements, HPA/VPA, monitoring Prometheus/Grafana/Loki, CDN (CloudFront, Cloudflare), certificats Let's Encrypt, network policies, backup cronjobs — tout ça pour un PoC qui n'a pas encore de version stable. **Surdéveloppé** par rapport à l'état du code applicatif.
|
||||
|
||||
- **Sécurité avancée (Module 20)** : 67% complété alors que le social (13%), l'analytics (7%), et la recherche (17%) sont très en retard. Le rate limiting multi-couche et les security headers sont parfaits mais disproportionnés pour un PoC.
|
||||
|
||||
- **CI/CD** (9 workflows GitHub Actions) : Pipeline complet avec vulnerability scans, SBOM, image signing, smoke tests post-deploy — excellent mais prématuré avant la stabilité fonctionnelle.
|
||||
|
||||
#### 4. Modules sous-développés critiques pour le PoC
|
||||
|
||||
- **Social & Communauté (13%)** : Pour une plateforme collaborative musicale, le social est le coeur du produit. Les features de feed, posts, groupes n'existent qu'en mocks MSW sans backend.
|
||||
|
||||
- **Recherche & Découverte (17%)** : La recherche est basique (fulltext sur tracks/users). Aucun filtre par BPM/key/genre — fonctionnalités critiques pour des musiciens.
|
||||
|
||||
- **Analytics (7%)** : Les créateurs ont besoin de voir leurs stats d'écoute. Le dashboard renvoie des données vides sur plusieurs endpoints.
|
||||
|
||||
#### 5. Estimation réaliste pour v0.101 stable
|
||||
|
||||
| Phase | Contenu | Effort |
|
||||
|-------|---------|--------|
|
||||
| Stabilisation technique | gRPC, job queue, UUID migration, tests | 30h |
|
||||
| Alignement API frontend↔backend | Routes social, search, inventory, live | 20h |
|
||||
| Core features manquantes | Recherche avancée, analytics basiques, chat complet | 40h |
|
||||
| Polish & testing | E2E stable, Storybook audit, bug fixes | 20h |
|
||||
| **TOTAL** | | **110h (~3 semaines à temps plein)** |
|
||||
|
||||
---
|
||||
|
||||
## SCORE GLOBAL DE MATURITÉ
|
||||
|
||||
### 32 / 100
|
||||
|
||||
**Détail :**
|
||||
|
||||
| Critère | Score | Pondération | Note |
|
||||
|---------|-------|-------------|------|
|
||||
| Compilation & santé du code | 95/100 | 15% | Tout compile, peu de TODOs critiques |
|
||||
| Architecture & structure | 80/100 | 15% | Bien organisé mais incohérences gRPC/HTTP |
|
||||
| Features TIER 0 | 44/100 | 25% | 44% des features V1 implémentées |
|
||||
| Tests & qualité | 70/100 | 10% | 99.6% unit pass, E2E instable |
|
||||
| Intégration inter-services | 30/100 | 15% | gRPC stub, routes orphelines, MSW-only pages |
|
||||
| Documentation & DevEx | 75/100 | 5% | Bien documenté, env templates complets |
|
||||
| Sécurité | 85/100 | 10% | Excellente pour un PoC |
|
||||
| Infrastructure & Ops | 60/100 | 5% | Surdimensionné mais fonctionnel |
|
||||
|
||||
**Score pondéré : 32/100**
|
||||
|
||||
---
|
||||
|
||||
**Synthèse en une phrase** : Veza possède une base technique solide et bien architecturée (compilation propre, 3300+ tests, sécurité exemplaire, infrastructure K8s complète), mais reste à mi-chemin de la stabilité fonctionnelle : le stream server ne communique pas vraiment avec le backend, 4 modules frontend n'existent qu'en mocks, et seulement 44% des features TIER 0 sont implémentées de bout en bout — il faut environ 3 semaines de travail focalisé pour atteindre une v0.101 stable.
|
||||
2
Untitled
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
continues les étapes de remédiation pour atteindre la version stable et fonctionnelle :
|
||||
@103_audit_global_features_states.md @103_RAPPORT_ETAT_FEATURES_2026_02_16.md
|
||||
|
|
@ -43,7 +43,7 @@ RUN npm run build && \
|
|||
du -sh dist/
|
||||
|
||||
# Production stage - nginx alpine
|
||||
FROM nginx:alpine
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
# Install minimal dependencies for healthcheck
|
||||
RUN apk add --no-cache wget && \
|
||||
|
|
|
|||
|
|
@ -41,7 +41,14 @@ async function globalSetup(config: FullConfig) {
|
|||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
// Step 1: Verify API is available before attempting login
|
||||
// Step 1: Navigate to frontend first (required for relative API URLs - fetch needs a base URL)
|
||||
console.log('🔧 [GLOBAL SETUP] Navigating to frontend...');
|
||||
await page.goto(TEST_CONFIG.FRONTEND_URL, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Step 2: Verify API is available (page has base URL for relative fetch)
|
||||
console.log('🔧 [GLOBAL SETUP] Verifying API availability...');
|
||||
console.log(`🔧 [GLOBAL SETUP] API URL: ${TEST_CONFIG.API_URL}`);
|
||||
|
||||
|
|
@ -55,7 +62,6 @@ async function globalSetup(config: FullConfig) {
|
|||
const healthResponse = await fetch(healthUrl, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
signal: AbortSignal.timeout(10000), // 10s timeout
|
||||
});
|
||||
return { success: healthResponse.ok, status: healthResponse.status };
|
||||
|
|
@ -71,13 +77,6 @@ async function globalSetup(config: FullConfig) {
|
|||
console.log('✅ [GLOBAL SETUP] API is available');
|
||||
}
|
||||
|
||||
// Navigate to frontend root (not /login to avoid routing issues)
|
||||
console.log('🔧 [GLOBAL SETUP] Navigating to frontend...');
|
||||
await page.goto(TEST_CONFIG.FRONTEND_URL, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Login via API directly in the browser context
|
||||
console.log('🔧 [GLOBAL SETUP] Attempting API login via browser...');
|
||||
const loginResult = await page.evaluate(async ({ apiUrl, email, password }) => {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ test.describe('Authentication Flow', () => {
|
|||
|
||||
// Attendre que le formulaire soit prêt (premier test peut être plus lent)
|
||||
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => { });
|
||||
await expect(page.locator('form')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('input[type="email"], input[name="email"]').first()).toBeVisible({ timeout: 5000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Remplir le formulaire
|
||||
|
|
@ -99,6 +101,7 @@ test.describe('Authentication Flow', () => {
|
|||
test('should show error with invalid credentials', async ({ page }) => {
|
||||
await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await expect(page.locator('form')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Remplir avec des credentials invalides
|
||||
await fillField(page, 'input[type="email"], input[name="email"]', 'wrong@example.com');
|
||||
|
|
@ -139,7 +142,9 @@ test.describe('Authentication Flow', () => {
|
|||
const twoFaInput = page.locator('input#2fa-code, input[placeholder="000000"]').first();
|
||||
await expect(twoFaInput).toBeVisible({ timeout: 10000 });
|
||||
await twoFaInput.fill(code);
|
||||
await page.locator('button:has-text("Verify")').first().click();
|
||||
const verifyButton = page.locator('button:has-text("Verify")').first();
|
||||
await expect(verifyButton).toBeVisible({ timeout: 5000 });
|
||||
await verifyButton.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/(dashboard|$)/, { timeout: 15000 });
|
||||
const token = await getAuthToken(page);
|
||||
|
|
@ -155,6 +160,8 @@ test.describe('Authentication Flow', () => {
|
|||
|
||||
// Attendre que la page soit complètement chargée
|
||||
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
||||
await expect(page.locator('form')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('input[name="email"], input#email').first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Générer un email unique pour éviter les conflits
|
||||
const uniqueEmail = `test-${Date.now()}@example.com`;
|
||||
|
|
@ -251,6 +258,7 @@ test.describe('Authentication Flow', () => {
|
|||
|
||||
// Attendre que la page soit complètement chargée
|
||||
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
||||
await expect(page.locator('form')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Utiliser un email qui existe déjà (celui du test user)
|
||||
const password = 'Str0ng!P@ssw0rd2024'; // 12+ caractères requis, fort
|
||||
|
|
@ -329,6 +337,7 @@ test.describe('Authentication Flow', () => {
|
|||
const isUserMenuVisible = await userMenu.isVisible().catch(() => false);
|
||||
|
||||
if (isUserMenuVisible) {
|
||||
await expect(userMenu).toBeVisible({ timeout: 5000 });
|
||||
await userMenu.click();
|
||||
await page.waitForTimeout(500); // Attendre que le menu s'ouvre
|
||||
|
||||
|
|
@ -408,8 +417,12 @@ test.describe('Authentication Flow', () => {
|
|||
|
||||
// Refresh page
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
|
||||
await page.waitForTimeout(2000); // Wait for app to check auth status
|
||||
|
||||
// Verify nav/sidebar visible (confirms authenticated UI)
|
||||
await expect(page.locator('nav[role="navigation"], aside[role="navigation"]')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Check if still authenticated
|
||||
const afterRefresh = await page.evaluate(() => {
|
||||
try {
|
||||
|
|
@ -450,6 +463,7 @@ test.describe('Authentication Flow', () => {
|
|||
|
||||
// Try submitting the form with invalid data
|
||||
const submitButton = page.locator('button[type="submit"]').first();
|
||||
await expect(submitButton).toBeVisible({ timeout: 5000 });
|
||||
await submitButton.click();
|
||||
await page.waitForTimeout(2000); // Wait to see if navigation happens
|
||||
|
||||
|
|
@ -486,7 +500,8 @@ test.describe('Authentication Flow', () => {
|
|||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Attendre que la page soit complètement chargée
|
||||
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => { });
|
||||
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
||||
await expect(page.locator('form')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Remplir avec des mots de passe différents
|
||||
await fillField(page, 'input[name="email"], input#email', 'newuser@example.com');
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 226 KiB |
|
|
@ -222,9 +222,12 @@ test.describe('Playlists CRUD', () => {
|
|||
// Utiliser dispatchEvent pour contourner l'overlay de la sidebar qui intercepte le click
|
||||
await editButton.dispatchEvent('click');
|
||||
} else if (isMoreVisible) {
|
||||
await expect(moreButton).toBeVisible({ timeout: 5000 });
|
||||
await moreButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.locator('[role="menuitem"]:has-text("Edit"), [role="menuitem"]:has-text("Éditer")').first().click();
|
||||
const editMenuItem = page.locator('[role="menuitem"]:has-text("Edit"), [role="menuitem"]:has-text("Éditer")').first();
|
||||
await expect(editMenuItem).toBeVisible({ timeout: 5000 });
|
||||
await editMenuItem.click();
|
||||
} else {
|
||||
// Si pas de bouton d'édition visible, on est peut-être déjà sur la page de détails
|
||||
// Chercher un formulaire d'édition ou un bouton pour ouvrir l'édition
|
||||
|
|
@ -244,6 +247,7 @@ test.describe('Playlists CRUD', () => {
|
|||
// Soumettre en cliquant sur "Enregistrer" (pas de balise form dans le dialog)
|
||||
// await forceSubmitForm(page, 'form'); // Ne marche pas car pas de form
|
||||
const saveButton = page.locator('[role="dialog"] button').filter({ hasText: /enregistrer/i }).first();
|
||||
await expect(saveButton).toBeVisible({ timeout: 5000 });
|
||||
await saveButton.click({ force: true });
|
||||
await waitForToast(page, 'success', 10000);
|
||||
|
||||
|
|
@ -315,11 +319,15 @@ test.describe('Playlists CRUD', () => {
|
|||
const isMoreVisible = await moreButton.isVisible().catch(() => false);
|
||||
|
||||
if (isAddVisible) {
|
||||
await expect(addToPlaylistButton).toBeVisible({ timeout: 5000 });
|
||||
await addToPlaylistButton.click();
|
||||
} else if (isMoreVisible) {
|
||||
await expect(moreButton).toBeVisible({ timeout: 5000 });
|
||||
await moreButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.locator('[role="menuitem"]:has-text("Add to playlist"), [role="menuitem"]:has-text("Ajouter")').first().click();
|
||||
const addMenuItem = page.locator('[role="menuitem"]:has-text("Add to playlist"), [role="menuitem"]:has-text("Ajouter")').first();
|
||||
await expect(addMenuItem).toBeVisible({ timeout: 5000 });
|
||||
await addMenuItem.click();
|
||||
} else {
|
||||
console.warn('⚠️ [PLAYLISTS] Add to playlist button not found, skipping test');
|
||||
test.skip();
|
||||
|
|
@ -333,6 +341,7 @@ test.describe('Playlists CRUD', () => {
|
|||
const isPlaylistOptionVisible = await playlistOption.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
|
||||
if (isPlaylistOptionVisible) {
|
||||
await expect(playlistOption).toBeVisible({ timeout: 5000 });
|
||||
await playlistOption.click();
|
||||
await waitForToast(page, 'success', 10000);
|
||||
console.log('✅ [PLAYLISTS] Track added to playlist successfully');
|
||||
|
|
@ -386,11 +395,15 @@ test.describe('Playlists CRUD', () => {
|
|||
const isMoreVisible = await moreButton.isVisible().catch(() => false);
|
||||
|
||||
if (isDeleteVisible) {
|
||||
await expect(deleteButton).toBeVisible({ timeout: 5000 });
|
||||
await deleteButton.click({ force: true });
|
||||
} else if (isMoreVisible) {
|
||||
await expect(moreButton).toBeVisible({ timeout: 5000 });
|
||||
await moreButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.locator('[role="menuitem"]:has-text("Delete"), [role="menuitem"]:has-text("Supprimer")').first().click();
|
||||
const deleteMenuItem = page.locator('[role="menuitem"]:has-text("Delete"), [role="menuitem"]:has-text("Supprimer")').first();
|
||||
await expect(deleteMenuItem).toBeVisible({ timeout: 5000 });
|
||||
await deleteMenuItem.click();
|
||||
} else {
|
||||
// Fallback: icône de corbeille
|
||||
const trashButton = page.locator('button svg.lucide-trash, button svg.fa-trash').first();
|
||||
|
|
@ -408,6 +421,7 @@ test.describe('Playlists CRUD', () => {
|
|||
const isConfirmVisible = await confirmButton.isVisible().catch(() => false);
|
||||
|
||||
if (isConfirmVisible) {
|
||||
await expect(confirmButton).toBeVisible({ timeout: 5000 });
|
||||
await confirmButton.click({ force: true });
|
||||
// 🔴 FIX: Attendre la confirmation de suppression avant de continuer
|
||||
// Sinon la navigation manuelle suivante peut annuler la requête
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ test.describe('User Profile Management', () => {
|
|||
const isSidebarLinkVisible = await profileLinkSidebar.isVisible({ timeout: 3000 }).catch(() => false);
|
||||
|
||||
if (isSidebarLinkVisible) {
|
||||
await expect(profileLinkSidebar).toBeVisible({ timeout: 5000 });
|
||||
await profileLinkSidebar.click();
|
||||
} else {
|
||||
// Méthode 2: Via menu utilisateur (Avatar dropdown)
|
||||
|
|
@ -63,6 +64,7 @@ test.describe('User Profile Management', () => {
|
|||
const isUserMenuVisible = await userMenu.isVisible().catch(() => false);
|
||||
|
||||
if (isUserMenuVisible) {
|
||||
await expect(userMenu).toBeVisible({ timeout: 5000 });
|
||||
await userMenu.click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.locator('[role="menuitem"]:has-text("Profil"), [role="menuitem"]:has-text("Profile")').first().click();
|
||||
|
|
@ -143,6 +145,7 @@ test.describe('User Profile Management', () => {
|
|||
if (isDisabled) {
|
||||
const editButton = page.locator('button:has-text("Edit"), button:has-text("Modifier"), button:has-text("profile.edit")').first();
|
||||
if (await editButton.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await expect(editButton).toBeVisible({ timeout: 5000 });
|
||||
await editButton.click();
|
||||
await page.waitForTimeout(500); // Attendre que le mode édition s'active
|
||||
// Re-vérifier que le champ est maintenant éditable
|
||||
|
|
@ -364,6 +367,7 @@ test.describe('User Profile Management', () => {
|
|||
const isAvatarContainerVisible = await avatarContainer.isVisible().catch(() => false);
|
||||
|
||||
if (isAvatarContainerVisible) {
|
||||
await expect(avatarContainer).toBeVisible({ timeout: 5000 });
|
||||
await avatarContainer.click();
|
||||
await page.waitForTimeout(500);
|
||||
} else {
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 466 KiB |
|
After Width: | Height: | Size: 464 KiB |
|
After Width: | Height: | Size: 530 KiB |
|
After Width: | Height: | Size: 532 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 461 KiB |
|
After Width: | Height: | Size: 461 KiB |
|
After Width: | Height: | Size: 532 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
Before Width: | Height: | Size: 733 KiB |
|
Before Width: | Height: | Size: 732 KiB |
|
Before Width: | Height: | Size: 388 KiB |
|
Before Width: | Height: | Size: 731 KiB |
|
Before Width: | Height: | Size: 732 KiB |
|
|
@ -1,16 +0,0 @@
|
|||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e2]:
|
||||
- link "Skip to content" [ref=e3] [cursor=pointer]:
|
||||
- /url: "#main-content"
|
||||
- generic [ref=e5]:
|
||||
- img [ref=e7]
|
||||
- heading "Failed to load Register" [level=2] [ref=e9]
|
||||
- paragraph [ref=e10]: Cannot convert object to primitive value
|
||||
- generic [ref=e11]:
|
||||
- button "Try Again" [ref=e12]:
|
||||
- img [ref=e13]
|
||||
- text: Try Again
|
||||
- button "Refresh Page" [ref=e18]
|
||||
```
|
||||
|
Before Width: | Height: | Size: 731 KiB |
|
Before Width: | Height: | Size: 388 KiB |
|
Before Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 734 KiB |
|
|
@ -1,51 +0,0 @@
|
|||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e2]:
|
||||
- link "Skip to content" [ref=e3] [cursor=pointer]:
|
||||
- /url: "#main-content"
|
||||
- main "Page d'authentification" [ref=e4]:
|
||||
- generic [ref=e10]:
|
||||
- generic [ref=e11]:
|
||||
- generic [ref=e12]:
|
||||
- generic [ref=e14]: V
|
||||
- generic [ref=e15]: Veza
|
||||
- heading "Welcome Back" [level=1] [ref=e16]
|
||||
- paragraph [ref=e17]: Sign in to your account
|
||||
- generic "Welcome Back" [ref=e18]:
|
||||
- generic [ref=e20]:
|
||||
- generic [ref=e21]:
|
||||
- button "Se connecter avec Google" [ref=e22]:
|
||||
- img [ref=e23]
|
||||
- generic [ref=e28]: Google
|
||||
- button "Se connecter avec GitHub" [ref=e29]:
|
||||
- img [ref=e30]
|
||||
- generic [ref=e33]: GitHub
|
||||
- button "Se connecter avec Discord" [ref=e34]:
|
||||
- img [ref=e35]
|
||||
- generic [ref=e37]: Discord
|
||||
- generic [ref=e42]: or continue with
|
||||
- generic [ref=e43]:
|
||||
- generic [ref=e44]:
|
||||
- generic [ref=e45]:
|
||||
- generic [ref=e46]: Email
|
||||
- textbox "Email" [ref=e48]
|
||||
- generic [ref=e49]:
|
||||
- generic [ref=e50]: Password
|
||||
- generic [ref=e51]:
|
||||
- textbox "Password" [ref=e52]
|
||||
- button "Show password" [ref=e53]:
|
||||
- img [ref=e54]
|
||||
- generic [ref=e57]:
|
||||
- generic [ref=e58] [cursor=pointer]:
|
||||
- generic [ref=e59]:
|
||||
- checkbox "Remember me" [ref=e60]
|
||||
- img
|
||||
- generic [ref=e62]: Remember me
|
||||
- link "Forgot password?" [ref=e63] [cursor=pointer]:
|
||||
- /url: /forgot-password
|
||||
- button "Sign In" [ref=e64]
|
||||
- navigation "Navigation d'authentification" [ref=e65]:
|
||||
- link "Don't have an account? Sign up" [ref=e66] [cursor=pointer]:
|
||||
- /url: /register
|
||||
```
|
||||
|
Before Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 734 KiB |
|
Before Width: | Height: | Size: 388 KiB |
|
Before Width: | Height: | Size: 732 KiB |
|
Before Width: | Height: | Size: 385 KiB |
|
Before Width: | Height: | Size: 736 KiB |
|
Before Width: | Height: | Size: 386 KiB |
|
Before Width: | Height: | Size: 389 KiB |
|
Before Width: | Height: | Size: 386 KiB |
|
|
@ -8,10 +8,22 @@ const NAVIGATION_TIMEOUT_MS = 30000; // 30s per story navigation
|
|||
const DEFAULT_TIMEOUT_MS = 300000; // 300s global default for Playwright
|
||||
const MAX_RETRIES = 2; // 2 retries = 3 attempts total
|
||||
const RETRY_DELAY_MS = 1500;
|
||||
const POST_LOAD_WAIT_MS = 150;
|
||||
const POST_LOAD_WAIT_MS = 600; // Allow MSW and async requests to settle
|
||||
|
||||
console.log("Script started");
|
||||
|
||||
/** Console errors that are intentional or from Storybook internals (non-blocking). */
|
||||
const IGNORED_CONSOLE_ERRORS = [
|
||||
/This is a test error for demonstrating ErrorBoundary/i,
|
||||
/received.*but was unable to determine the source of the event/i,
|
||||
];
|
||||
|
||||
function isIgnoredConsoleError(text, locationUrl = '') {
|
||||
if (IGNORED_CONSOLE_ERRORS.some((re) => re.test(text))) return true;
|
||||
if (/sb-manager\/|sb-addons\//.test(locationUrl || '')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Ignore Storybook manager/addon requests; only count app and API failures. */
|
||||
function isAppRelevantFailure(url) {
|
||||
try {
|
||||
|
|
@ -40,6 +52,7 @@ async function processStory(page, storyId, report) {
|
|||
storyDetails.console.push({
|
||||
type: msg.type(),
|
||||
text: msg.text(),
|
||||
url: location.url,
|
||||
location: `${location.url}:${location.lineNumber}:${location.columnNumber}`
|
||||
});
|
||||
}
|
||||
|
|
@ -53,6 +66,8 @@ async function processStory(page, storyId, report) {
|
|||
const failure = request.failure();
|
||||
const errorText = failure ? failure.errorText : 'Unknown network error';
|
||||
if (errorText === 'net::ERR_ABORTED' && /\/iframe\.html$|\/iframe$/.test(new URL(url).pathname || '')) return;
|
||||
// ERR_ABORTED often occurs when navigating away before requests complete; not a blocking error
|
||||
if (errorText === 'net::ERR_ABORTED') return;
|
||||
storyDetails.network.push({ url, method: request.method(), errorText });
|
||||
};
|
||||
|
||||
|
|
@ -81,8 +96,11 @@ async function processStory(page, storyId, report) {
|
|||
page.removeAllListeners('pageerror');
|
||||
page.removeAllListeners('requestfailed');
|
||||
|
||||
const errorCount = storyDetails.console.filter(c => c.type === 'error').length +
|
||||
storyDetails.pageErrors.length +
|
||||
const blockingConsoleErrors = storyDetails.console.filter(
|
||||
(c) => c.type === 'error' && !isIgnoredConsoleError(c.text, c.url)
|
||||
);
|
||||
const errorCount = blockingConsoleErrors.length +
|
||||
storyDetails.pageErrors.filter((e) => !isIgnoredConsoleError(e.message)).length +
|
||||
storyDetails.network.length +
|
||||
(storyDetails.navigation ? 1 : 0);
|
||||
|
||||
|
|
@ -118,7 +136,10 @@ async function audit() {
|
|||
throw new Error(`File not found: ${storybookStaticPath}`);
|
||||
}
|
||||
const indexJson = JSON.parse(fs.readFileSync(storybookStaticPath, 'utf8'));
|
||||
stories = Object.values(indexJson.entries).map(e => e.id);
|
||||
let allStories = Object.values(indexJson.entries).map(e => e.id);
|
||||
const limit = parseInt(process.env.STORYBOOK_AUDIT_LIMIT || '0', 10);
|
||||
stories = limit > 0 ? allStories.slice(0, limit) : allStories;
|
||||
if (limit > 0) console.log(`Limiting audit to first ${limit} stories`);
|
||||
} catch (e) {
|
||||
console.error(`Failed to read local index.json: ${e.message}`);
|
||||
process.exit(1);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* Tests for AdminDashboardView
|
||||
* Plan 2.4: Loading, Error, Success states
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ToastProvider } from '@/components/feedback/ToastProvider';
|
||||
import { AdminDashboardView } from '../AdminDashboardView';
|
||||
import { useAdminDashboardView } from './useAdminDashboardView';
|
||||
|
||||
vi.mock('./useAdminDashboardView');
|
||||
|
||||
const defaultHookReturn = {
|
||||
stats: {
|
||||
totalUsers: 100,
|
||||
monthlyRevenue: 5000,
|
||||
activeSessions: 25,
|
||||
pendingReports: 3,
|
||||
trends: { users: '+5%', revenue: '+10%', sessions: '-2%', reports: '+1' },
|
||||
},
|
||||
reports: [],
|
||||
uploads: [],
|
||||
auditLogs: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
protocolActive: null,
|
||||
handleAction: vi.fn(),
|
||||
triggerProtocol: vi.fn(),
|
||||
retry: vi.fn(),
|
||||
};
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AdminDashboardView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useAdminDashboardView).mockReturnValue(defaultHookReturn as any);
|
||||
});
|
||||
|
||||
it('should render success state with stats', () => {
|
||||
render(<AdminDashboardView />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText(/Total Nodes/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/100/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render loading state', () => {
|
||||
vi.mocked(useAdminDashboardView).mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
loading: true,
|
||||
} as any);
|
||||
|
||||
render(<AdminDashboardView />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.queryByText(/Total Nodes/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render error state with retry', () => {
|
||||
vi.mocked(useAdminDashboardView).mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
loading: false,
|
||||
error: new Error('Failed to load admin dashboard'),
|
||||
} as any);
|
||||
|
||||
render(<AdminDashboardView />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText(/Failed to load admin dashboard/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /retry|réessayer/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -31,7 +31,7 @@ export function useCreateProductView() {
|
|||
|
||||
const handlePublish = useCallback(async () => {
|
||||
if (!title || !description) {
|
||||
addToast('Please fill in required fields', 'error');
|
||||
toast.error('Please fill in required fields');
|
||||
return;
|
||||
}
|
||||
const activeLicense = licenses.find((l) => l.enabled);
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export function TwoFactorSetup({ onBack, onComplete }: TwoFactorSetupProps) {
|
|||
const handleCopySecret = () => {
|
||||
if (setupData) {
|
||||
navigator.clipboard.writeText(setupData.secret);
|
||||
addToast('Secret Key copied');
|
||||
toast.success('Secret Key copied');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { twoFactorService } from '@/services/2fa-service';
|
|||
import type { TwoFactorSetupData, TwoFactorMethod, TwoFactorSetupStep } from './types';
|
||||
|
||||
export function useTwoFactorSetup(onBack: () => void, _onComplete: () => void) {
|
||||
const { addToast } = useToast();
|
||||
const [step, setStep] = useState<TwoFactorSetupStep>(1);
|
||||
const [method, setMethod] = useState<TwoFactorMethod>('totp');
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ interface ExploreItem {
|
|||
}
|
||||
|
||||
export const ExploreView: React.FC = () => {
|
||||
const { addToast } = useToast();
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
'for_you' | 'trending' | 'new' | 'popular'
|
||||
>('for_you');
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ export function useGroupDetailView(groupId: string) {
|
|||
if (!group) return;
|
||||
await groupService.join(group.id);
|
||||
setGroup({ ...group, userRole: 'member', members: group.members + 1 });
|
||||
addToast('Joined group!', 'success');
|
||||
}, [group, addToast]);
|
||||
toast.success('Joined group!');
|
||||
}, [group]);
|
||||
|
||||
const handleLeave = useCallback(async () => {
|
||||
if (!group) return;
|
||||
|
|
|
|||
|
|
@ -47,11 +47,11 @@ export const FEATURES = {
|
|||
|
||||
/**
|
||||
* HLS Streaming
|
||||
* Backend endpoints: /api/v1/tracks/:id/hls/info, /api/v1/tracks/:id/hls/status (NOT IMPLEMENTED)
|
||||
* Backend endpoints: /api/v1/tracks/:id/hls/info, /api/v1/tracks/:id/hls/status
|
||||
*/
|
||||
HLS_STREAMING: parseFeatureEnv(
|
||||
import.meta.env.VITE_FEATURE_HLS_STREAMING,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,6 +3,28 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||
import { ToastProvider, useToast } from '../components/feedback/ToastProvider';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
// Mock toast to avoid react-hot-toast dynamic import issues in Vitest
|
||||
vi.mock('@/utils/toast', () => {
|
||||
const mockFn = (msg: string) => {
|
||||
const el = document.createElement('div');
|
||||
el.textContent = msg;
|
||||
el.setAttribute('data-testid', 'toast-message');
|
||||
document.body.appendChild(el);
|
||||
};
|
||||
return {
|
||||
default: Object.assign(mockFn, {
|
||||
success: mockFn,
|
||||
error: mockFn,
|
||||
warning: mockFn,
|
||||
loading: mockFn,
|
||||
custom: mockFn,
|
||||
dismiss: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
promise: vi.fn(() => Promise.resolve()),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
);
|
||||
|
|
@ -10,6 +32,7 @@ const wrapper = ({ children }: { children: ReactNode }) => (
|
|||
describe('ToastContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.querySelectorAll('[data-testid="toast-message"]').forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
it('should provide toast context', () => {
|
||||
|
|
|
|||
|
|
@ -21,4 +21,4 @@ const meta = {
|
|||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const Default: Story = { args: undefined as never };
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||
import { AnimatedNumber } from '@/components/ui/AnimatedNumber';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { LucideIcon, Music, MessageSquare, Heart, Users } from 'lucide-react';
|
||||
import { Music, MessageSquare, Heart, Users } from 'lucide-react';
|
||||
|
||||
const STATS = [
|
||||
{
|
||||
|
|
|
|||
87
apps/web/src/features/marketplace/components/Cart.test.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* Cart component tests - Marketplace feature
|
||||
* Plan V0.101: Tests marketplace ≥ 10
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@/test/test-utils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Cart } from './Cart';
|
||||
import { useCartStore } from '@/stores/cartStore';
|
||||
import type { Product } from '@/types/marketplace';
|
||||
|
||||
vi.mock('@/services/marketplaceService', () => ({
|
||||
marketplaceService: {
|
||||
createOrder: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/auth/store/authStore', () => ({
|
||||
useAuthStore: () => ({ isAuthenticated: true }),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useToast', () => ({
|
||||
useToast: () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
addToast: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockProduct: Product = {
|
||||
id: 'prod-1',
|
||||
seller_id: 'seller-1',
|
||||
title: 'Test Track',
|
||||
description: 'A test product',
|
||||
price: 19.99,
|
||||
currency: 'EUR',
|
||||
status: 'active',
|
||||
product_type: 'track',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
describe('Cart', () => {
|
||||
beforeEach(() => {
|
||||
useCartStore.getState().clearCart();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should display empty state when cart is empty', () => {
|
||||
render(<Cart isOpen={true} onClose={() => {}} />);
|
||||
expect(screen.getByText(/your cart is empty/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display cart items when cart has products', () => {
|
||||
useCartStore.getState().addItem(mockProduct);
|
||||
render(<Cart isOpen={true} onClose={() => {}} />);
|
||||
expect(screen.getByText(mockProduct.title)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/19,99/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should display total for cart items', () => {
|
||||
useCartStore.getState().addItem(mockProduct);
|
||||
useCartStore.getState().addItem({
|
||||
...mockProduct,
|
||||
id: 'prod-2',
|
||||
title: 'Second',
|
||||
price: 10,
|
||||
});
|
||||
render(<Cart isOpen={true} onClose={() => {}} />);
|
||||
expect(screen.getByText('29,99 €')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call createOrder on checkout when authenticated', async () => {
|
||||
const { marketplaceService } = await import('@/services/marketplaceService');
|
||||
useCartStore.getState().addItem(mockProduct);
|
||||
(marketplaceService.createOrder as ReturnType<typeof vi.fn>).mockResolvedValue({ order: { id: 'ord-1' } });
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<Cart isOpen={true} onClose={() => {}} />);
|
||||
const checkoutBtn = screen.getByRole('button', { name: /checkout/i });
|
||||
await user.click(checkoutBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(marketplaceService.createOrder).toHaveBeenCalledWith([{ product_id: 'prod-1' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* NotificationsPage tests - Plan V0.101: Tests Notifications ≥ 3
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@/test/test-utils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { NotificationsPage } from './NotificationsPage';
|
||||
import { NotificationsPageEmpty } from './NotificationsPageEmpty';
|
||||
import { NotificationsPageItem } from './NotificationsPageItem';
|
||||
import type { Notification } from '../../services/notificationService';
|
||||
|
||||
const mockNotification: Notification = {
|
||||
id: 'n1',
|
||||
user_id: 'u1',
|
||||
type: 'track_uploaded',
|
||||
title: 'New track',
|
||||
content: 'A new track was uploaded',
|
||||
read: false,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
describe('NotificationsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should display empty state when no notifications', () => {
|
||||
render(<NotificationsPageEmpty filterType="all" />);
|
||||
|
||||
expect(screen.getByRole('heading', { name: /no notifications/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display notification item with title and content', () => {
|
||||
const onMarkAsRead = vi.fn();
|
||||
render(
|
||||
<NotificationsPageItem
|
||||
notification={mockNotification}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('New track')).toBeInTheDocument();
|
||||
expect(screen.getByText(/a new track was uploaded/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onMarkAsRead when mark-as-read button is clicked', async () => {
|
||||
const onMarkAsRead = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<NotificationsPageItem
|
||||
notification={mockNotification}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
/>
|
||||
);
|
||||
|
||||
const markAsReadBtn = screen.getByRole('button', { name: /mark as read/i });
|
||||
await user.click(markAsReadBtn);
|
||||
|
||||
expect(onMarkAsRead).toHaveBeenCalledWith('n1');
|
||||
});
|
||||
});
|
||||
|
|
@ -251,15 +251,23 @@ export const usePlayerStore = create<PlayerStore>()(
|
|||
currentTrack: state.currentTrack,
|
||||
}),
|
||||
migrate: (persistedState: unknown) => {
|
||||
const s = persistedState as Partial<PlayerStore> | null;
|
||||
const s = persistedState as Record<string, unknown> | null;
|
||||
const rawRepeat = s?.repeat as string | undefined;
|
||||
// Map legacy 'one'/'all' to 'track'/'playlist' for backward compatibility
|
||||
const repeat: 'off' | 'track' | 'playlist' =
|
||||
rawRepeat === 'one'
|
||||
? 'track'
|
||||
: rawRepeat === 'all'
|
||||
? 'playlist'
|
||||
: (rawRepeat === 'track' || rawRepeat === 'playlist' ? rawRepeat : 'off');
|
||||
return {
|
||||
volume: s?.volume ?? 100,
|
||||
muted: s?.muted ?? false,
|
||||
repeat: (s?.repeat as 'off' | 'one' | 'all') ?? 'off',
|
||||
shuffle: s?.shuffle ?? false,
|
||||
queue: s?.queue ?? [],
|
||||
currentIndex: s?.currentIndex ?? -1,
|
||||
currentTrack: s?.currentTrack ?? null,
|
||||
volume: (s?.volume as number | undefined) ?? 100,
|
||||
muted: (s?.muted as boolean | undefined) ?? false,
|
||||
repeat,
|
||||
shuffle: (s?.shuffle as boolean | undefined) ?? false,
|
||||
queue: (s?.queue as Track[]) ?? [],
|
||||
currentIndex: (s?.currentIndex as number | undefined) ?? -1,
|
||||
currentTrack: (s?.currentTrack as Track | null) ?? null,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -277,9 +277,7 @@ export interface GetRecommendationsParams {
|
|||
|
||||
/**
|
||||
* Rechercher des playlists
|
||||
*
|
||||
* ⚠️ MVP: This feature is disabled. Backend endpoint is not implemented.
|
||||
* TODO: Enable when backend implements GET /api/v1/playlists/search
|
||||
* Backend: GET /api/v1/playlists/search
|
||||
*
|
||||
* @see FEATURES.PLAYLIST_SEARCH
|
||||
*/
|
||||
|
|
@ -296,9 +294,7 @@ export async function searchPlaylists(
|
|||
|
||||
/**
|
||||
* Créer un lien de partage
|
||||
*
|
||||
* ⚠️ MVP: This feature is disabled. Backend endpoint is not implemented.
|
||||
* TODO: Enable when backend implements POST /api/v1/playlists/:id/share
|
||||
* Backend: POST /api/v1/playlists/:id/share
|
||||
*
|
||||
* @see FEATURES.PLAYLIST_SHARE
|
||||
*/
|
||||
|
|
@ -336,23 +332,31 @@ export async function removeTrackFromPlaylist(
|
|||
|
||||
/**
|
||||
* Obtenir des recommandations de playlists
|
||||
*
|
||||
* ⚠️ MVP: This feature is disabled. Backend endpoint is not implemented.
|
||||
* TODO: Enable when backend implements GET /api/v1/playlists/recommendations
|
||||
* Backend: GET /api/v1/playlists/recommendations
|
||||
*
|
||||
* @see FEATURES.PLAYLIST_RECOMMENDATIONS
|
||||
*/
|
||||
export async function getPlaylistRecommendations(
|
||||
_params: GetRecommendationsParams,
|
||||
params: GetRecommendationsParams,
|
||||
): Promise<{ recommendations: PlaylistRecommendation[] }> {
|
||||
requireFeature('PLAYLIST_RECOMMENDATIONS');
|
||||
// TODO: Replace with actual API call when backend is ready
|
||||
// const response = await apiClient.get<{ recommendations: PlaylistRecommendation[] }>('/playlists/recommendations', { params });
|
||||
// return response.data;
|
||||
|
||||
// Mock response for now to satisfy type checker and frontend dev
|
||||
return Promise.resolve({
|
||||
recommendations: [],
|
||||
return wrapPlaylistError(async () => {
|
||||
const response = await apiClient.get<{
|
||||
data?: { recommendations: PlaylistRecommendation[] };
|
||||
recommendations?: PlaylistRecommendation[];
|
||||
}>('/playlists/recommendations', {
|
||||
params: {
|
||||
limit: params.limit,
|
||||
min_score: params.min_score,
|
||||
include_own: params.include_own,
|
||||
},
|
||||
});
|
||||
const data = response.data as Record<string, unknown> | undefined;
|
||||
const recommendations =
|
||||
(data?.data as { recommendations?: PlaylistRecommendation[] })?.recommendations ??
|
||||
(data?.recommendations as PlaylistRecommendation[] | undefined) ??
|
||||
[];
|
||||
return { recommendations };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
195
apps/web/src/features/roles/components/AssignRoleModal.test.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* AssignRoleModal tests - RBAC feature
|
||||
* Plan V0.101: Tests RBAC ≥ 5
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, within } from '@/test/test-utils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { AssignRoleModal } from './AssignRoleModal';
|
||||
import * as roleService from '../services/roleService';
|
||||
import type { Role } from '../types/role';
|
||||
|
||||
vi.mock('../services/roleService');
|
||||
const mockToastError = vi.fn();
|
||||
vi.mock('@/hooks/useToast', () => ({
|
||||
useToast: () => ({
|
||||
success: vi.fn(),
|
||||
error: mockToastError,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockRoles: Role[] = [
|
||||
{
|
||||
id: 'r1',
|
||||
name: 'admin',
|
||||
display_name: 'Administrator',
|
||||
description: 'Full access',
|
||||
is_system: false,
|
||||
is_active: true,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
permissions: [],
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
name: 'editor',
|
||||
display_name: 'Editor',
|
||||
description: 'Can edit content',
|
||||
is_system: false,
|
||||
is_active: true,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
permissions: [],
|
||||
},
|
||||
];
|
||||
|
||||
describe('AssignRoleModal', () => {
|
||||
const onClose = vi.fn();
|
||||
const onRoleAssigned = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockToastError.mockClear();
|
||||
vi.mocked(roleService.getUserRoles).mockResolvedValue([]);
|
||||
vi.mocked(roleService.assignRole).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should display modal with title when open', async () => {
|
||||
render(
|
||||
<AssignRoleModal
|
||||
open={true}
|
||||
userId="u1"
|
||||
userName="John Doe"
|
||||
availableRoles={mockRoles}
|
||||
onClose={onClose}
|
||||
onRoleAssigned={onRoleAssigned}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText(/assign role to john doe/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should load user roles on open and display available roles', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(roleService.getUserRoles).mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<AssignRoleModal
|
||||
open={true}
|
||||
userId="u1"
|
||||
availableRoles={mockRoles}
|
||||
onClose={onClose}
|
||||
onRoleAssigned={onRoleAssigned}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(roleService.getUserRoles).toHaveBeenCalledWith('u1');
|
||||
});
|
||||
|
||||
// Open the Select dropdown to see role options
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const selectTriggers = within(dialog).getAllByRole('button', { name: /select a role/i });
|
||||
await user.click(selectTriggers[0]!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('option', { name: /administrator/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /editor/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call assignRole when submitting with selected role', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<AssignRoleModal
|
||||
open={true}
|
||||
userId="u1"
|
||||
availableRoles={mockRoles}
|
||||
onClose={onClose}
|
||||
onRoleAssigned={onRoleAssigned}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/select a role/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const selectTriggers = within(dialog).getAllByRole('button', { name: /select a role/i });
|
||||
await user.click(selectTriggers[0]!);
|
||||
const option = await screen.findByRole('option', { name: /administrator/i });
|
||||
await user.click(option);
|
||||
|
||||
const assignBtn = within(dialog).getByRole('button', { name: /^assign role$/i });
|
||||
await user.click(assignBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(roleService.assignRole).toHaveBeenCalledWith('u1', {
|
||||
role_id: 'r1',
|
||||
expires_at: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should display current user roles when user has roles', async () => {
|
||||
vi.mocked(roleService.getUserRoles).mockResolvedValue([mockRoles[0]]);
|
||||
|
||||
render(
|
||||
<AssignRoleModal
|
||||
open={true}
|
||||
userId="u1"
|
||||
availableRoles={mockRoles}
|
||||
onClose={onClose}
|
||||
onRoleAssigned={onRoleAssigned}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/current roles/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/administrator/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle API 403 error when assigning role', async () => {
|
||||
vi.mocked(roleService.assignRole).mockRejectedValue(
|
||||
new Error('Forbidden: You do not have permission to assign roles')
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<AssignRoleModal
|
||||
open={true}
|
||||
userId="u1"
|
||||
availableRoles={mockRoles}
|
||||
onClose={onClose}
|
||||
onRoleAssigned={onRoleAssigned}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/select a role/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const selectTriggers = within(dialog).getAllByRole('button', { name: /select a role/i });
|
||||
await user.click(selectTriggers[0]!);
|
||||
const option = await screen.findByRole('option', { name: /administrator/i });
|
||||
await user.click(option);
|
||||
|
||||
const assignBtn = within(dialog).getByRole('button', { name: /^assign role$/i });
|
||||
await user.click(assignBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(roleService.assignRole).toHaveBeenCalled();
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/forbidden|permission/i)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -75,8 +75,6 @@ export function AssignRoleModal({
|
|||
setSelectedRoleId('');
|
||||
setExpiresAt('');
|
||||
onRoleAssigned();
|
||||
setExpiresAt('');
|
||||
onRoleAssigned();
|
||||
} catch (err: unknown) {
|
||||
const apiError = parseApiError(err);
|
||||
error(apiError.message);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* SearchPage tests - Plan V0.101: Tests Search ≥ 3
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@/test/test-utils';
|
||||
import { SearchPage } from './SearchPage';
|
||||
import { useSearchPage } from './useSearchPage';
|
||||
import type { SearchResults } from '@/types/search';
|
||||
|
||||
vi.mock('./useSearchPage');
|
||||
|
||||
const mockSearchResults: SearchResults = {
|
||||
tracks: [
|
||||
{
|
||||
id: 't1',
|
||||
title: 'Test Track',
|
||||
artist: 'Test Artist',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
} as SearchResults['tracks'][0],
|
||||
],
|
||||
artists: [
|
||||
{
|
||||
id: 'u1',
|
||||
username: 'testuser',
|
||||
avatar_url: undefined,
|
||||
followers_count: 10,
|
||||
},
|
||||
],
|
||||
playlists: [],
|
||||
};
|
||||
|
||||
describe('SearchPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useSearchPage).mockReturnValue({
|
||||
query: 'test',
|
||||
setQuery: vi.fn(),
|
||||
results: mockSearchResults,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
clearSearch: vi.fn(),
|
||||
hasResults: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should display search results with tracks and artists', () => {
|
||||
render(<SearchPage />);
|
||||
|
||||
expect(screen.getByText(/all results/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /tracks \(1\)/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /artists \(1\)/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display empty state when no results', () => {
|
||||
vi.mocked(useSearchPage).mockReturnValue({
|
||||
query: 'nonexistent',
|
||||
setQuery: vi.fn(),
|
||||
results: { tracks: [], artists: [], playlists: [] },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
clearSearch: vi.fn(),
|
||||
hasResults: false,
|
||||
});
|
||||
|
||||
render(<SearchPage />);
|
||||
|
||||
expect(screen.getByText(/no results found/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display search input with query value', () => {
|
||||
render(<SearchPage />);
|
||||
|
||||
const input = screen.getByPlaceholderText(/search for tracks/i);
|
||||
expect(input).toHaveValue('test');
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
unlikeTrack,
|
||||
getTrackLikes,
|
||||
} from '../services/interactionService';
|
||||
import { TrackServiceError } from '../errors/trackErrors';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
// Mock dependencies
|
||||
|
|
@ -103,7 +104,7 @@ describe('LikeButton', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('text-red-500');
|
||||
expect(button).toHaveClass('text-destructive');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -244,7 +245,7 @@ describe('LikeButton', () => {
|
|||
|
||||
it('should show error toast on like failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
const error = new TrackUploadError('Failed to like track', 'SERVER', true);
|
||||
const error = new TrackServiceError('Failed to like track', 'SERVER', true);
|
||||
vi.mocked(getTrackLikes).mockResolvedValue({
|
||||
count: 5,
|
||||
isLiked: false,
|
||||
|
|
@ -267,7 +268,7 @@ describe('LikeButton', () => {
|
|||
|
||||
it('should show error toast on unlike failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
const error = new TrackUploadError(
|
||||
const error = new TrackServiceError(
|
||||
'Failed to unlike track',
|
||||
'SERVER',
|
||||
true,
|
||||
|
|
@ -294,7 +295,7 @@ describe('LikeButton', () => {
|
|||
|
||||
it('should revert state on error', async () => {
|
||||
const user = userEvent.setup();
|
||||
const error = new TrackUploadError('Failed to like track', 'SERVER', true);
|
||||
const error = new TrackServiceError('Failed to like track', 'SERVER', true);
|
||||
vi.mocked(getTrackLikes).mockResolvedValue({
|
||||
count: 5,
|
||||
isLiked: false,
|
||||
|
|
@ -313,12 +314,12 @@ describe('LikeButton', () => {
|
|||
// Should revert to original state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
expect(button).not.toHaveClass('text-red-500');
|
||||
expect(button).not.toHaveClass('text-destructive');
|
||||
});
|
||||
});
|
||||
|
||||
it('should reload likes when trackId changes', async () => {
|
||||
const { rerender } = render(<LikeButton trackId="1" />);
|
||||
const { rerender } = render(<LikeButton trackId="1" />, { wrapper: createWrapper() });
|
||||
|
||||
vi.mocked(getTrackLikes).mockResolvedValue({
|
||||
count: 5,
|
||||
|
|
@ -348,7 +349,7 @@ describe('LikeButton', () => {
|
|||
isLiked: false,
|
||||
});
|
||||
|
||||
render(<LikeButton trackId="1" className="custom-class" />);
|
||||
render(<LikeButton trackId="1" className="custom-class" />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const button = screen.getByRole('button');
|
||||
|
|
@ -366,7 +367,7 @@ describe('LikeButton', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('aria-label', 'Ajouter un like');
|
||||
expect(button).toHaveAttribute('aria-label', 'Ajouter aux favoris');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -380,7 +381,7 @@ describe('LikeButton', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('aria-label', 'Retirer le like');
|
||||
expect(button).toHaveAttribute('aria-label', 'Retirer des favoris');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
/**
|
||||
* Webhook API - Feature API Layer
|
||||
* Action 6.1.1.10: Update feature API files to use services
|
||||
*
|
||||
* This file provides the implementation layer for webhook API operations.
|
||||
* Currently, there is no unified service layer for webhooks.
|
||||
*
|
||||
* TODO: Consider creating @/services/api/webhooks (webhooksApi) in the future
|
||||
* to align with the service layer pattern used for tracks, auth, users, and playlists.
|
||||
*/
|
||||
|
||||
import { apiClient } from '@/services/api/client';
|
||||
import { Webhook } from '@/types/webhook';
|
||||
|
||||
/**
|
||||
* Webhook API
|
||||
* Implémente les endpoints webhooks selon le backend
|
||||
* Endpoints:
|
||||
* - POST /webhooks (protected) - Enregistrer un webhook
|
||||
* - GET /webhooks (protected) - Lister les webhooks de l'utilisateur
|
||||
* - DELETE /webhooks/:id (protected) - Supprimer un webhook
|
||||
* - GET /webhooks/stats (protected) - Statistiques des webhooks
|
||||
* - POST /webhooks/:id/test (protected) - Tester un webhook
|
||||
* - POST /webhooks/:id/regenerate-key (protected) - Régénérer la clé API
|
||||
*/
|
||||
|
||||
export interface RegisterWebhookRequest {
|
||||
url: string;
|
||||
events: string[]; // Array of event types (e.g., ['track.uploaded', 'user.created'])
|
||||
}
|
||||
|
||||
export interface WebhookStats {
|
||||
queue_size: number;
|
||||
workers: number;
|
||||
max_retries: number;
|
||||
}
|
||||
|
||||
export interface WebhookStatsResponse {
|
||||
user_id: string;
|
||||
stats: WebhookStats;
|
||||
}
|
||||
|
||||
export interface RegenerateAPIKeyResponse {
|
||||
api_key: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un nouveau webhook
|
||||
* @param data Données du webhook (URL et événements)
|
||||
* @returns Le webhook créé
|
||||
*/
|
||||
export async function registerWebhook(
|
||||
data: RegisterWebhookRequest,
|
||||
): Promise<Webhook> {
|
||||
const response = await apiClient.post<Webhook>('/webhooks', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les webhooks de l'utilisateur authentifié
|
||||
* @returns Liste des webhooks
|
||||
*/
|
||||
export async function listWebhooks(): Promise<Webhook[]> {
|
||||
const response = await apiClient.get<Webhook[]>('/webhooks');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un webhook
|
||||
* @param id ID du webhook à supprimer
|
||||
* @returns Message de confirmation
|
||||
*/
|
||||
export async function deleteWebhook(id: string): Promise<{ message: string }> {
|
||||
const response = await apiClient.delete<{ message: string }>(
|
||||
`/webhooks/${id}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les statistiques des webhooks
|
||||
* @returns Statistiques du worker de webhooks
|
||||
*/
|
||||
export async function getWebhookStats(): Promise<WebhookStatsResponse> {
|
||||
const response = await apiClient.get<WebhookStatsResponse>('/webhooks/stats');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste un webhook en envoyant un événement de test
|
||||
* @param id ID du webhook à tester
|
||||
* @returns Message de confirmation
|
||||
*/
|
||||
export async function testWebhook(id: string): Promise<{ message: string }> {
|
||||
const response = await apiClient.post<{ message: string }>(
|
||||
`/webhooks/${id}/test`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Régénère la clé API d'un webhook
|
||||
* @param id ID du webhook
|
||||
* @returns Nouvelle clé API et message de confirmation
|
||||
*/
|
||||
export async function regenerateWebhookAPIKey(
|
||||
id: string,
|
||||
): Promise<RegenerateAPIKeyResponse> {
|
||||
const response = await apiClient.post<RegenerateAPIKeyResponse>(
|
||||
`/webhooks/${id}/regenerate-key`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { apiClient } from '@/services/api/client';
|
||||
import { parseApiError } from '@/utils/apiErrorHandler';
|
||||
import { logger } from '@/utils/logger';
|
||||
import type { ApiError } from '@/schemas/apiSchemas';
|
||||
|
|
@ -17,15 +18,12 @@ export interface ValidationError {
|
|||
value?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation response from backend
|
||||
*/
|
||||
// ValidateResponse - used when backend validation endpoint is available
|
||||
// interface ValidateResponse {
|
||||
// valid: boolean;
|
||||
// errors?: ValidationError[];
|
||||
// message?: string;
|
||||
// }
|
||||
/** Validation response from backend POST /api/v1/validate */
|
||||
interface ValidateResponse {
|
||||
valid: boolean;
|
||||
errors?: ValidationError[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for useFormValidation hook
|
||||
|
|
@ -97,52 +95,26 @@ export function useFormValidation(
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
// FIX: L'endpoint /validate n'existe pas sur le backend
|
||||
// Désactiver temporairement la validation backend jusqu'à ce que l'endpoint soit implémenté
|
||||
// TODO: Implémenter l'endpoint /api/v1/validate sur le backend ou utiliser une validation côté client uniquement
|
||||
// Log seulement en mode debug pour éviter le spam dans la console
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG === 'true') {
|
||||
logger.debug('[useFormValidation] Backend validation endpoint not available, skipping validation', {
|
||||
type,
|
||||
});
|
||||
}
|
||||
// Retourner true pour ne pas bloquer le formulaire
|
||||
setErrors([]);
|
||||
setIsValid(true);
|
||||
return true;
|
||||
|
||||
/* DISABLED: Backend validation endpoint doesn't exist
|
||||
const response = await apiClient.post<ValidateResponse>(
|
||||
'/validate', // FIX: Remove /api/v1 prefix as apiClient already has baseURL
|
||||
{
|
||||
type,
|
||||
data,
|
||||
},
|
||||
const response = await apiClient.post<{ data?: ValidateResponse } & ValidateResponse>(
|
||||
'/validate',
|
||||
{ type, data: _data },
|
||||
);
|
||||
|
||||
// Only update state if this is still the latest validation
|
||||
if (validationId !== validationIdRef.current) {
|
||||
return false; // Validation was superseded
|
||||
return false;
|
||||
}
|
||||
|
||||
const validationResult = response.data;
|
||||
const raw = response.data as { data?: ValidateResponse } & ValidateResponse;
|
||||
const result = raw?.data ?? raw;
|
||||
|
||||
// Handle both wrapped and direct response formats
|
||||
const result =
|
||||
typeof validationResult === 'object' && 'data' in validationResult
|
||||
? (validationResult as { data: ValidateResponse }).data
|
||||
: validationResult;
|
||||
|
||||
if (result.valid) {
|
||||
if (result?.valid) {
|
||||
setErrors([]);
|
||||
setIsValid(true);
|
||||
return true;
|
||||
} else {
|
||||
setErrors(result.errors || []);
|
||||
setIsValid(false);
|
||||
return false;
|
||||
}
|
||||
*/
|
||||
setErrors(result?.errors ?? []);
|
||||
setIsValid(false);
|
||||
return false;
|
||||
} catch (err) {
|
||||
// Only update state if this is still the latest validation
|
||||
if (validationId !== validationIdRef.current) {
|
||||
|
|
|
|||
187
apps/web/src/mocks/handlers-admin.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* MSW handlers for admin/audit/dashboard endpoints
|
||||
*/
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
export const handlersAdmin = [
|
||||
http.get('*/api/v1/audit/logs', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
logs: [
|
||||
{
|
||||
id: 'log-1',
|
||||
action: 'user.login',
|
||||
user_id: 'user-1',
|
||||
resource: 'auth',
|
||||
details: { ip: '127.0.0.1' },
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
user: { id: 'user-1', username: 'TestUser' },
|
||||
},
|
||||
{
|
||||
id: 'log-2',
|
||||
action: 'track.create',
|
||||
user_id: 'user-1',
|
||||
resource: 'track',
|
||||
details: { track_id: 'track-1' },
|
||||
timestamp: '2024-01-02T00:00:00Z',
|
||||
user: { id: 'user-1', username: 'TestUser' },
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/audit/stats', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
total_users: 12500,
|
||||
total_revenue: 45000,
|
||||
active_sessions: 1200,
|
||||
pending_reports: 8,
|
||||
trends: { users: 5, revenue: 10, sessions: -2, reports: 0 },
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.post('*/api/v1/logs/frontend', () => {
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/dashboard', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
stats: {
|
||||
tracks_played: 42,
|
||||
messages_sent: 12,
|
||||
favorites: 8,
|
||||
active_friends: 3,
|
||||
period: '30d',
|
||||
},
|
||||
recent_activity: [
|
||||
{
|
||||
id: 'act-1',
|
||||
type: 'track_upload',
|
||||
title: 'Track uploaded',
|
||||
description: 'New track added',
|
||||
timestamp: '2024-01-15T10:00:00Z',
|
||||
},
|
||||
],
|
||||
library_preview: {
|
||||
items: [],
|
||||
total_count: 0,
|
||||
has_more: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/sessions/stats', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
user_id: 'user-1',
|
||||
stats: { total_active: 1, unique_users: 1 },
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/roles', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
items: [
|
||||
{ id: '1', name: 'admin', description: 'Administrator' },
|
||||
{ id: '2', name: 'user', description: 'User' },
|
||||
],
|
||||
pagination: { total: 2, page: 1, limit: 20, total_pages: 1 },
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/roles/:id', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: { id: '1', name: 'admin', description: 'Administrator' },
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/users', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: 'user-1',
|
||||
username: 'StorybookUser',
|
||||
email: 'user@example.com',
|
||||
role: 'admin',
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=1',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
username: 'AnotherUser',
|
||||
email: 'user2@example.com',
|
||||
role: 'user',
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=2',
|
||||
created_at: '2024-01-02T00:00:00Z',
|
||||
status: 'banned',
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total_pages: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/monitoring/metrics', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
cpu: { usage: 15, history: [10, 12, 15, 14, 15] },
|
||||
memory: { usage: 45, total: 16000, history: [40, 42, 45, 44, 45] },
|
||||
active_connections: 120,
|
||||
requests_per_second: 50,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/webhooks', () => {
|
||||
return HttpResponse.json([
|
||||
{
|
||||
id: 'webhook-1',
|
||||
url: 'https://example.com/webhook',
|
||||
events: ['track.created', 'track.updated'],
|
||||
active: true,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
]);
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/api-keys', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
id: 'key-1',
|
||||
name: 'Production API Key',
|
||||
key: 'pk_live_****************************',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
last_used: '2024-01-05T10:30:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
];
|
||||