Skip to content

Commit e35bc24

Browse files
authored
Merge pull request #179 from lbedner/auth-tests
Auth Tests
2 parents 3e426a4 + 3f5225c commit e35bc24

10 files changed

Lines changed: 1102 additions & 89 deletions

File tree

CLAUDE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,22 @@ This project uses `uv` for dependency management and a `Makefile` for CLI develo
4242
- `make test-template-quick` - Quick template test without validation
4343
- `make test-template` - Full template test with validation
4444
- `make test-template-with-components` - Test template with scheduler component
45+
- `make test-template-auth` - **Test auth service template with comprehensive validation**
46+
- `make test-template-worker` - Test worker component template
47+
- `make test-template-database` - Test database component template
48+
- `make test-template-full` - Test template with all components
4549
- `make clean-test-projects` - Remove generated test projects
4650

4751
**Template testing is critical** - always run `make test-template` after modifying templates to ensure generated projects work correctly.
4852

53+
**For auth service development**: Use `make test-template-auth` to generate auth service project and run full validation including:
54+
- ✅ Auth service includes Alembic migration infrastructure
55+
- ✅ Database component auto-inclusion
56+
- ✅ Migration files generate correctly
57+
- ✅ All 52 auth service tests pass
58+
- ✅ CLI script installation and functionality
59+
- ✅ Linting, type checking, and quality checks
60+
4961
## CRITICAL: Template Development Workflow
5062

5163
**NEVER edit generated test projects directly!** Always follow this workflow:

aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/api/auth/router.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,46 @@
11
"""Authentication API routes."""
22

3-
from app.components.backend.api.deps import get_db
3+
from app.components.backend.api.deps import get_async_db
44
from app.core.security import create_access_token, verify_password
55
from app.models.user import UserCreate, UserResponse
66
from app.services.auth.auth_service import get_current_user_from_token
7-
from app.services.auth.user_service import UserService
7+
from app.services.auth.user_service import AsyncUserService
88
from fastapi import APIRouter, Depends, HTTPException, status
99
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
10-
from sqlmodel import Session
10+
from sqlmodel.ext.asyncio.session import AsyncSession
1111

1212
router = APIRouter(prefix="/auth", tags=["authentication"])
1313

1414
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token")
1515

1616

1717
@router.post("/register", response_model=UserResponse)
18-
async def register(user_data: UserCreate, db: Session = Depends(get_db)):
18+
async def register(user_data: UserCreate, db: AsyncSession = Depends(get_async_db)):
1919
"""Register a new user."""
20-
user_service = UserService(db)
20+
user_service = AsyncUserService(db)
2121

2222
# Check if user already exists
23-
existing_user = user_service.get_user_by_email(user_data.email)
23+
existing_user = await user_service.get_user_by_email(user_data.email)
2424
if existing_user:
2525
raise HTTPException(
2626
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
2727
)
2828

2929
# Create new user
30-
user = user_service.create_user(user_data)
30+
user = await user_service.create_user(user_data)
3131
return UserResponse.model_validate(user)
3232

3333

3434
@router.post("/token")
3535
async def login(
3636
form_data: OAuth2PasswordRequestForm = Depends(),
37-
db: Session = Depends(get_db),
37+
db: AsyncSession = Depends(get_async_db),
3838
):
3939
"""Login and get access token."""
40-
user_service = UserService(db)
40+
user_service = AsyncUserService(db)
4141

4242
# Get user by email (username field in OAuth2 form)
43-
user = user_service.get_user_by_email(form_data.username)
43+
user = await user_service.get_user_by_email(form_data.username)
4444

