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:
parent
cd3efcb528
commit
ca3aca2b38
@ -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})>"
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
1
backend/tests/unit/models/__init__.py
Normal file
1
backend/tests/unit/models/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Unit tests for database models."""
|
||||
118
backend/tests/unit/models/test_user.py
Normal file
118
backend/tests/unit/models/test_user.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user