Skip to content

Commit 047560c

Browse files
authored
feat(backend): dx improvements (#47)
* feat(backend): improve dev experience with auto-seeding and long-lived sessions * test(backend): add unit tests for startup tasks and dev session expiry * feat(backend): make dev and demo user credentials configurable
1 parent af1838f commit 047560c

5 files changed

Lines changed: 171 additions & 10 deletions

File tree

backend/app/core/startup.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import logging
2+
from typing import Any
3+
4+
from fastapi import HTTPException
5+
from sqlalchemy.orm import Session
6+
7+
from app.modules.admin.seed.service import seed_all
8+
from app.modules.auth.schemas import UserCreate
9+
from app.modules.auth.service import create_user_if_not_exists
10+
from app.settings import Settings
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
def setup_test_users(db: Session, users: list[dict[str, Any]], default_password: str):
16+
"""Create initial test users if they don't exist. Easily extendable."""
17+
for user_info in users:
18+
# Use default password if not provided in user_info
19+
data = user_info.copy()
20+
if "password" not in data:
21+
data["password"] = default_password
22+
23+
logger.info(f"Ensuring test user exists: {data['email']}")
24+
create_user_if_not_exists(db, UserCreate(**data))
25+
26+
27+
def auto_seed_data(db: Session):
28+
"""Seed the database with initial data if it's empty."""
29+
try:
30+
seed_all(db, n_tags=7, n_fields=12, n_events=30)
31+
logger.info("Auto-seeding completed successfully.")
32+
except HTTPException as e:
33+
if e.status_code == 405:
34+
logger.info("Database already contains data. Skipping auto-seeding.")
35+
else:
36+
logger.exception(f"Auto-seeding failed with unexpected error: {e.detail}")
37+
except Exception:
38+
logger.exception("Auto-seeding failed")
39+
40+
41+
def run_startup_tasks(db: Session, settings: Settings):
42+
"""Run all necessary startup tasks for development environment."""
43+
if settings.is_dev:
44+
setup_test_users(db, settings.dev_users, settings.dev_users_password)
45+
auto_seed_data(db)
46+
elif settings.is_demo:
47+
create_user_if_not_exists(
48+
db,
49+
UserCreate(
50+
email=settings.demo_user_email, password=settings.demo_user_password
51+
),
52+
)

backend/app/factory.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@
1010

1111
from app.api.v1.routes import admin, auth, events, fields, generic, tags
1212
from app.core.handlers import http_exception_handler, validation_exception_handler
13-
from app.modules.auth.schemas import UserCreate
14-
from app.modules.auth.service import create_user_if_not_exists
13+
from app.core.startup import run_startup_tasks
1514
from app.settings import Settings
1615

1716
logger = logging.getLogger(__name__)
@@ -24,11 +23,8 @@ def create_app(
2423
@asynccontextmanager
2524
async def lifespan(app: FastAPI):
2625
logger.info(f"Starting application in {settings.env} mode")
27-
if settings.is_demo:
28-
with SessionLocal() as db:
29-
create_user_if_not_exists(
30-
db, UserCreate(email="demo@evsy.dev", password="bestructured")
31-
)
26+
with SessionLocal() as db:
27+
run_startup_tasks(db, settings)
3228
yield
3329
logger.info("Shutting down application")
3430
engine.dispose()

backend/app/modules/auth/token.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,15 @@
1717
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
1818
settings = get_settings()
1919
to_encode = data.copy()
20-
expire = datetime.now(UTC) + (
21-
expires_delta or timedelta(minutes=settings.access_token_expire_minutes)
22-
)
20+
if expires_delta:
21+
expire = datetime.now(UTC) + expires_delta
22+
elif settings.is_dev:
23+
# 100 years for dev mode
24+
expire = datetime.now(UTC) + timedelta(days=365 * 100)
25+
else:
26+
expire = datetime.now(UTC) + timedelta(
27+
minutes=settings.access_token_expire_minutes
28+
)
2329
to_encode.update({"exp": expire})
2430
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.jwt_algorithm)
2531

backend/app/settings.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@ def __init__(self, _env_file: Optional[str] = None, **kwargs: Any):
5757
default=None, alias="GOOGLE_CLIENT_SECRET"
5858
)
5959

