Add has_starter_deck to user profile API response

The frontend routing guard checks has_starter_deck to decide whether to
redirect users to starter selection. The field was missing from the API
response, causing authenticated users with a starter deck to be
incorrectly redirected to /starter on page refresh.

- Add has_starter_deck computed property to User model
- Add has_starter_deck field to UserResponse schema
- Add unit tests for User model properties
- Add API tests for has_starter_deck in profile response

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-01-30 23:14:04 -06:00
parent cd3efcb528
commit ca3aca2b38
6 changed files with 211 additions and 1 deletions

View File

@ -154,5 +154,14 @@ class User(Base):
"""
return 999 if self.has_active_premium else 5
@property
def has_starter_deck(self) -> bool:
"""Check if user has selected a starter deck.
Returns:
True if any of the user's decks is marked as a starter deck.
"""
return any(deck.is_starter for deck in self.decks)
def __repr__(self) -> str:
return f"<User(id={self.id!r}, email={self.email!r})>"

View File

@ -38,6 +38,9 @@ class UserResponse(BaseModel):
avatar_url: str | None = Field(default=None, description="Avatar image URL")
is_premium: bool = Field(default=False, description="Premium subscription status")
premium_until: datetime | None = Field(default=None, description="Premium expiration date")
has_starter_deck: bool = Field(
default=False, description="Whether user has selected a starter deck"
)
created_at: datetime = Field(..., description="Account creation date")
model_config = {"from_attributes": True}

View File

@ -60,6 +60,7 @@ def test_user():
user.updated_at = datetime.now(UTC)
user.last_login = None
user.linked_accounts = []
user.decks = [] # No decks by default (no starter selected)
return user

View File

@ -39,7 +39,7 @@ class TestGetCurrentUser:
):
"""Test that endpoint returns user profile for authenticated user.
Should return the user's profile information.
Should return the user's profile information including has_starter_deck.
"""
# Set up mock db session to return test user when queried
mock_result = MagicMock()
@ -58,6 +58,84 @@ class TestGetCurrentUser:
assert data["avatar_url"] == test_user.avatar_url
assert data["is_premium"] == test_user.is_premium
def test_returns_has_starter_deck_false_for_new_user(
self, app, client: TestClient, test_user, access_token, mock_db_session
):
"""Test that has_starter_deck is false for users without a starter deck.
New users who haven't selected a starter deck should have has_starter_deck=false.
This is used by the frontend to redirect to the starter selection page.
"""
# User has empty decks list (no starter selected)
test_user.decks = []
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = test_user
mock_db_session.execute = AsyncMock(return_value=mock_result)
response = client.get(
"/api/users/me",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["has_starter_deck"] is False
def test_returns_has_starter_deck_true_when_starter_selected(
self, app, client: TestClient, test_user, access_token, mock_db_session
):
"""Test that has_starter_deck is true after selecting a starter deck.
Users who have selected a starter deck should have has_starter_deck=true.
This allows the frontend to navigate to the dashboard instead of starter selection.
"""
# Create a mock starter deck
starter_deck = MagicMock()
starter_deck.is_starter = True
starter_deck.starter_type = "grass"
test_user.decks = [starter_deck]
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = test_user
mock_db_session.execute = AsyncMock(return_value=mock_result)
response = client.get(
"/api/users/me",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["has_starter_deck"] is True
def test_returns_has_starter_deck_false_for_non_starter_decks(
self, app, client: TestClient, test_user, access_token, mock_db_session
):
"""Test that has_starter_deck is false when user only has regular decks.
Users can have custom decks without having selected a starter.
The has_starter_deck field should only be true if a starter deck exists.
"""
# Create a mock regular deck (not a starter)
regular_deck = MagicMock()
regular_deck.is_starter = False
regular_deck.starter_type = None
test_user.decks = [regular_deck]
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = test_user
mock_db_session.execute = AsyncMock(return_value=mock_result)
response = client.get(
"/api/users/me",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["has_starter_deck"] is False
def test_requires_authentication(self, client: TestClient):
"""Test that endpoint returns 401 without authentication."""
response = client.get("/api/users/me")

View File

@ -0,0 +1 @@
"""Unit tests for database models."""

View File

@ -0,0 +1,118 @@
"""Unit tests for User model properties.
Tests the computed properties on the User model that don't require
database access.
"""
from datetime import UTC, datetime
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
from app.db.models.user import User
@pytest.fixture
def user():
"""Create a test user with minimal attributes.
Returns a User model instance without database persistence.
"""
user = User(
email="test@example.com",
display_name="Test User",
avatar_url=None,
oauth_provider="google",
oauth_id="google-123",
is_premium=False,
premium_until=None,
)
user.id = uuid4()
user.created_at = datetime.now(UTC)
user.updated_at = datetime.now(UTC)
user.decks = []
user.linked_accounts = []
return user
class TestHasStarterDeck:
"""Tests for the has_starter_deck computed property."""
def test_returns_false_when_no_decks(self, user):
"""Test has_starter_deck is False when user has no decks.
New users start with no decks, so has_starter_deck should be False.
This is used by the frontend to redirect to starter selection.
"""
user.decks = []
assert user.has_starter_deck is False
def test_returns_false_when_only_regular_decks(self, user):
"""Test has_starter_deck is False when user only has regular decks.
Users can create custom decks without selecting a starter.
has_starter_deck should only be True for actual starter decks.
"""
regular_deck = MagicMock()
regular_deck.is_starter = False
user.decks = [regular_deck]
assert user.has_starter_deck is False
def test_returns_true_when_has_starter_deck(self, user):
"""Test has_starter_deck is True when user has selected a starter.
After selecting a starter deck, the property should return True.
This allows the frontend to skip the starter selection page.
"""
starter_deck = MagicMock()
starter_deck.is_starter = True
user.decks = [starter_deck]
assert user.has_starter_deck is True
def test_returns_true_when_starter_among_multiple_decks(self, user):
"""Test has_starter_deck is True even with mixed deck types.
Users can have both starter and custom decks. As long as one
starter deck exists, the property should return True.
"""
regular_deck = MagicMock()
regular_deck.is_starter = False
starter_deck = MagicMock()
starter_deck.is_starter = True
user.decks = [regular_deck, starter_deck]
assert user.has_starter_deck is True
class TestMaxDecks:
"""Tests for the max_decks computed property."""
def test_returns_5_for_free_users(self, user):
"""Test free users have 5 deck slots.
Free users should be limited to 5 decks to encourage premium upgrades.
"""
user.is_premium = False
assert user.max_decks == 5
def test_returns_999_for_premium_users(self, user):
"""Test premium users have unlimited deck slots.
Premium users get 999 slots (effectively unlimited) as a benefit.
"""
from datetime import timedelta
user.is_premium = True
user.premium_until = datetime.now(UTC) + timedelta(days=30)
assert user.max_decks == 999
def test_returns_5_for_expired_premium(self, user):
"""Test expired premium users revert to free limits.
When premium expires, users should be treated as free users.
"""
from datetime import timedelta
user.is_premium = True
user.premium_until = datetime.now(UTC) - timedelta(days=1) # Expired
assert user.max_decks == 5