927 lines
26 KiB
Markdown
927 lines
26 KiB
Markdown
# User Story: Authentification Utilisateur
|
|
|
|
**ID** : US-001
|
|
**Epic** : Epic 4 - User Authentication & Access Control
|
|
**Status** : Draft
|
|
**Priorité** : P0 - Critique (bloque toutes les autres fonctionnalités)
|
|
|
|
---
|
|
|
|
## 📋 Description
|
|
|
|
En tant qu'utilisateur, je veux créer un compte sur Chartbastan pour accéder au dashboard et consulter les prédictions de matchs.
|
|
|
|
## 🎯 Objectifs d'Utilisateur
|
|
|
|
1. Créer un compte avec email et mot de passe
|
|
2. Me connecter à mon compte
|
|
3. Rester connecté entre les sessions
|
|
4. Accéder au dashboard personnalisé
|
|
|
|
## ✅ Critères de Succès
|
|
|
|
### Scénario 1 : Inscription
|
|
|
|
**Given** : Je suis sur la page d'inscription `http://localhost:3000/register`
|
|
**When** : Je remplis le formulaire avec des données valides
|
|
**Then** :
|
|
- ✅ Mon compte est créé dans la base de données
|
|
- ✅ Un email de confirmation est envoyé (optionnel)
|
|
- ✅ Je suis automatiquement connecté et redirigé vers le dashboard
|
|
- ✅ Mes préférences sont initialisées (langue, notifications)
|
|
|
|
**Validation** :
|
|
- Email : Format valide, unique dans la base de données
|
|
- Mot de passe : Minimum 8 caractères, 1 majuscule, 1 chiffre, 1 caractère spécial
|
|
- Confirmation mot de passe : Doit correspondre
|
|
|
|
### Scénario 2 : Connexion
|
|
|
|
**Given** : Je suis un utilisateur enregistré
|
|
**When** : Je vais sur la page de connexion `http://localhost:3000/login`
|
|
**Then** :
|
|
- ✅ Je peux me connecter avec email et mot de passe
|
|
- ✅ Je suis redirigé vers le dashboard
|
|
- ✅ Ma session persiste (je reste connecté après fermer le navigateur)
|
|
- ✅ J'accède à mes prédictions personnalisées
|
|
|
|
**Validation** :
|
|
- Identifiants corrects
|
|
- Session créée avec token JWT
|
|
- Cookies sécurisés (HttpOnly, Secure, SameSite)
|
|
|
|
### Scénario 3 : Déconnexion
|
|
|
|
**Given** : Je suis connecté au dashboard
|
|
**When** : Je clique sur "Déconnexion"
|
|
**Then** :
|
|
- ✅ Ma session est terminée
|
|
- ✅ Je suis redirigé vers la page de connexion
|
|
- ✅ Toutes mes données sensibles sont effacées du navigateur
|
|
|
|
### Scénario 4 : Réinitialisation Mot de Passe
|
|
|
|
**Given** : J'ai oublié mon mot de passe
|
|
**When** : Je clique sur "Mot de passe oublié ?"
|
|
**Then** :
|
|
- ✅ Je peux demander une réinitialisation par email
|
|
- ✅ Un email avec lien de réinitialisation est envoyé
|
|
- ✅ Je peux définir un nouveau mot de passe via le lien
|
|
|
|
---
|
|
|
|
## 📱 Composants Frontend Requis
|
|
|
|
### Page d'Inscription (`src/app/(auth)/register/page.tsx`)
|
|
|
|
```tsx
|
|
// Interface du formulaire
|
|
interface RegisterFormData {
|
|
email: string;
|
|
password: string;
|
|
confirmPassword: string;
|
|
name?: string;
|
|
referralCode?: string;
|
|
}
|
|
|
|
// Validation des inputs
|
|
const registerValidation = {
|
|
email: {
|
|
required: "L'email est requis",
|
|
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
message: "Format d'email invalide"
|
|
},
|
|
password: {
|
|
required: "Le mot de passe est requis",
|
|
minLength: {
|
|
value: 8,
|
|
message: "Le mot de passe doit contenir au moins 8 caractères"
|
|
},
|
|
pattern: {
|
|
value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&])/,
|
|
message: "Le mot de passe doit contenir 1 majuscule, 1 minuscule et 1 chiffre"
|
|
}
|
|
},
|
|
confirmPassword: {
|
|
required: "La confirmation est requise",
|
|
validate: (value: string) => value === watch('password'),
|
|
message: "Les mots de passe ne correspondent pas"
|
|
}
|
|
};
|
|
|
|
export default function RegisterPage() {
|
|
const handleSubmit = async (formData: RegisterFormData) => {
|
|
// Call API backend
|
|
const response = await fetch('/api/v1/auth/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(formData)
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
// Success - Store session and redirect
|
|
window.location.href = '/dashboard';
|
|
} else {
|
|
// Error - Display error message
|
|
alert(data.error.message || "Erreur lors de l'inscription");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
|
<Card className="w-full max-w-md p-8">
|
|
<CardHeader>
|
|
<CardTitle>Créer un compte Chartbastan</CardTitle>
|
|
<CardDescription>
|
|
Rejoignez la communauté des prédictions sportives basées sur l'énergie collective.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{/* Email Input */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">Email</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
placeholder="votre.email@exemple.com"
|
|
required
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Utilisé pour la connexion et la récupération de compte
|
|
</p>
|
|
</div>
|
|
|
|
{/* Password Input */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="password">Mot de passe</Label>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
placeholder="•••••••••"
|
|
required
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Minimum 8 caractères : 1 majuscule, 1 minuscule, 1 chiffre
|
|
</p>
|
|
</div>
|
|
|
|
{/* Confirm Password Input */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="confirmPassword">Confirmer le mot de passe</Label>
|
|
<Input
|
|
id="confirmPassword"
|
|
type="password"
|
|
placeholder="••••••••••"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{/* Name Input (Optional) */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">Nom (optionnel)</Label>
|
|
<Input
|
|
id="name"
|
|
type="text"
|
|
placeholder="Votre prénom"
|
|
/>
|
|
</div>
|
|
|
|
{/* Referral Code Input (Optional) */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="referralCode">Code de parrainage (optionnel)</Label>
|
|
<Input
|
|
id="referralCode"
|
|
type="text"
|
|
placeholder="EX: ABC12345"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Invitez 3 amis pour obtenir 1 mois premium GRATUIT
|
|
</p>
|
|
</div>
|
|
|
|
{/* Submit Button */}
|
|
<Button type="submit" className="w-full">
|
|
Créer mon compte
|
|
</Button>
|
|
|
|
{/* Login Link */}
|
|
<div className="text-center mt-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Déjà inscrit ?{" "}
|
|
<Link href="/login" className="text-primary hover:underline font-medium">
|
|
Connectez-vous
|
|
</Link>
|
|
</p>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Page de Connexion (`src/app/(auth)/login/page.tsx`)
|
|
|
|
```tsx
|
|
// Interface du formulaire
|
|
interface LoginFormData {
|
|
email: string;
|
|
password: string;
|
|
rememberMe: boolean;
|
|
}
|
|
|
|
export default function LoginPage() {
|
|
const handleSubmit = async (formData: LoginFormData) => {
|
|
// Call API backend
|
|
const response = await fetch('/api/v1/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(formData)
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
// Success - Store session and redirect
|
|
window.location.href = '/dashboard';
|
|
} else {
|
|
// Error - Display error message
|
|
alert(data.error.message || "Email ou mot de passe incorrect");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
|
<Card className="w-full max-w-md p-8">
|
|
<CardHeader>
|
|
<CardTitle>Connexion</CardTitle>
|
|
<CardDescription>
|
|
Accédez à votre dashboard et consultez les prédictions de matchs.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{/* Email Input */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">Email</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
placeholder="votre.email@exemple.com"
|
|
required
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
{/* Password Input */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="password">Mot de passe</Label>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
placeholder="••••••••••"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{/* Remember Me Checkbox */}
|
|
<div className="flex items-center space-y-2">
|
|
<input
|
|
id="rememberMe"
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-gray-300"
|
|
/>
|
|
<Label htmlFor="rememberMe" className="text-sm">
|
|
Se souvenir de moi
|
|
</Label>
|
|
</div>
|
|
|
|
{/* Forgot Password Link */}
|
|
<div className="text-center">
|
|
<Link href="/forgot-password" className="text-sm text-primary hover:underline">
|
|
Mot de passe oublié ?
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Submit Button */}
|
|
<Button type="submit" className="w-full">
|
|
Se connecter
|
|
</Button>
|
|
|
|
{/* Register Link */}
|
|
<div className="text-center mt-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Pas encore de compte ?{" "}
|
|
<Link href="/register" className="text-primary hover:underline font-medium">
|
|
Créer un compte
|
|
</Link>
|
|
</p>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Session Management Hook (`src/hooks/useAuth.ts`)
|
|
|
|
```typescript
|
|
import { useState, useEffect } from 'react';
|
|
|
|
export interface User {
|
|
id: number;
|
|
email: string;
|
|
name?: string;
|
|
isPremium: boolean;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface AuthState {
|
|
user: User | null;
|
|
isAuthenticated: boolean;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
export function useAuth() {
|
|
const [state, setState] = useState<AuthState>({
|
|
user: null,
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
error: null
|
|
});
|
|
|
|
// Load session from localStorage on mount
|
|
useEffect(() => {
|
|
const loadSession = () => {
|
|
const session = localStorage.getItem('chartbastan_session');
|
|
if (session) {
|
|
const user = JSON.parse(session);
|
|
setState({
|
|
user,
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
error: null
|
|
});
|
|
}
|
|
};
|
|
|
|
loadSession();
|
|
}, []);
|
|
|
|
const login = async (email: string, password: string, rememberMe: boolean = false) => {
|
|
setState({ isLoading: true, error: null });
|
|
|
|
try {
|
|
const response = await fetch('/api/v1/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
const { user, token } = data;
|
|
|
|
// Store session
|
|
if (rememberMe) {
|
|
localStorage.setItem('chartbastan_session', JSON.stringify(user));
|
|
localStorage.setItem('chartbastan_token', token);
|
|
}
|
|
|
|
setState({
|
|
user,
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
error: null
|
|
});
|
|
|
|
return true;
|
|
} else {
|
|
throw new Error(data.error?.message || 'Échec de la connexion');
|
|
}
|
|
} catch (error) {
|
|
setState({
|
|
user: null,
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
error: error instanceof Error ? error.message : 'Erreur de connexion'
|
|
});
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const logout = async () => {
|
|
setState({ isLoading: true, error: null });
|
|
|
|
try {
|
|
await fetch('/api/v1/auth/logout', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
|
|
// Clear local storage
|
|
localStorage.removeItem('chartbastan_session');
|
|
localStorage.removeItem('chartbastan_token');
|
|
|
|
setState({
|
|
user: null,
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
error: null
|
|
});
|
|
|
|
window.location.href = '/login';
|
|
} catch (error) {
|
|
setState({
|
|
error: error instanceof Error ? error.message : 'Erreur de déconnexion'
|
|
});
|
|
}
|
|
};
|
|
|
|
const register = async (formData: {
|
|
email: string;
|
|
password: string;
|
|
name?: string;
|
|
referralCode?: string;
|
|
}) => {
|
|
setState({ isLoading: true, error: null });
|
|
|
|
try {
|
|
const response = await fetch('/api/v1/auth/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(formData)
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
const { user, token } = data;
|
|
|
|
// Auto-login after registration
|
|
localStorage.setItem('chartbastan_session', JSON.stringify(user));
|
|
localStorage.setItem('chartbastan_token', token);
|
|
|
|
setState({
|
|
user,
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
error: null
|
|
});
|
|
|
|
return true;
|
|
} else {
|
|
throw new Error(data.error?.message || 'Échec de l\'inscription');
|
|
}
|
|
} catch (error) {
|
|
setState({
|
|
user: null,
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
error: error instanceof Error ? error.message : 'Erreur d\'inscription'
|
|
});
|
|
return false;
|
|
}
|
|
};
|
|
|
|
return {
|
|
...state,
|
|
login,
|
|
logout,
|
|
register,
|
|
clearError: () => setState({ error: null })
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🔌 API Backend Requis
|
|
|
|
### Endpoints à Créer dans `backend/app/api/v1/auth.py`
|
|
|
|
```python
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy.orm import Session
|
|
from pydantic import BaseModel, EmailStr, Field
|
|
|
|
from app.database import get_db
|
|
from app.models.user import User
|
|
from passlib.context import CryptContext
|
|
|
|
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
|
|
|
# Configuration hashing
|
|
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
|
|
|
# Schemas
|
|
class RegisterRequest(BaseModel):
|
|
email: EmailStr
|
|
password: str = Field(..., min_length=8, regex=r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)')
|
|
name: str | None = None
|
|
referral_code: str | None = None
|
|
|
|
class LoginRequest(BaseModel):
|
|
email: EmailStr
|
|
password: str
|
|
remember_me: bool = False
|
|
|
|
class UserResponse(BaseModel):
|
|
id: int
|
|
email: str
|
|
name: str | None
|
|
is_premium: bool
|
|
created_at: str
|
|
token: str
|
|
|
|
# Endpoints
|
|
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
|
async def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
|
"""
|
|
Inscription d'un nouvel utilisateur.
|
|
|
|
Validation:
|
|
- Email unique dans la base
|
|
- Mot de passe hashé avec pbkdf2_sha256
|
|
- Création automatique de la session
|
|
"""
|
|
# Vérifier si l'email existe déjà
|
|
existing_user = db.query(User).filter(User.email == request.email).first()
|
|
if existing_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cet email est déjà associé à un compte"
|
|
)
|
|
|
|
# Hasher le mot de passe
|
|
password_hash = pwd_context.hash(request.password)
|
|
|
|
# Créer l'utilisateur
|
|
new_user = User(
|
|
email=request.email,
|
|
password_hash=password_hash,
|
|
name=request.name,
|
|
is_premium=False, # Utilisateur gratuit par défaut
|
|
referral_code=request.referral_code
|
|
)
|
|
|
|
db.add(new_user)
|
|
db.commit()
|
|
db.refresh(new_user)
|
|
|
|
# Générer le token JWT (simulation pour Phase 1)
|
|
# TODO: Intégrer une vraie librairie JWT dans Phase 2
|
|
token = f"token_{new_user.id}_{new_user.email}"
|
|
|
|
return UserResponse(
|
|
id=new_user.id,
|
|
email=new_user.email,
|
|
name=new_user.name,
|
|
is_premium=new_user.is_premium,
|
|
created_at=new_user.created_at.isoformat() if new_user.created_at else "",
|
|
token=token
|
|
)
|
|
|
|
@router.post("/login", response_model=UserResponse)
|
|
async def login(request: LoginRequest, db: Session = Depends(get_db)):
|
|
"""
|
|
Connexion d'un utilisateur.
|
|
|
|
Validation:
|
|
- Vérification du hash du mot de passe
|
|
- Génération d'un nouveau token de session
|
|
"""
|
|
# Rechercher l'utilisateur
|
|
user = db.query(User).filter(User.email == request.email).first()
|
|
|
|
if not user or not user.password_hash:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Email ou mot de passe incorrect"
|
|
)
|
|
|
|
# Vérifier le mot de passe
|
|
if not pwd_context.verify(request.password, user.password_hash):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Email ou mot de passe incorrect"
|
|
)
|
|
|
|
# Générer un nouveau token
|
|
token = f"token_{user.id}_{user.email}"
|
|
|
|
return UserResponse(
|
|
id=user.id,
|
|
email=user.email,
|
|
name=user.name,
|
|
is_premium=user.is_premium,
|
|
created_at=user.created_at.isoformat() if user.created_at else "",
|
|
token=token
|
|
)
|
|
|
|
@router.post("/logout", status_code=status.HTTP_200_OK)
|
|
async def logout(db: Session = Depends(get_db)):
|
|
"""
|
|
Déconnexion d'un utilisateur.
|
|
|
|
En Phase 1, c'est principalement côté client (suppression du localStorage).
|
|
En Phase 2, le backend invalidera le token.
|
|
"""
|
|
# TODO: Invalider le token dans Redis/Blacklist (Phase 2)
|
|
return {"message": "Déconnexion réussie"}
|
|
|
|
@router.post("/forgot-password", status_code=status.HTTP_200_OK)
|
|
async def forgot_password(request: dict, db: Session = Depends(get_db)):
|
|
"""
|
|
Réinitialisation du mot de passe (optionnel).
|
|
|
|
Envoie un email avec un lien de réinitialisation.
|
|
"""
|
|
email = request.get("email")
|
|
|
|
# Vérifier que l'utilisateur existe
|
|
user = db.query(User).filter(User.email == email).first()
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Aucun compte trouvé avec cet email"
|
|
)
|
|
|
|
# TODO: Envoyer un email avec token de réinitialisation (Phase 2)
|
|
# Pour l'instant, on simule l'envoi
|
|
reset_token = f"reset_{user.id}_{user.email[:8]}"
|
|
|
|
return {
|
|
"message": "Si cet email existe, un lien de réinitialisation sera envoyé",
|
|
"note": "Fonctionnalité simulée pour Phase 1"
|
|
}
|
|
|
|
@router.get("/me", response_model=UserResponse)
|
|
async def get_current_user(db: Session = Depends(get_db)):
|
|
"""
|
|
Récupère l'utilisateur connecté.
|
|
|
|
Utilise le token pour identifier l'utilisateur.
|
|
En Phase 2, utilise une dépendance JWT réelle.
|
|
"""
|
|
# TODO: Valider le token JWT (Phase 2)
|
|
# Pour l'instant, on retourne un utilisateur factice ou utilise l'ID
|
|
|
|
# Simulation : retourner le premier utilisateur si aucun token
|
|
user = db.query(User).first()
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Utilisateur non connecté"
|
|
)
|
|
|
|
return UserResponse(
|
|
id=user.id,
|
|
email=user.email,
|
|
name=user.name,
|
|
is_premium=user.is_premium,
|
|
created_at=user.created_at.isoformat() if user.created_at else "",
|
|
token="current_session_token"
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## 📊 Tests Acceptation
|
|
|
|
### Tests Unitaires Frontend
|
|
|
|
```typescript
|
|
// tests/use-auth.test.ts
|
|
import { renderHook, waitFor } from '@testing-library/react';
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
|
|
describe('useAuth Hook', () => {
|
|
it('devrait se charger avec null par défaut', () => {
|
|
const { result } = renderHook(() => useAuth());
|
|
|
|
expect(result.current.user).toBeNull();
|
|
expect(result.current.isAuthenticated).toBe(false);
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
|
|
it('devrait connecter avec succès', async () => {
|
|
const { result, waitForNextUpdate } = renderHook(() => useAuth());
|
|
|
|
global.fetch = jest.fn(() =>
|
|
Promise.resolve({
|
|
ok: true,
|
|
json: async () => ({
|
|
user: { id: 1, email: 'test@test.com', is_premium: false },
|
|
token: 'token_123'
|
|
})
|
|
})
|
|
);
|
|
|
|
await act(async () => {
|
|
const success = await result.current.login('test@test.com', 'password123', false);
|
|
expect(success).toBe(true);
|
|
});
|
|
|
|
await waitForNextUpdate();
|
|
|
|
expect(result.current.user).toEqual({
|
|
id: 1,
|
|
email: 'test@test.com',
|
|
is_premium: false
|
|
});
|
|
expect(result.current.isAuthenticated).toBe(true);
|
|
expect(localStorage.getItem('chartbastan_session')).toBeTruthy();
|
|
});
|
|
|
|
it('devrait gérer les erreurs de connexion', async () => {
|
|
const { result } = renderHook(() => useAuth());
|
|
|
|
global.fetch = jest.fn(() =>
|
|
Promise.resolve({
|
|
ok: false,
|
|
json: async () => ({
|
|
error: { message: 'Email ou mot de passe incorrect' }
|
|
})
|
|
})
|
|
);
|
|
|
|
await act(async () => {
|
|
const success = await result.current.login('test@test.com', 'wrongpass');
|
|
expect(success).toBe(false);
|
|
});
|
|
|
|
expect(result.current.error).toBe('Email ou mot de passe incorrect');
|
|
expect(result.current.isAuthenticated).toBe(false);
|
|
});
|
|
});
|
|
```
|
|
|
|
### Tests d'Intégration Backend
|
|
|
|
```python
|
|
# tests/test_auth.py
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
from app.database import Base, get_db
|
|
from app.models.user import User
|
|
from app.main import app
|
|
|
|
# Setup base de données de test
|
|
TEST_DATABASE_URL = "sqlite:///./test_chartbastan.db"
|
|
engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False})
|
|
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
|
|
def override_get_db():
|
|
try:
|
|
db = TestingSessionLocal()
|
|
yield db
|
|
finally:
|
|
db.close()
|
|
|
|
client = TestClient(app)
|
|
|
|
def test_register_success():
|
|
"""Test l'inscription réussie."""
|
|
response = client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "newuser@test.com",
|
|
"password": "Password123",
|
|
"name": "Test User"
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert data["email"] == "newuser@test.com"
|
|
assert data["name"] == "Test User"
|
|
assert "password" not in data
|
|
assert "token" in data
|
|
|
|
def test_register_duplicate_email():
|
|
"""Test l'inscription avec email déjà existant."""
|
|
# Créer un utilisateur
|
|
client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "duplicate@test.com",
|
|
"password": "Password123"
|
|
}
|
|
)
|
|
|
|
# Essayer de créer le même email
|
|
response = client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "duplicate@test.com",
|
|
"password": "Password456"
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "déjà" in response.json()["detail"].lower()
|
|
|
|
def test_login_success():
|
|
"""Test la connexion réussie."""
|
|
# Créer un utilisateur
|
|
client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "loginuser@test.com",
|
|
"password": "Password123"
|
|
}
|
|
)
|
|
|
|
# Se connecter
|
|
response = client.post(
|
|
"/api/v1/auth/login",
|
|
json={
|
|
"email": "loginuser@test.com",
|
|
"password": "Password123"
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["email"] == "loginuser@test.com"
|
|
assert "token" in data
|
|
|
|
def test_login_wrong_password():
|
|
"""Test la connexion avec mauvais mot de passe."""
|
|
# Créer un utilisateur
|
|
client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "wrongpass@test.com",
|
|
"password": "Password123"
|
|
}
|
|
)
|
|
|
|
# Se connecter avec mauvais mot de passe
|
|
response = client.post(
|
|
"/api/v1/auth/login",
|
|
json={
|
|
"email": "wrongpass@test.com",
|
|
"password": "WrongPassword"
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
assert "incorrect" in response.json()["detail"].lower()
|
|
```
|
|
|
|
---
|
|
|
|
## 📝 Définitions de Succès
|
|
|
|
- [ ] Un utilisateur peut créer un compte avec email et mot de passe
|
|
- [ ] Le mot de passe est hashé de manière sécurisée (pbkdf2_sha256)
|
|
- [ ] Un utilisateur peut se connecter avec ses identifiants
|
|
- [ ] La session persiste après fermeture du navigateur
|
|
- [ ] Un utilisateur peut se déconnecter
|
|
- [ ] Les erreurs de connexion sont clairement affichées
|
|
- [ ] L'inscription valide l'unicité de l'email
|
|
|
|
---
|
|
|
|
## 🔗 Dépendances
|
|
|
|
**Frontend** :
|
|
- `@/components/ui/card`, `label`, `input`, `button`
|
|
- `@/hooks/useAuth` (hook personnalisé)
|
|
|
|
**Backend** :
|
|
- `@/models/user` (modèle utilisateur)
|
|
- `passlib` (hashing pbkdf2_sha256)
|
|
- SQLAlchemy ORM
|
|
|
|
**Tests** :
|
|
- `@testing-library/react` (tests frontend)
|
|
- `pytest` (tests backend)
|
|
|
|
---
|
|
|
|
## 🎯 KPIs de Mesure
|
|
|
|
- **Taux de conversion inscription** : % d'utilisateurs qui s'inscrivent
|
|
- **Taux de réussite connexion** : % de tentatives de connexion réussies
|
|
- **Temps de chargement page** : < 2s pour page inscription/connexion
|
|
- **Temps de réponse API** : < 500ms pour endpoints auth
|
|
- **Taux d'erreurs 401** : < 5% (trop d'erreurs = problème)
|
|
|
|
---
|
|
|
|
**Note** : Cette User Story couvre les fonctionnalités critiques de l'Epic 4. Une fois implémentée, les utilisateurs pourront créer des comptes et accéder à toutes les autres fonctionnalités du système.
|