refactor: make ARNES external-repo based with ticket publish flow
This commit is contained in:
0
features/steps/.gitkeep
Normal file
0
features/steps/.gitkeep
Normal file
@@ -1,198 +0,0 @@
|
||||
from behave import given, when, then
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(self):
|
||||
self.users_db: dict[str, User] = {}
|
||||
self.sessions: dict[str, str] = {}
|
||||
|
||||
def register(self, email: str, password: str, name: str = "") -> dict:
|
||||
if email in self.users_db:
|
||||
raise ValueError("Email already exists")
|
||||
|
||||
self.users_db[email] = User(email=email, password=password, name=name)
|
||||
token = f"token_{email}"
|
||||
self.sessions[token] = email
|
||||
return {"user_id": email, "token": token}
|
||||
|
||||
def login(self, email: str, password: str) -> dict:
|
||||
user = self.users_db.get(email)
|
||||
if not user or user.password != password:
|
||||
raise ValueError("Invalid credentials")
|
||||
|
||||
token = f"token_{email}"
|
||||
self.sessions[token] = email
|
||||
return {"user_id": email, "token": token}
|
||||
|
||||
def logout(self, token: str) -> bool:
|
||||
if token in self.sessions:
|
||||
del self.sessions[token]
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_active_session(self, token: str) -> bool:
|
||||
return token in self.sessions
|
||||
|
||||
|
||||
# Global service instance for tests
|
||||
auth_service = AuthService()
|
||||
|
||||
|
||||
@given('un usuario registrado con email "{email}" y password "{password}"')
|
||||
def step_registered_user(context, email, password):
|
||||
"""Crea usuario de prueba en el sistema."""
|
||||
try:
|
||||
auth_service.register(email, password, name="Test User")
|
||||
except ValueError:
|
||||
pass # Already exists
|
||||
|
||||
|
||||
@given('un usuario no registrado con email "{email}"')
|
||||
def step_unregistered_user(context, email):
|
||||
"""Verifica que el usuario no existe."""
|
||||
if email in auth_service.users_db:
|
||||
del auth_service.users_db[email]
|
||||
|
||||
|
||||
@given('el usuario no tiene sesión activa')
|
||||
def step_no_active_session(context):
|
||||
"""Limpia cualquier sesión activa."""
|
||||
context.token = None
|
||||
|
||||
|
||||
@when('el usuario navega a la página de login')
|
||||
def step_navigate_to_login(context):
|
||||
"""Simula navegación a login."""
|
||||
context.page = "login"
|
||||
|
||||
|
||||
@when('el usuario ingresa su email "{email}"')
|
||||
def step_enter_email(context, email):
|
||||
"""Ingresa email en el formulario."""
|
||||
context.email_input = email
|
||||
|
||||
|
||||
@when('ingresa password "{password}"')
|
||||
def step_enter_password(context, password):
|
||||
"""Ingresa password."""
|
||||
context.password_input = password
|
||||
|
||||
|
||||
@when('el usuario ingresa email "{email}"')
|
||||
def step_ingresa_email(context, email):
|
||||
"""Variante: ingresa email."""
|
||||
context.email_input = email
|
||||
|
||||
|
||||
@when('ingresa password incorrecta "{password}"')
|
||||
def step_ingresa_password_incorrecto(context, password):
|
||||
"""Variante: ingresa password incorrecto."""
|
||||
context.password_input = password
|
||||
|
||||
|
||||
@when('deja el campo de password vacío')
|
||||
def step_password_vacio(context):
|
||||
"""Campo de password vacío."""
|
||||
context.password_input = ""
|
||||
|
||||
|
||||
@when('presiona el botón "Iniciar sesión"')
|
||||
def step_press_login_button(context):
|
||||
"""Intenta hacer login."""
|
||||
try:
|
||||
result = auth_service.login(context.email_input, context.password_input)
|
||||
context.token = result.get("token")
|
||||
context.login_success = True
|
||||
except ValueError as e:
|
||||
context.error_message = str(e)
|
||||
context.login_success = False
|
||||
|
||||
|
||||
@then('el sistema autentica al usuario')
|
||||
def step_authenticate(context):
|
||||
"""Verifica que el usuario fue autenticado."""
|
||||
assert context.login_success, "Login should succeed"
|
||||
assert context.token is not None, "Token should be generated"
|
||||
|
||||
|
||||
@then('redirige a la página del dashboard')
|
||||
def step_redirect_dashboard(context):
|
||||
"""Verifica redirección a dashboard."""
|
||||
assert context.token is not None, "Should have token for authenticated user"
|
||||
|
||||
|
||||
@then('muestra un toast de bienvenida con su nombre')
|
||||
def step_show_welcome_toast(context):
|
||||
"""Verifica toast de bienvenida."""
|
||||
assert context.token is not None, "Should show welcome for authenticated user"
|
||||
|
||||
|
||||
@then('el sistema muestra mensaje de error "{expected_message}"')
|
||||
def step_show_error_message(context, expected_message):
|
||||
"""Verifica mensaje de error específico."""
|
||||
assert not context.login_success, "Login should fail"
|
||||
assert context.error_message == expected_message, f"Expected '{expected_message}', got '{context.error_message}'"
|
||||
|
||||
|
||||
@then('el usuario permanece en la página de login')
|
||||
def step_remains_in_login(context):
|
||||
"""Verifica que permanece en login."""
|
||||
assert context.page == "login" or not context.login_success
|
||||
|
||||
|
||||
@then('el campo de password está vacío')
|
||||
def step_password_empty(context):
|
||||
"""Verifica que password se limpió."""
|
||||
assert context.password_input == ""
|
||||
|
||||
|
||||
@then('el sistema sanitiza el input')
|
||||
def step_sanitize_input(context):
|
||||
"""Verifica sanitización de input malicioso."""
|
||||
# El servicio debe rechazar inyecciones
|
||||
malicious_email = context.email_input if hasattr(context, 'email_input') else ""
|
||||
assert "'" not in malicious_email or "@" in malicious_email
|
||||
|
||||
|
||||
@then('muestra mensaje de error genérico')
|
||||
def step_generic_error(context):
|
||||
"""Verifica mensaje de error genérico (no revelar detalles)."""
|
||||
# Para seguridad, no mostrar si el email existe o no
|
||||
pass
|
||||
|
||||
|
||||
@then('no permite acceso al sistema')
|
||||
def step_no_access(context):
|
||||
"""Verifica que no hay acceso."""
|
||||
assert context.token is None or not context.login_success
|
||||
|
||||
|
||||
@when('el usuario hace clic en "¿Olvidaste tu contraseña?"')
|
||||
def step_click_forgot_password(context):
|
||||
"""Clic en recuperación de password."""
|
||||
context.page = "recover_password"
|
||||
|
||||
|
||||
@then('el sistema muestra formulario de recuperación')
|
||||
def step_show_recovery_form(context):
|
||||
"""Verifica que muestra formulario."""
|
||||
assert context.page == "recover_password"
|
||||
|
||||
|
||||
@then('el sistema envía email de recuperación')
|
||||
def step_send_recovery_email(context):
|
||||
"""Simula envío de email."""
|
||||
context.email_sent = True
|
||||
|
||||
|
||||
@then('muestra mensaje "Revisa tu bandeja de entrada"')
|
||||
def step_show_check_inbox(context):
|
||||
"""Verifica mensaje de email enviado."""
|
||||
assert context.email_sent, "Email should be sent"
|
||||
@@ -1,470 +0,0 @@
|
||||
"""Step definitions para Change Password BDD tests."""
|
||||
from behave import given, when, then
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Literal
|
||||
import re
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""User model for testing."""
|
||||
id: str
|
||||
email: str
|
||||
password_hash: str
|
||||
sessions: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class PasswordValidator:
|
||||
"""Validates password strength requirements."""
|
||||
|
||||
MIN_LENGTH = 8
|
||||
MAX_LENGTH = 128
|
||||
|
||||
@classmethod
|
||||
def validate(cls, password: str) -> tuple[bool, str]:
|
||||
"""Validate password strength. Returns (is_valid, error_message)."""
|
||||
if len(password) < cls.MIN_LENGTH:
|
||||
return False, "La contraseña debe tener al menos 8 caracteres"
|
||||
if len(password) > cls.MAX_LENGTH:
|
||||
return False, "La contraseña debe tener máximo 128 caracteres"
|
||||
if not re.search(r'[A-Z]', password):
|
||||
return False, "La contraseña debe contener al menos una mayúscula"
|
||||
if not re.search(r'[a-z]', password):
|
||||
return False, "La contraseña debe contener al menos una minúscula"
|
||||
if not re.search(r'\d', password):
|
||||
return False, "La contraseña debe contener al menos un número"
|
||||
if not re.search(r'[!@#$%^&*()_+\-=\[\]{}|;:\'\",./<>?\\]', password):
|
||||
return False, "La contraseña debe contener al menos un carácter especial (!@#$%^&*...)"
|
||||
return True, ""
|
||||
|
||||
|
||||
class PasswordService:
|
||||
"""Service for password management."""
|
||||
|
||||
def __init__(self):
|
||||
self.users: dict[str, User] = {}
|
||||
self.password_history: dict[str, list[str]] = {} # user_id -> list of hashed passwords
|
||||
self.rate_limits: dict[str, list[datetime]] = {} # user_id -> list of attempt times
|
||||
self._init_mock_data()
|
||||
|
||||
def _init_mock_data(self):
|
||||
"""Initialize mock data."""
|
||||
self.users = {
|
||||
"user-123": User(
|
||||
id="user-123",
|
||||
email="user@example.com",
|
||||
password_hash="OldPass123!" # In real app: bcrypt hash
|
||||
),
|
||||
"user-456": User(
|
||||
id="user-456",
|
||||
email="other@example.com",
|
||||
password_hash="OtherPass456!"
|
||||
)
|
||||
}
|
||||
|
||||
def _hash_password(self, password: str) -> str:
|
||||
"""Mock bcrypt hash."""
|
||||
return password # In real app: bcrypt.hashpw()
|
||||
|
||||
def _verify_password(self, password: str, hashed: str) -> bool:
|
||||
"""Mock password verification."""
|
||||
return password == hashed # In real app: bcrypt.checkpw()
|
||||
|
||||
def _is_rate_limited(self, user_id: str) -> bool:
|
||||
"""Check if user is rate limited (5 attempts per hour)."""
|
||||
if user_id not in self.rate_limits:
|
||||
return False
|
||||
|
||||
# Clean old attempts
|
||||
one_hour_ago = datetime.now() - timedelta(hours=1)
|
||||
self.rate_limits[user_id] = [
|
||||
t for t in self.rate_limits[user_id] if t > one_hour_ago
|
||||
]
|
||||
|
||||
return len(self.rate_limits[user_id]) >= 5
|
||||
|
||||
def _record_attempt(self, user_id: str):
|
||||
"""Record a password change attempt."""
|
||||
if user_id not in self.rate_limits:
|
||||
self.rate_limits[user_id] = []
|
||||
self.rate_limits[user_id].append(datetime.now())
|
||||
|
||||
def _is_same_as_history(self, user_id: str, new_password: str) -> bool:
|
||||
"""Check if password was used recently."""
|
||||
if user_id not in self.password_history:
|
||||
return False
|
||||
|
||||
# Check last 3 passwords
|
||||
recent_passwords = self.password_history[user_id][-3:]
|
||||
return new_password in recent_passwords
|
||||
|
||||
def change_password(
|
||||
self,
|
||||
user_id: str,
|
||||
current_password: str,
|
||||
new_password: str,
|
||||
confirm_password: str,
|
||||
is_authenticated: bool = True,
|
||||
is_owner: bool = True
|
||||
) -> tuple[bool, int, str | None]:
|
||||
"""
|
||||
Change user password.
|
||||
|
||||
Returns: (success, status_code, error_message)
|
||||
"""
|
||||
# Check authentication
|
||||
if not is_authenticated:
|
||||
return False, 401, "No autorizado"
|
||||
|
||||
# Check authorization
|
||||
if not is_owner:
|
||||
return False, 403, "No tienes permiso para modificar esta cuenta"
|
||||
|
||||
# Check rate limit
|
||||
if self._is_rate_limited(user_id):
|
||||
return False, 429, "Demasiados intentos. Intenta de nuevo en 1 hora"
|
||||
|
||||
# Record attempt
|
||||
self._record_attempt(user_id)
|
||||
|
||||
# Check user exists
|
||||
if user_id not in self.users:
|
||||
return False, 404, "Usuario no encontrado"
|
||||
|
||||
user = self.users[user_id]
|
||||
|
||||
# Validate current password
|
||||
if not current_password:
|
||||
return False, 400, "La contraseña actual es requerida"
|
||||
|
||||
if not self._verify_password(current_password, user.password_hash):
|
||||
return False, 401, "La contraseña actual es incorrecta"
|
||||
|
||||
# Validate passwords match
|
||||
if new_password != confirm_password:
|
||||
return False, 400, "Las contraseñas no coinciden"
|
||||
|
||||
# Validate new password strength
|
||||
is_valid, error = PasswordValidator.validate(new_password)
|
||||
if not is_valid:
|
||||
return False, 400, error
|
||||
|
||||
# Check password history
|
||||
if self._is_same_as_history(user_id, new_password):
|
||||
return False, 400, "La nueva contraseña no puede ser igual a la anterior"
|
||||
|
||||
# Change password
|
||||
self.password_history.setdefault(user_id, []).append(new_password)
|
||||
user.password_hash = self._hash_password(new_password)
|
||||
|
||||
# Invalidate all sessions
|
||||
user.sessions.clear()
|
||||
|
||||
return True, 200, None
|
||||
|
||||
|
||||
# Global service instance
|
||||
password_service = PasswordService()
|
||||
|
||||
|
||||
# ====================
|
||||
# GIVEN STEPS
|
||||
# ====================
|
||||
|
||||
@given('un usuario autenticado con email "{email}"')
|
||||
def step_user_authenticated_email(context, email):
|
||||
"""User authenticated with specific email."""
|
||||
context.is_authenticated = True
|
||||
context.is_owner = True
|
||||
# Find user by email
|
||||
for uid, user in password_service.users.items():
|
||||
if user.email == email:
|
||||
context.user_id = uid
|
||||
context.user_email = email
|
||||
context.current_password = user.password_hash
|
||||
break
|
||||
|
||||
|
||||
@given('un usuario autenticado')
|
||||
def step_user_authenticated(context):
|
||||
"""User authenticated (generic)."""
|
||||
context.is_authenticated = True
|
||||
context.is_owner = True
|
||||
context.user_id = "user-123"
|
||||
|
||||
|
||||
@given('su contraseña actual es "{password}"')
|
||||
def step_current_password(context, password):
|
||||
"""Set current password."""
|
||||
context.current_password_input = password
|
||||
if hasattr(context, 'user_id') and context.user_id in password_service.users:
|
||||
password_service.users[context.user_id].password_hash = password
|
||||
|
||||
|
||||
@given('un usuario no autenticado')
|
||||
def step_user_not_authenticated(context):
|
||||
"""User not authenticated."""
|
||||
context.is_authenticated = False
|
||||
|
||||
|
||||
@given('un usuario con contraseña actual "{password}"')
|
||||
def step_user_with_password(context, password):
|
||||
"""User with specific current password."""
|
||||
context.current_password_input = password
|
||||
|
||||
|
||||
@given('historial de contraseñas incluye "{password}"')
|
||||
def step_password_in_history(context, password):
|
||||
"""Add password to user's history."""
|
||||
if hasattr(context, 'user_id'):
|
||||
password_service.password_history.setdefault(context.user_id, []).append(password)
|
||||
|
||||
|
||||
@given('un usuario con sesión expirada')
|
||||
def step_user_expired_session(context):
|
||||
"""User with expired session."""
|
||||
context.is_authenticated = False
|
||||
context.token_expired = True
|
||||
|
||||
|
||||
@given('un usuario autenticado con ID "{user_id}"')
|
||||
def step_user_authenticated_id(context, user_id):
|
||||
"""User authenticated with specific ID."""
|
||||
context.is_authenticated = True
|
||||
context.is_owner = True
|
||||
context.user_id = user_id
|
||||
|
||||
|
||||
@given('ya realizó {count} intentos fallidos en la última hora')
|
||||
def step_rate_limited_user(context, count):
|
||||
"""User has exceeded rate limit."""
|
||||
context.user_id = "user-123"
|
||||
password_service.rate_limits[context.user_id] = [
|
||||
datetime.now() - timedelta(minutes=i) for i in range(int(count))
|
||||
]
|
||||
|
||||
|
||||
@given('un usuario con contraseña "{password}"')
|
||||
def step_user_with_specific_password(context, password):
|
||||
"""User with specific password."""
|
||||
context.user_id = "user-123"
|
||||
password_service.users[context.user_id].password_hash = password
|
||||
|
||||
|
||||
# ====================
|
||||
# WHEN STEPS
|
||||
# ====================
|
||||
|
||||
@when('el usuario solicita cambiar contraseña')
|
||||
def step_request_change_password(context):
|
||||
"""User requests password change."""
|
||||
context.password_change_requested = True
|
||||
|
||||
|
||||
@when('ingresa contraseña actual "{password}"')
|
||||
def step_enter_current_password(context, password):
|
||||
"""Enter current password."""
|
||||
context.current_password_input = password
|
||||
|
||||
|
||||
@when('intenta cambiar contraseña con actual "{password}"')
|
||||
def step_try_with_current_password(context, password):
|
||||
"""Try to change password with specific current password."""
|
||||
context.current_password_input = password
|
||||
|
||||
|
||||
@when('ingresa nueva contraseña "{password}"')
|
||||
def step_enter_new_password(context, password):
|
||||
"""Enter new password."""
|
||||
context.new_password_input = password
|
||||
|
||||
|
||||
@when('intenta cambiar contraseña a "{password}"')
|
||||
def step_try_change_to_password(context, password):
|
||||
"""Try to change to specific password."""
|
||||
context.new_password_input = password
|
||||
context.confirm_password_input = password
|
||||
|
||||
|
||||
@when('intenta cambiar contraseña a "{prefix}" repetido {count} veces más "{suffix}"')
|
||||
def step_try_change_long_password(context, prefix, count, suffix):
|
||||
"""Try to change to very long password."""
|
||||
long_password = prefix * (int(count) + 1) + suffix
|
||||
context.new_password_input = long_password
|
||||
context.confirm_password_input = long_password
|
||||
|
||||
|
||||
@when('confirma nueva contraseña "{password}"')
|
||||
def step_confirm_password(context, password):
|
||||
"""Confirm new password."""
|
||||
context.confirm_password_input = password
|
||||
|
||||
|
||||
@when('ingresa contraseña actual correcta')
|
||||
def step_enter_correct_current_password(context):
|
||||
"""Enter correct current password."""
|
||||
if hasattr(context, 'current_password'):
|
||||
context.current_password_input = context.current_password
|
||||
|
||||
|
||||
@when('pero confirma con "{password}"')
|
||||
def step_confirm_different_password(context, password):
|
||||
"""Confirm with different password."""
|
||||
context.confirm_password_input = password
|
||||
|
||||
|
||||
@when('luego intenta iniciar sesión con "{password}"')
|
||||
def step_login_with_password(context, password):
|
||||
"""Try to login with new password."""
|
||||
context.login_password = password
|
||||
context.login_user_id = context.user_id
|
||||
|
||||
|
||||
@when('intenta cambiar contraseña')
|
||||
def step_try_change_password(context):
|
||||
"""Try to change password (generic)."""
|
||||
pass # Will be handled in then step
|
||||
|
||||
|
||||
@when('intenta cambiar contraseña del usuario "{target_user_id}"')
|
||||
def step_try_change_other_user_password(context, target_user_id):
|
||||
"""Try to change another user's password."""
|
||||
context.user_id = target_user_id
|
||||
context.is_owner = False
|
||||
|
||||
|
||||
@when('intenta cambiar contraseña una vez más')
|
||||
def step_try_one_more_time(context):
|
||||
"""Try one more time (rate limited)."""
|
||||
pass
|
||||
|
||||
|
||||
# ====================
|
||||
# THEN STEPS
|
||||
# ====================
|
||||
|
||||
@then('el sistema valida la contraseña actual correctamente')
|
||||
def step_validate_current_password(context):
|
||||
"""Validate current password correctly."""
|
||||
if hasattr(context, 'new_password_input'):
|
||||
success, status, error = password_service.change_password(
|
||||
context.user_id,
|
||||
context.current_password_input or "",
|
||||
context.new_password_input,
|
||||
context.confirm_password_input or context.new_password_input,
|
||||
is_authenticated=context.is_authenticated,
|
||||
is_owner=context.is_owner
|
||||
)
|
||||
context.password_change_success = success
|
||||
context.response_status = status
|
||||
context.response_error = error
|
||||
|
||||
|
||||
@then('guarda la nueva contraseña hasheada')
|
||||
def step_save_hashed_password(context):
|
||||
"""Save new hashed password."""
|
||||
if hasattr(context, 'user_id'):
|
||||
user = password_service.users.get(context.user_id)
|
||||
if user:
|
||||
# Password was changed, verify it's different
|
||||
assert user.password_hash != context.current_password_input
|
||||
|
||||
|
||||
@then('invalida todas las sesiones existentes')
|
||||
def step_invalidate_sessions(context):
|
||||
"""Invalidate all user sessions."""
|
||||
if hasattr(context, 'user_id'):
|
||||
user = password_service.users.get(context.user_id)
|
||||
if user:
|
||||
assert len(user.sessions) == 0, "Sessions should be cleared"
|
||||
|
||||
|
||||
@then('muestra mensaje de confirmación "{message}"')
|
||||
def step_show_confirmation_message(context, message):
|
||||
"""Show confirmation message."""
|
||||
assert context.password_change_success, f"Password change should succeed"
|
||||
assert context.response_status == 200
|
||||
|
||||
|
||||
@then('el sistema acepta la contraseña')
|
||||
def step_accept_password(context):
|
||||
"""System accepts the password."""
|
||||
if not hasattr(context, 'new_password_input'):
|
||||
return
|
||||
|
||||
is_valid, error = PasswordValidator.validate(context.new_password_input)
|
||||
assert is_valid, f"Password should be valid but got: {error}"
|
||||
|
||||
|
||||
@then('la guarda correctamente')
|
||||
def step_save_correctly(context):
|
||||
"""Save password correctly."""
|
||||
pass # Handled by previous steps
|
||||
|
||||
|
||||
@then('el sistema muestra error "{error_message}"')
|
||||
def step_show_error(context, error_message):
|
||||
"""Show specific error message."""
|
||||
# Execute the change to get the error
|
||||
if hasattr(context, 'new_password_input'):
|
||||
success, status, error = password_service.change_password(
|
||||
context.user_id,
|
||||
context.current_password_input or "",
|
||||
context.new_password_input,
|
||||
context.confirm_password_input or context.new_password_input,
|
||||
is_authenticated=context.is_authenticated,
|
||||
is_owner=context.is_owner
|
||||
)
|
||||
context.password_change_success = success
|
||||
context.response_status = status
|
||||
context.response_error = error
|
||||
|
||||
if context.response_error:
|
||||
# Check if error contains expected message
|
||||
assert error_message in context.response_error or context.response_status >= 400
|
||||
|
||||
|
||||
@then('la contraseña no es cambiada')
|
||||
def step_password_not_changed(context):
|
||||
"""Password is not changed."""
|
||||
# Verify password wasn't changed
|
||||
if hasattr(context, 'user_id'):
|
||||
user = password_service.users.get(context.user_id)
|
||||
if user and hasattr(context, 'current_password_input'):
|
||||
# Password should remain unchanged
|
||||
pass
|
||||
|
||||
|
||||
@then('no se invalidan sesiones')
|
||||
def step_sessions_not_invalidated(context):
|
||||
"""Sessions are not invalidated."""
|
||||
pass # If change failed, sessions should remain
|
||||
|
||||
|
||||
@then('el sistema retorna error {status_code} "{error_message}"')
|
||||
def step_return_http_error(context, status_code, error_message):
|
||||
"""Return HTTP error with specific status code."""
|
||||
# Execute the change
|
||||
success, status, error = password_service.change_password(
|
||||
context.user_id,
|
||||
context.current_password_input or "",
|
||||
context.new_password_input or "TestPass123!",
|
||||
context.confirm_password_input or "TestPass123!",
|
||||
is_authenticated=context.is_authenticated,
|
||||
is_owner=context.is_owner
|
||||
)
|
||||
context.password_change_success = success
|
||||
context.response_status = status
|
||||
context.response_error = error
|
||||
|
||||
assert status == int(status_code), f"Expected {status_code}, got {status}"
|
||||
|
||||
|
||||
@then('el login es exitoso')
|
||||
def step_login_successful(context):
|
||||
"""Login is successful with new password."""
|
||||
if hasattr(context, 'user_id') and hasattr(context, 'login_password'):
|
||||
user = password_service.users.get(context.user_id)
|
||||
if user:
|
||||
assert user.password_hash == context.login_password, "New password should work"
|
||||
@@ -1,431 +0,0 @@
|
||||
"""Step definitions para User Profile BDD tests."""
|
||||
from behave import given, when, then
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserProfile:
|
||||
"""Modelo de perfil de usuario."""
|
||||
id: str
|
||||
name: str
|
||||
avatar_url: str
|
||||
language: str
|
||||
created_at: datetime = datetime.now()
|
||||
updated_at: datetime = datetime.now()
|
||||
|
||||
|
||||
class ProfileService:
|
||||
"""Servicio mock para tests BDD."""
|
||||
|
||||
def __init__(self):
|
||||
self.profiles: dict[str, UserProfile] = {}
|
||||
self._init_mock_data()
|
||||
|
||||
def _init_mock_data(self):
|
||||
"""Datos mock para testing."""
|
||||
self.profiles = {
|
||||
"user-123": UserProfile(
|
||||
id="user-123",
|
||||
name="Juan Pérez",
|
||||
avatar_url="https://cdn.example.com/avatar-123.jpg",
|
||||
language="es"
|
||||
),
|
||||
"user-456": UserProfile(
|
||||
id="user-456",
|
||||
name="María García",
|
||||
avatar_url="https://cdn.example.com/avatar-456.jpg",
|
||||
language="en"
|
||||
)
|
||||
}
|
||||
|
||||
def get_profile(self, user_id: str, authenticated: bool = True, is_owner: bool = True) -> tuple[UserProfile | None, int, str | None]:
|
||||
"""Obtiene perfil de usuario."""
|
||||
if not authenticated:
|
||||
return None, 401, "No autorizado"
|
||||
|
||||
if user_id not in self.profiles:
|
||||
return None, 404, "Usuario no encontrado"
|
||||
|
||||
return self.profiles[user_id], 200, None
|
||||
|
||||
def update_profile(self, user_id: str, name: str | None = None,
|
||||
avatar_url: str | None = None, language: str | None = None,
|
||||
authenticated: bool = True, is_owner: bool = True) -> tuple[UserProfile | None, int, str | None]:
|
||||
"""Actualiza perfil de usuario."""
|
||||
if not authenticated:
|
||||
return None, 401, "No autorizado"
|
||||
|
||||
if not is_owner:
|
||||
return None, 403, "No tienes permiso para editar este perfil"
|
||||
|
||||
if user_id not in self.profiles:
|
||||
return None, 404, "Usuario no encontrado"
|
||||
|
||||
profile = self.profiles[user_id]
|
||||
|
||||
# Validaciones
|
||||
if name is not None:
|
||||
if len(name) < 2:
|
||||
return None, 400, "Nombre debe tener al menos 2 caracteres"
|
||||
if len(name) > 50:
|
||||
return None, 400, "Nombre debe tener máximo 50 caracteres"
|
||||
if not re.match(r'^[a-zA-ZáéíóúñÑ\s]+$', name):
|
||||
return None, 400, "Nombre inválido: solo letras y espacios"
|
||||
profile.name = name
|
||||
|
||||
if avatar_url is not None:
|
||||
if not avatar_url.startswith(('http://', 'https://')):
|
||||
return None, 400, "Solo se permiten URLs http o https"
|
||||
if not self._is_valid_url(avatar_url):
|
||||
return None, 400, "URL de avatar inválida"
|
||||
profile.avatar_url = avatar_url
|
||||
|
||||
if language is not None:
|
||||
valid_languages = ['en', 'es', 'fr', 'de']
|
||||
if language not in valid_languages:
|
||||
return None, 400, "Idioma no soportado"
|
||||
profile.language = language
|
||||
|
||||
profile.updated_at = datetime.now()
|
||||
return profile, 200, None
|
||||
|
||||
def _is_valid_url(self, url: str) -> bool:
|
||||
"""Valida formato de URL."""
|
||||
pattern = r'^https?://[\w\-\.]+\.[a-zA-Z]{2,}(\/[\w\-\./]*)?$'
|
||||
return bool(re.match(pattern, url))
|
||||
|
||||
|
||||
# Global service instance
|
||||
profile_service = ProfileService()
|
||||
|
||||
|
||||
# ====================
|
||||
# GIVEN STEPS
|
||||
# ====================
|
||||
|
||||
@given('un usuario autenticado con ID "{user_id}" y nombre "{name}"')
|
||||
def step_user_authenticated_with_name(context, user_id, name):
|
||||
"""Usuario autenticado con nombre específico."""
|
||||
context.user_id = user_id
|
||||
context.auth_token = f"token_{user_id}"
|
||||
context.is_authenticated = True
|
||||
context.is_owner = True
|
||||
# Ensure user exists in mock
|
||||
if user_id not in profile_service.profiles:
|
||||
profile_service.profiles[user_id] = UserProfile(id=user_id, name=name, avatar_url="", language="en")
|
||||
|
||||
|
||||
@given('un usuario autenticado con ID "{user_id}"')
|
||||
def step_user_authenticated(context, user_id):
|
||||
"""Usuario autenticado genérico."""
|
||||
context.user_id = user_id
|
||||
context.auth_token = f"token_{user_id}"
|
||||
context.is_authenticated = True
|
||||
context.is_owner = True
|
||||
|
||||
|
||||
@given('un usuario autenticado')
|
||||
def step_user_authenticated_generic(context):
|
||||
"""Usuario autenticado sin ID específico."""
|
||||
context.is_authenticated = True
|
||||
context.is_owner = True
|
||||
context.user_id = "user-123"
|
||||
|
||||
|
||||
@given('el usuario tiene avatar "{avatar_url}"')
|
||||
def step_user_has_avatar(context, avatar_url):
|
||||
"""Usuario tiene avatar específico."""
|
||||
context.expected_avatar = avatar_url
|
||||
if context.user_id in profile_service.profiles:
|
||||
profile_service.profiles[context.user_id].avatar_url = avatar_url
|
||||
|
||||
|
||||
@given('el idioma configurado es "{language}"')
|
||||
def step_user_language(context, language):
|
||||
"""Idioma del usuario."""
|
||||
context.expected_language = language
|
||||
if context.user_id in profile_service.profiles:
|
||||
profile_service.profiles[context.user_id].language = language
|
||||
|
||||
|
||||
@given('el perfil tiene nombre "{name}"')
|
||||
def step_profile_has_name(context, name):
|
||||
"""El perfil tiene un nombre específico."""
|
||||
if context.user_id in profile_service.profiles:
|
||||
profile_service.profiles[context.user_id].name = name
|
||||
|
||||
|
||||
@given('un usuario no autenticado')
|
||||
def step_user_not_authenticated(context):
|
||||
"""Usuario sin autenticación."""
|
||||
context.is_authenticated = False
|
||||
|
||||
|
||||
@given('un usuario con idioma "{language}"')
|
||||
def step_user_with_language(context, language):
|
||||
"""Usuario con idioma específico."""
|
||||
if context.user_id in profile_service.profiles:
|
||||
profile_service.profiles[context.user_id].language = language
|
||||
|
||||
|
||||
@given('un usuario con token expirado')
|
||||
def step_user_expired_token(context):
|
||||
"""Usuario con token expirado."""
|
||||
context.is_authenticated = False
|
||||
context.token_expired = True
|
||||
|
||||
|
||||
# ====================
|
||||
# WHEN STEPS
|
||||
# ====================
|
||||
|
||||
@when('el usuario solicita ver su perfil')
|
||||
def step_request_profile(context):
|
||||
"""Solicita ver el perfil."""
|
||||
profile, status, error = profile_service.get_profile(
|
||||
context.user_id,
|
||||
authenticated=context.is_authenticated,
|
||||
is_owner=context.is_owner
|
||||
)
|
||||
context.response_status = status
|
||||
context.response_error = error
|
||||
context.profile = profile
|
||||
|
||||
|
||||
@when('el usuario actualiza su nombre a "{new_name}"')
|
||||
def step_update_name(context, new_name):
|
||||
"""Actualiza el nombre del perfil."""
|
||||
profile, status, error = profile_service.update_profile(
|
||||
context.user_id,
|
||||
name=new_name,
|
||||
authenticated=context.is_authenticated,
|
||||
is_owner=context.is_owner
|
||||
)
|
||||
context.response_status = status
|
||||
context.response_error = error
|
||||
context.profile = profile
|
||||
|
||||
|
||||
@when('el usuario intenta cambiar nombre a "{name}"')
|
||||
def step_try_update_name(context, name):
|
||||
"""Intenta cambiar nombre (puede fallar)."""
|
||||
step_update_name(context, name)
|
||||
|
||||
|
||||
@when('cambia su nombre a "{name}"')
|
||||
def step_change_name(context, name):
|
||||
"""Cambia nombre (contexto genérico)."""
|
||||
step_update_name(context, name)
|
||||
|
||||
|
||||
@when('intenta cambiar nombre a "{name}" repetido {times} veces')
|
||||
def step_update_long_name(context, name, times):
|
||||
"""Nombre muy largo."""
|
||||
long_name = name * (int(times) + 1)
|
||||
step_update_name(context, long_name)
|
||||
|
||||
|
||||
@when('el usuario sube un nuevo avatar "{avatar_url}"')
|
||||
def step_update_avatar(context, avatar_url):
|
||||
"""Sube nuevo avatar."""
|
||||
profile, status, error = profile_service.update_profile(
|
||||
context.user_id,
|
||||
avatar_url=avatar_url,
|
||||
authenticated=context.is_authenticated,
|
||||
is_owner=context.is_owner
|
||||
)
|
||||
context.response_status = status
|
||||
context.response_error = error
|
||||
context.profile = profile
|
||||
|
||||
|
||||
@when('intenta cambiar avatar a "{avatar_url}"')
|
||||
def step_try_update_avatar(context, avatar_url):
|
||||
"""Intenta cambiar avatar."""
|
||||
step_update_avatar(context, avatar_url)
|
||||
|
||||
|
||||
@when('el usuario cambia idioma a "{language}"')
|
||||
def step_change_language(context, language):
|
||||
"""Cambia el idioma."""
|
||||
profile, status, error = profile_service.update_profile(
|
||||
context.user_id,
|
||||
language=language,
|
||||
authenticated=context.is_authenticated,
|
||||
is_owner=context.is_owner
|
||||
)
|
||||
context.response_status = status
|
||||
context.response_error = error
|
||||
context.profile = profile
|
||||
|
||||
|
||||
@when('cambia idioma a "{language}"')
|
||||
def step_change_lang(context, language):
|
||||
"""Alias para cambiar idioma."""
|
||||
step_change_language(context, language)
|
||||
|
||||
|
||||
@when('intenta cambiar idioma a "{language}"')
|
||||
def step_try_change_language(context, language):
|
||||
"""Intenta cambiar idioma."""
|
||||
step_change_language(context, language)
|
||||
|
||||
|
||||
@when('el usuario solo actualiza nombre a "{new_name}"')
|
||||
def step_update_only_name(context, new_name):
|
||||
"""Actualiza solo el nombre."""
|
||||
step_update_name(context, new_name)
|
||||
|
||||
|
||||
@when('envía actualización con nombre "{name}", avatar "{avatar}", idioma "{language}"')
|
||||
def step_update_multiple_fields(context, name, avatar, language):
|
||||
"""Actualiza múltiples campos."""
|
||||
profile, status, error = profile_service.update_profile(
|
||||
context.user_id,
|
||||
name=name,
|
||||
avatar_url=avatar,
|
||||
language=language,
|
||||
authenticated=context.is_authenticated,
|
||||
is_owner=context.is_owner
|
||||
)
|
||||
context.response_status = status
|
||||
context.response_error = error
|
||||
context.profile = profile
|
||||
|
||||
|
||||
@when('intenta actualizar perfil de usuario "{target_user_id}"')
|
||||
def step_try_update_other_user(context, target_user_id):
|
||||
"""Intenta editar perfil de otro usuario."""
|
||||
context.user_id = target_user_id
|
||||
context.is_owner = False
|
||||
step_request_profile(context)
|
||||
|
||||
|
||||
@when('intenta actualizar su perfil')
|
||||
def step_try_update_own_profile(context):
|
||||
"""Intenta actualizar su propio perfil."""
|
||||
profile, status, error = profile_service.update_profile(
|
||||
context.user_id,
|
||||
authenticated=context.is_authenticated,
|
||||
is_owner=context.is_owner
|
||||
)
|
||||
context.response_status = status
|
||||
context.response_error = error
|
||||
|
||||
|
||||
# ====================
|
||||
# THEN STEPS
|
||||
# ====================
|
||||
|
||||
@then('el sistema retorna los datos completos del perfil')
|
||||
def step_return_profile_data(context):
|
||||
"""Verifica que retorna datos del perfil."""
|
||||
assert context.profile is not None, "Profile should not be None"
|
||||
assert context.response_status == 200, f"Expected 200, got {context.response_status}"
|
||||
|
||||
|
||||
@then('incluye id "{expected_id}", nombre "{expected_name}"')
|
||||
def step_profile_contains_id_name(context, expected_id, expected_name):
|
||||
"""Verifica ID y nombre en respuesta."""
|
||||
assert context.profile.id == expected_id, f"Expected id {expected_id}, got {context.profile.id}"
|
||||
assert context.profile.name == expected_name, f"Expected name {expected_name}, got {context.profile.name}"
|
||||
|
||||
|
||||
@then('incluye avatar_url y language "{expected_lang}"')
|
||||
def step_profile_contains_avatar_lang(context, expected_lang):
|
||||
"""Verifica avatar y lenguaje."""
|
||||
assert context.profile.avatar_url, "Avatar URL should be present"
|
||||
assert context.profile.language == expected_lang, f"Expected language {expected_lang}"
|
||||
|
||||
|
||||
@then('el sistema retorna error {status_code} "{error_message}"')
|
||||
def step_return_error(context, status_code, error_message):
|
||||
"""Verifica error específico."""
|
||||
status_code = int(status_code)
|
||||
assert context.response_status == status_code, f"Expected {status_code}, got {context.response_status}"
|
||||
assert context.response_error == error_message, f"Expected '{error_message}', got '{context.response_error}'
|
||||
|
||||
|
||||
@then('el perfil muestra nombre "{expected_name}"')
|
||||
def step_profile_shows_name(context, expected_name):
|
||||
"""Verifica nombre en perfil."""
|
||||
assert context.profile.name == expected_name, f"Expected name {expected_name}, got {context.profile.name}"
|
||||
|
||||
|
||||
@then('la fecha de updated_at se actualiza')
|
||||
def step_updated_at_changed(context):
|
||||
"""Verifica que updated_at cambió (simplificado para test)."""
|
||||
# En test real verificaríamos timestamp diferente
|
||||
assert context.profile is not None
|
||||
|
||||
|
||||
@then('el sistema acepta el cambio')
|
||||
def step_accept_change(context):
|
||||
"""Verifica que el cambio fue aceptado."""
|
||||
assert context.response_status == 200, f"Expected 200, got {context.response_status}"
|
||||
|
||||
|
||||
@then('el nombre se guarda como "{expected_name}"')
|
||||
def step_name_saved(context, expected_name):
|
||||
"""Verifica nombre guardado."""
|
||||
assert context.profile.name == expected_name
|
||||
|
||||
|
||||
@then('el sistema muestra error de validación "{error_message}"')
|
||||
def step_validation_error(context, error_message):
|
||||
"""Verifica error de validación."""
|
||||
assert context.response_status == 400, f"Expected 400, got {context.response_status}"
|
||||
assert context.response_error == error_message or "Nombre inválido" in context.response_error
|
||||
|
||||
|
||||
@then('el nombre permanece sin cambios')
|
||||
def step_name_unchanged(context):
|
||||
"""Verifica que el nombre no cambió."""
|
||||
# En tests reales compararíamos con valor original
|
||||
assert context.profile is not None or context.response_status == 400
|
||||
|
||||
|
||||
@then('el sistema muestra error "{error_message}"')
|
||||
def step_show_error(context, error_message):
|
||||
"""Verifica mensaje de error genérico."""
|
||||
# Acepta cualquier mensaje de error que contenga el texto esperado
|
||||
assert context.response_error is not None or context.response_status >= 400
|
||||
|
||||
|
||||
@then('el perfil muestra avatar_url "{expected_url}"')
|
||||
def step_avatar_updated(context, expected_url):
|
||||
"""Verifica nuevo avatar."""
|
||||
assert context.profile.avatar_url == expected_url
|
||||
|
||||
|
||||
@then('el avatar_url permanece "{expected_url}"')
|
||||
def step_avatar_unchanged(context, expected_url):
|
||||
"""Verifica que avatar no cambió."""
|
||||
assert context.profile.avatar_url == expected_url
|
||||
|
||||
|
||||
@then('el idioma se guarda como "{expected_lang}"')
|
||||
def step_language_saved(context, expected_lang):
|
||||
"""Verifica idioma guardado."""
|
||||
assert context.profile.language == expected_lang
|
||||
|
||||
|
||||
@then('el sistema confirma el cambio')
|
||||
def step_confirm_change(context):
|
||||
"""Confirma que el cambio fue exitoso."""
|
||||
assert context.response_status == 200
|
||||
|
||||
|
||||
@then('todos los campos se actualizan correctamente')
|
||||
def step_all_fields_updated(context):
|
||||
"""Verifica actualización múltiple."""
|
||||
assert context.response_status == 200
|
||||
assert context.profile is not None
|
||||
|
||||
|
||||
@then('el perfil refleja todos los cambios')
|
||||
def step_profile_reflects_changes(context):
|
||||
"""Verifica que todos los cambios están en el perfil."""
|
||||
assert context.profile is not None
|
||||
Reference in New Issue
Block a user