feat: add Phase 3 Workout UI — auth, profiles, workout viewer, exercise browser

Build the core user-facing experience with admin login (bcrypt + signed
session cookies), profile switcher, workout day viewer with warmups and
exercise cards, and HTMX-powered exercise browser with search/filter.

- AuthService with bcrypt password verification and itsdangerous session tokens
- Auth dependency redirects to /login (303) for unauthenticated requests
- NavContextMiddleware injects admin/profiles/active_profile into all templates
- Profile management (list, switch, edit) with cookie-based active profile
- Workout day viewer shows warmups + exercises + per-user programming targets
- Exercise browser with HTMX filter dropdowns (no page reloads)
- Flash message partial for success/error feedback
- 12 new tests (66 total passing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 11:14:52 -06:00
parent 1f47103480
commit 23754ea239
29 changed files with 1267 additions and 11 deletions

View File

@@ -0,0 +1,56 @@
"""Tests for the auth dependency (require_admin)."""
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
from app.utils.auth import get_current_admin_user, get_active_profile_id
class TestAuthDependency:
"""Tests for the require_admin dependency."""
def test_redirects_when_no_session_cookie(self) -> None:
"""Should redirect to /login (303) when no session cookie is present."""
request = MagicMock()
request.cookies = {}
with pytest.raises(HTTPException) as exc_info:
get_current_admin_user(request=request, session=MagicMock())
assert exc_info.value.status_code == 303
def test_redirects_when_invalid_token(self) -> None:
"""Should redirect to /login (303) when session cookie has invalid token."""
request = MagicMock()
request.cookies = {"session": "invalid-token"}
request.app.state.secret_key = "test-secret"
mock_session = MagicMock()
mock_session.get.return_value = None
with pytest.raises(HTTPException) as exc_info:
get_current_admin_user(request=request, session=mock_session)
assert exc_info.value.status_code == 303
class TestGetActiveProfileId:
"""Tests for the get_active_profile_id dependency."""
def test_returns_profile_id_from_cookie(self) -> None:
"""Should return the integer profile ID from cookie."""
request = MagicMock()
request.cookies = {"active_profile_id": "5"}
assert get_active_profile_id(request) == 5
def test_returns_none_when_no_cookie(self) -> None:
"""Should return None when no active_profile_id cookie is set."""
request = MagicMock()
request.cookies = {}
assert get_active_profile_id(request) is None
def test_returns_none_for_non_numeric(self) -> None:
"""Should return None for non-numeric cookie values."""
request = MagicMock()
request.cookies = {"active_profile_id": "abc"}
assert get_active_profile_id(request) is None

43
tests/test_auth_routes.py Normal file
View File

@@ -0,0 +1,43 @@
"""Tests for login/logout routes."""
from fastapi.testclient import TestClient
class TestLoginPage:
"""Tests for GET /login."""
def test_login_page_returns_200(self, client: TestClient) -> None:
"""GET /login should render the login form."""
response = client.get("/login")
assert response.status_code == 200
assert "Login" in response.text
def test_login_page_has_form(self, client: TestClient) -> None:
"""GET /login should contain a login form with username and password."""
response = client.get("/login")
assert 'name="username"' in response.text
assert 'name="password"' in response.text
class TestLoginPost:
"""Tests for POST /login."""
def test_login_invalid_credentials_shows_error(self, client: TestClient) -> None:
"""POST /login with wrong credentials should show error message."""
response = client.post(
"/login",
data={"username": "wrong", "password": "wrong"},
follow_redirects=False,
)
# Should re-render login page with error or redirect back
assert response.status_code in (200, 303)
class TestLogout:
"""Tests for GET /logout."""
def test_logout_redirects_to_login(self, client: TestClient) -> None:
"""GET /logout should clear session and redirect to login."""
response = client.get("/logout", follow_redirects=False)
assert response.status_code in (303, 307)
assert "/login" in response.headers.get("location", "")

View File

@@ -0,0 +1,93 @@
"""Tests for the AuthService class."""
import bcrypt
from sqlmodel import SQLModel, Session, create_engine
from app.models.user import User
from app.services.auth_service import AuthService
class TestAuthService:
"""Tests for admin authentication logic."""
def _setup(self):
"""Create an in-memory DB with an admin user."""
engine = create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
session = Session(engine)
# Create admin user with known password
pw_hash = bcrypt.hashpw(b"adminpass", bcrypt.gensalt()).decode("utf-8")
admin = User(
username="admin",
password_hash=pw_hash,
display_name="Admin",
is_admin=True,
)
session.add(admin)
session.commit()
service = AuthService(session, secret_key="test-secret-key")
return session, service
def test_authenticate_valid_credentials(self) -> None:
"""authenticate should return the User for valid credentials."""
session, service = self._setup()
user = service.authenticate("admin", "adminpass")
assert user is not None
assert user.username == "admin"
session.close()
def test_authenticate_wrong_password(self) -> None:
"""authenticate should return None for wrong password."""
session, service = self._setup()
user = service.authenticate("admin", "wrongpass")
assert user is None
session.close()
def test_authenticate_unknown_user(self) -> None:
"""authenticate should return None for unknown username."""
session, service = self._setup()
user = service.authenticate("nobody", "anything")
assert user is None
session.close()
def test_authenticate_non_admin(self) -> None:
"""authenticate should return None for non-admin users."""
session, service = self._setup()
pw_hash = bcrypt.hashpw(b"userpass", bcrypt.gensalt()).decode("utf-8")
non_admin = User(
username="regular",
password_hash=pw_hash,
display_name="Regular",
is_admin=False,
)
session.add(non_admin)
session.commit()
user = service.authenticate("regular", "userpass")
assert user is None
session.close()
def test_create_session_token(self) -> None:
"""create_session_token should return a non-empty signed string."""
session, service = self._setup()
token = service.create_session_token(user_id=1)
assert isinstance(token, str)
assert len(token) > 0
session.close()
def test_validate_session_token(self) -> None:
"""validate_session_token should return the user_id from a valid token."""
session, service = self._setup()
token = service.create_session_token(user_id=42)
user_id = service.validate_session_token(token)
assert user_id == 42
session.close()
def test_validate_session_token_invalid(self) -> None:
"""validate_session_token should return None for tampered tokens."""
session, service = self._setup()
user_id = service.validate_session_token("fake-token-value")
assert user_id is None
session.close()

View File

@@ -0,0 +1,24 @@
"""Tests for exercise browser routes."""
from fastapi.testclient import TestClient
class TestExerciseBrowser:
"""Tests for GET /exercises."""
def test_exercise_browser_requires_auth(self, client: TestClient) -> None:
"""GET /exercises should require admin login."""
response = client.get("/exercises", follow_redirects=False)
assert response.status_code in (401, 303)
class TestExerciseSearch:
"""Tests for HTMX exercise search."""
def test_exercise_search_requires_auth(self, client: TestClient) -> None:
"""GET /exercises/search should require admin login."""
response = client.get(
"/exercises/search?workout_day=Push",
follow_redirects=False,
)
assert response.status_code in (401, 303)

View File

@@ -0,0 +1,26 @@
"""Tests for profile management routes."""
from fastapi.testclient import TestClient
class TestProfileSwitcher:
"""Tests for POST /profiles/switch."""
def test_switch_profile_requires_auth(self, client: TestClient) -> None:
"""POST /profiles/switch should require admin login."""
response = client.post(
"/profiles/switch",
data={"profile_id": "1"},
follow_redirects=False,
)
# Should redirect to login or return 401
assert response.status_code in (401, 303)
class TestProfileList:
"""Tests for GET /profiles."""
def test_profiles_page_requires_auth(self, client: TestClient) -> None:
"""GET /profiles should require admin login."""
response = client.get("/profiles", follow_redirects=False)
assert response.status_code in (401, 303)

View File

@@ -0,0 +1,17 @@
"""Tests for workout day viewer routes."""
from fastapi.testclient import TestClient
class TestWorkoutDayViewer:
"""Tests for GET /workouts/<day_name>."""
def test_workout_day_requires_auth(self, client: TestClient) -> None:
"""GET /workouts/push should require admin login."""
response = client.get("/workouts/push", follow_redirects=False)
assert response.status_code in (401, 303)
def test_workout_days_list_requires_auth(self, client: TestClient) -> None:
"""GET /workouts should require admin login."""
response = client.get("/workouts", follow_redirects=False)
assert response.status_code in (401, 303)