60+
dev_users: list[dict[str, Any]] = Field(
61+
default=[{"email": "user@example.com"}],
62+
alias="DEV_USERS",
63+
)
64+
dev_users_password: str = Field(default="12345678", alias="DEV_USERS_PASSWORD")
65+
66+
demo_user_email: str = Field(default="demo@evsy.dev", alias="DEMO_USER_EMAIL")
67+
demo_user_password: str = Field(default="bestructured", alias="DEMO_USER_PASSWORD")
68+
6069
model_config = SettingsConfigDict(
6170
env_file_encoding="utf-8",
6271
case_sensitive=False,

backend/tests/test_startup.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from datetime import UTC, datetime, timedelta
2+
from unittest.mock import MagicMock, patch
3+
4+
from fastapi import HTTPException
5+
from jose import jwt
6+
7+
from app.core.startup import auto_seed_data, run_startup_tasks, setup_test_users
8+
from app.modules.auth.models import User
9+
from app.modules.auth.token import create_access_token
10+
11+
12+
def test_create_access_token_dev_long_expiry():
13+
"""Test that tokens in dev mode have a very long expiry."""
14+
mock_settings = MagicMock()
15+
mock_settings.is_dev = True
16+
mock_settings.secret_key = "test_secret"
17+
mock_settings.jwt_algorithm = "HS256"
18+
19+
with patch("app.modules.auth.token.get_settings", return_value=mock_settings):
20+
token = create_access_token({"sub": "user@example.com"})
21+
payload = jwt.decode(token, "test_secret", algorithms=["HS256"])
22+
23+
exp = payload["exp"]
24+
expected_min_exp = (datetime.now(UTC) + timedelta(days=365 * 99)).timestamp()
25+
assert exp > expected_min_exp
26+
27+
28+
def test_create_access_token_prod_normal_expiry():
29+
"""Test that tokens in prod mode have normal expiry."""
30+
mock_settings = MagicMock()
31+
mock_settings.is_dev = False
32+
mock_settings.access_token_expire_minutes = 60
33+
mock_settings.secret_key = "test_secret"
34+
mock_settings.jwt_algorithm = "HS256"
35+
36+
with patch("app.modules.auth.token.get_settings", return_value=mock_settings):
37+
token = create_access_token({"sub": "user@example.com"})
38+
payload = jwt.decode(token, "test_secret", algorithms=["HS256"])
39+
40+
exp = payload["exp"]
41+
# Should be roughly 60 minutes from now
42+
expected_exp = (datetime.now(UTC) + timedelta(minutes=60)).timestamp()
43+
assert abs(exp - expected_exp) < 10 # Allow 10s difference
44+
45+
46+
def test_setup_test_users(db, test_settings):
47+
"""Test that test users are created if they don't exist."""
48+
# Ensure user doesn't exist in the current transaction
49+
primary_dev_user = test_settings.dev_users[0]
50+
user = db.query(User).filter(User.email == primary_dev_user["email"]).first()
51+
if user:
52+
db.delete(user)
53+
db.flush()
54+
55+
setup_test_users(db, test_settings.dev_users, test_settings.dev_users_password)
56+
57+
user = db.query(User).filter(User.email == primary_dev_user["email"]).first()
58+
assert user is not None
59+
assert user.email == primary_dev_user["email"]
60+
61+
62+
@patch("app.core.startup.seed_all")
63+
def test_auto_seed_data_empty_db(mock_seed_all, db):
64+
"""Test that seeding is called when DB is empty."""
65+
mock_seed_all.return_value = None
66+
auto_seed_data(db)
67+
mock_seed_all.assert_called_once()
68+
69+
70+
@patch("app.core.startup.seed_all")
71+
def test_auto_seed_data_already_seeded(mock_seed_all, db):
72+
"""Test that seeding is skipped if DB already has data (simulated by HTTPException 405)."""
73+
mock_seed_all.side_effect = HTTPException(
74+
status_code=405, detail="Action is only allowed on empty database"
75+
)
76+
77+
# This should not raise an exception, just log and return
78+
auto_seed_data(db)
79+
mock_seed_all.assert_called_once()
80+
81+
82+
def test_run_startup_tasks_dev_calls_subtasks(db):
83+
"""Test that all dev startup tasks are triggered in dev mode."""
84+
mock_settings = MagicMock()
85+
mock_settings.is_dev = True
86+
mock_settings.is_demo = False
87+
mock_settings.dev_users = [{"email": "user@example.com"}]
88+
mock_settings.dev_users_password = "password"
89+
90+
with (
91+
patch("app.core.startup.setup_test_users") as mock_setup_users,
92+
patch("app.core.startup.auto_seed_data") as mock_auto_seed,
93+
):
94+
run_startup_tasks(db, mock_settings)
95+
mock_setup_users.assert_called_once_with(
96+
db, mock_settings.dev_users, mock_settings.dev_users_password
97+
)
98+
mock_auto_seed.assert_called_once_with(db)

0 commit comments

Comments
 (0)