4545
if not user or not verify_password(form_data.password, user.hashed_password):
4646
raise HTTPException(
@@ -57,7 +57,7 @@ async def login(
5757
@router.get("/me", response_model=UserResponse)
5858
async def get_current_user(
5959
token: str = Depends(oauth2_scheme),
60-
db: Session = Depends(get_db),
60+
db: AsyncSession = Depends(get_async_db),
6161
):
6262
"""Get current authenticated user."""
6363
user = await get_current_user_from_token(token, db)

aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/api/deps.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""FastAPI dependencies for the backend API."""
22

3-
from collections.abc import Generator
3+
from collections.abc import AsyncGenerator, Generator
44

5-
from app.core.db import SessionLocal
5+
from app.core.db import AsyncSessionLocal, SessionLocal
66
from sqlmodel import Session
7+
from sqlmodel.ext.asyncio.session import AsyncSession
78

89

910
def get_db() -> Generator[Session, None, None]:
@@ -28,3 +29,30 @@ def example_endpoint(db: Session = Depends(get_db)):
2829
yield db
2930
finally:
3031
db.close()
32+
33+
34+
async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
35+
"""
36+
Async database dependency that provides an async database session.
37+
38+
This dependency is used in async FastAPI route functions to get access to
39+
the database with non-blocking I/O operations. It automatically handles
40+
session lifecycle - creating, yielding, committing and closing the session properly.
41+
42+
Usage:
43+
@router.get("/example")
44+
async def example_endpoint(db: AsyncSession = Depends(get_async_db)):
45+
# Use db for async database operations with await
46+
result = await db.exec(select(MyModel))
47+
return result.first()
48+
49+
Yields:
50+
AsyncSession: SQLModel async database session
51+
"""
52+
async with AsyncSessionLocal() as session:
53+
try:
54+
yield session
55+
await session.commit()
56+
except Exception:
57+
await session.rollback()
58+
raise

aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/auth/auth_service.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
"""Authentication service utilities."""
22

33
from fastapi import HTTPException, status
4-
from sqlmodel import Session
4+
from sqlmodel.ext.asyncio.session import AsyncSession
55

66
from app.core.security import verify_token
77
from app.models.user import User
8-
from app.services.auth.user_service import UserService
8+
from app.services.auth.user_service import AsyncUserService
99

1010

11-
async def get_current_user_from_token(token: str, db: Session) -> User:
11+
async def get_current_user_from_token(token: str, db: AsyncSession) -> User:
1212
"""Get current user from JWT token."""
1313
credentials_exception = HTTPException(
1414
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -27,8 +27,8 @@ async def get_current_user_from_token(token: str, db: Session) -> User:
2727
raise credentials_exception
2828

2929
# Get user from database
30-
user_service = UserService(db)
31-
user = user_service.get_user_by_email(email)
30+
user_service = AsyncUserService(db)
31+
user = await user_service.get_user_by_email(email)
3232
if user is None:
3333
raise credentials_exception
3434

aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/auth/user_service.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from datetime import UTC, datetime
44

55
from sqlmodel import Session, select
6+
from sqlmodel.ext.asyncio.session import AsyncSession
67

78
from app.core.security import get_password_hash
89
from app.models.user import User, UserCreate
@@ -78,3 +79,77 @@ def find_existing_emails_with_prefix(self, prefix: str, domain: str) -> list[str
7879
statement = select(User.email).where(User.email.like(pattern))
7980
result = self.db.exec(statement)
8081
return list(result.all())
82+
83+
84+
class AsyncUserService:
85+
"""Async service for managing users with non-blocking database operations."""
86+
87+
def __init__(self, db: AsyncSession):
88+
self.db = db
89+
90+
async def create_user(self, user_data: UserCreate) -> User:
91+
"""Create a new user asynchronously."""
92+
# Hash the password
93+
hashed_password = get_password_hash(user_data.password)
94+
95+
# Create user object
96+
user = User(
97+
email=user_data.email,
98+
full_name=user_data.full_name,
99+
hashed_password=hashed_password,
100+
is_active=user_data.is_active,
101+
created_at=datetime.now(UTC),
102+
)
103+
104+
# Save to database
105+
self.db.add(user)
106+
await self.db.commit()
107+
await self.db.refresh(user)
108+
109+
return user
110+
111+
async def get_user_by_email(self, email: str) -> User | None:
112+
"""Get user by email address asynchronously."""
113+
statement = select(User).where(User.email == email)
114+
result = await self.db.exec(statement)
115+
return result.first()
116+
117+
async def get_user_by_id(self, user_id: int) -> User | None:
118+
"""Get user by ID asynchronously."""
119+
return await self.db.get(User, user_id)
120+
121+
async def update_user(self, user_id: int, **updates) -> User | None:
122+
"""Update user data asynchronously."""
123+
user = await self.get_user_by_id(user_id)
124+
if not user:
125+
return None
126+
127+
for field, value in updates.items():
128+
if hasattr(user, field):
129+
setattr(user, field, value)
130+
131+
user.updated_at = datetime.now(UTC)
132+
self.db.add(user)
133+
await self.db.commit()
134+
await self.db.refresh(user)
135+
136+
return user
137+
138+
async def deactivate_user(self, user_id: int) -> User | None:
139+
"""Deactivate a user account asynchronously."""
140+
return await self.update_user(user_id, is_active=False)
141+
142+
async def list_users(self) -> list[User]:
143+
"""List all users in the system asynchronously."""
144+
statement = select(User).order_by(User.created_at.desc())
145+
result = await self.db.exec(statement)
146+
return list(result.all())
147+
148+
async def find_existing_emails_with_prefix(
149+
self, prefix: str, domain: str
150+
) -> list[str]:
151+
"""Find existing emails that match the pattern prefix{number}@domain async."""
152+
pattern = f"{prefix}%@{domain}"
153+
statement = select(User.email).where(User.email.like(pattern))
154+
result = await self.db.exec(statement)
155+
return list(result.all())

0 commit comments

Comments
 (0)