Phase 7: End-to-End API Testing - Database setup, migrations, and application fixes

- Switched from psycopg2 to asyncpg for async SQLAlchemy support
- Fixed router registration in main.py - removed duplicate prefixes
- Added user_data export to routers/__init__.py
- Refactored decks router to use DeckManager service layer
- Integrated FuzzyCardMatcher into card_router search endpoints
- Made WishlistCreate.card_id optional for proper schema validation
- Set PostgreSQL password and configured scram-sha-256 auth
- Updated alembic.ini to use local PostgreSQL instead of Docker hostname
- Created generic_schemas.py for reusable schema patterns
- Added test_routers.py and test_schema_validation.py test files
- All 6 Alembic migrations applied successfully (37 tables created)
- Application running on port 8000 with all services connected
This commit is contained in:
2026-08-18 03:35:19 +00:00
parent bea91db64d
commit 1df04aea52
20 changed files with 862 additions and 513 deletions
+161 -221
View File
@@ -6,7 +6,7 @@ deck finalization, precedent templates, and card search integration.
"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, update, func, and_, or_
from sqlalchemy import select, func, or_, update, delete
from typing import Optional, List
from app.core.database import get_db, mtg_get_db
@@ -20,12 +20,18 @@ from app.schemas.user_deck_schemas import (
PrecedentCreate, PrecedentUpdate, PrecedentResponse, PrecedentListResponse,
SuggestionCreate, SuggestionResponse, SuggestionListResponse,
DeckFinalizeRequest, DeckFinalizeResponse,
CardSearchRequest, CardSearchResponse,
MessageResponse, CountResponse
CardSearchRequest,
)
from app.schemas.card_search_schemas import CardSearchResponse
from app.schemas.generic_schemas import MessageResponse, CountResponse
from app.services.deck_manager import DeckManager
from app.services.deck_parser import DeckParser
router = APIRouter()
_deck_mgr = DeckManager()
_deck_parser = DeckParser()
# ===== Deck CRUD =====
@@ -41,57 +47,50 @@ async def list_user_decks(
):
"""List user's decks with optional filtering."""
user_id = int(current_user["user_id"])
offset = (page - 1) * page_size
# Build conditions
conditions = [UserDeck.user_id == user_id]
if status_filter:
conditions.append(UserDeck.status == status_filter)
if folder_id:
conditions.append(UserDeck.folder_id == folder_id)
if is_precedent is not None:
conditions.append(UserDeck.is_precedent == is_precedent)
# Count total
count_stmt = select(func.count()).select_from(UserDeck).where(*conditions)
total_result = await db.execute(count_stmt)
total = total_result.scalar() or 0
# Fetch decks
stmt = select(UserDeck).where(*conditions).order_by(UserDeck.updated_at.desc()).offset(offset).limit(page_size)
result = await db.execute(stmt)
decks = result.scalars().all()
# Get card counts
deck_ids = [d.id for d in decks]
card_counts = {}
if deck_ids:
count_subquery = (
select(UserDeckCard.deck_id, func.count().label('cnt'))
.where(UserDeckCard.deck_id.in_(deck_ids))
.group_by(UserDeckCard.deck_id)
.subquery()
try:
decks = await _deck_mgr.list_decks(
db=db,
user_id=user_id,
status_filter=status_filter,
folder_id=folder_id,
is_precedent=is_precedent,
page=page,
page_size=page_size,
)
count_stmt = select(count_subquery.c.deck_id, count_subquery.c.cnt).where(
count_subquery.c.deck_id.in_(deck_ids)
# Build response with card counts
deck_ids = [d.id for d in decks]
card_counts = {}
if deck_ids:
count_subquery = (
select(UserDeckCard.deck_id, func.count().label('cnt'))
.where(UserDeckCard.deck_id.in_(deck_ids))
.group_by(UserDeckCard.deck_id)
.subquery()
)
count_stmt = select(count_subquery.c.deck_id, count_subquery.c.cnt).where(
count_subquery.c.deck_id.in_(deck_ids)
)
count_result = await db.execute(count_stmt)
card_counts = {row[0]: row[1] for row in count_result.fetchall()}
deck_responses = []
for deck in decks:
deck_data = UserDeckResponse.model_validate(deck)
deck_data.card_count = card_counts.get(deck.id, 0)
deck_data.is_owner = True
deck_responses.append(deck_data)
total = len(decks)
return UserDeckListResponse(
decks=deck_responses,
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size,
)
count_result = await db.execute(count_stmt)
card_counts = {row[0]: row[1] for row in count_result.fetchall()}
deck_responses = []
for deck in decks:
deck_data = UserDeckResponse.model_validate(deck)
deck_data.card_count = card_counts.get(deck.id, 0)
deck_data.is_owner = True
deck_responses.append(deck_data)
return UserDeckListResponse(
decks=deck_responses,
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.post("/", response_model=UserDeckResponse, status_code=status.HTTP_201_CREATED)
@@ -102,34 +101,27 @@ async def create_user_deck(
):
"""Create a new user deck (DRAFT status)."""
user_id = int(current_user["user_id"])
# Verify folder exists if specified
if request.folder_id:
stmt = select(DecklistFolder).where(
DecklistFolder.id == request.folder_id,
DecklistFolder.owner_id == user_id,
try:
new_deck = await _deck_mgr.create_deck(
db=db,
user_id=user_id,
name=request.name,
folder_id=request.folder_id,
format=request.format,
notes=request.notes,
is_precedent=request.is_precedent,
precedent_name=request.precedent_name,
)
result = await db.execute(stmt)
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
new_deck = UserDeck(
user_id=user_id,
name=request.name,
folder_id=request.folder_id,
format=request.format,
notes=request.notes,
is_precedent=request.is_precedent,
precedent_name=request.precedent_name,
)
db.add(new_deck)
await db.flush()
deck_data = UserDeckResponse.model_validate(new_deck)
deck_data.card_count = 0
deck_data.is_owner = True
return deck_data
deck_data = UserDeckResponse.model_validate(new_deck)
deck_data.card_count = 0
deck_data.is_owner = True
return deck_data
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.get("/{deck_id}", response_model=UserDeckResponse)
@@ -140,26 +132,24 @@ async def get_user_deck(
):
"""Get a specific user deck."""
user_id = int(current_user["user_id"])
try:
deck = await _deck_mgr.get_deck(db=db, deck_id=deck_id, user_id=user_id)
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
stmt = select(UserDeck).where(
UserDeck.id == deck_id,
UserDeck.user_id == user_id,
)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
# Get card count
count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id)
count_result = await db.execute(count_stmt)
card_count = count_result.scalar() or 0
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
# Get card count
count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id)
count_result = await db.execute(count_stmt)
card_count = count_result.scalar() or 0
deck_data = UserDeckResponse.model_validate(deck)
deck_data.card_count = card_count
deck_data.is_owner = True
return deck_data
deck_data = UserDeckResponse.model_validate(deck)
deck_data.card_count = card_count
deck_data.is_owner = True
return deck_data
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.patch("/{deck_id}", response_model=UserDeckResponse)
@@ -171,41 +161,36 @@ async def update_user_deck(
):
"""Update a user deck."""
user_id = int(current_user["user_id"])
try:
update_data = request.model_dump(exclude_unset=True)
if "status" in update_data and update_data["status"]:
update_data["status"] = update_data["status"].value
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
updated_deck = await _deck_mgr.update_deck(
db=db,
deck_id=deck_id,
user_id=user_id,
**update_data,
)
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
if not updated_deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
# Can't modify FINAL decks
if deck.status == "FINAL":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
# Get card count
count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id)
count_result = await db.execute(count_stmt)
card_count = count_result.scalar() or 0
# Update fields
update_data = request.model_dump(exclude_unset=True)
if "status" in update_data and update_data["status"]:
update_data["status"] = update_data["status"].value
stmt = update(UserDeck).where(UserDeck.id == deck_id).values(**update_data)
await db.execute(stmt)
await db.flush()
# Fetch updated deck
stmt = select(UserDeck).where(UserDeck.id == deck_id)
result = await db.execute(stmt)
updated_deck = result.scalar_one_or_none()
# Get card count
count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id)
count_result = await db.execute(count_stmt)
card_count = count_result.scalar() or 0
deck_data = UserDeckResponse.model_validate(updated_deck)
deck_data.card_count = card_count
deck_data.is_owner = True
return deck_data
deck_data = UserDeckResponse.model_validate(updated_deck)
deck_data.card_count = card_count
deck_data.is_owner = True
return deck_data
except HTTPException:
raise
except ValueError as e:
if "not found" in str(e).lower():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.delete("/{deck_id}", response_model=MessageResponse)
@@ -216,18 +201,15 @@ async def delete_user_deck(
):
"""Delete a user deck."""
user_id = int(current_user["user_id"])
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
await db.execute(delete(UserDeck).where(UserDeck.id == deck_id))
await db.flush()
return MessageResponse(message="Deck deleted successfully")
try:
result = await _deck_mgr.delete_deck(db=db, deck_id=deck_id, user_id=user_id)
if not result:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
return MessageResponse(message="Deck deleted successfully")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
# ===== Deck Finalize =====
@@ -240,34 +222,21 @@ async def finalize_user_deck(
):
"""Transition a deck from DRAFT to FINAL status."""
user_id = int(current_user["user_id"])
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
if deck.status == "FINAL":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Deck is already finalized")
# Check deck has cards
count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id)
count_result = await db.execute(count_stmt)
card_count = count_result.scalar() or 0
if card_count == 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot finalize an empty deck")
# Update status
stmt = update(UserDeck).where(UserDeck.id == deck_id).values(status="FINAL")
await db.execute(stmt)
await db.flush()
return DeckFinalizeResponse(
deck_id=deck_id,
status="FINAL",
message="Deck finalized successfully",
)
try:
deck = await _deck_mgr.finalize_deck(db=db, deck_id=deck_id, user_id=user_id)
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
return DeckFinalizeResponse(
deck_id=deck_id,
status="FINAL",
message="Deck finalized successfully",
)
except HTTPException:
raise
except ValueError as e:
if "not found" in str(e).lower():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ===== Deck Card Management =====
@@ -283,14 +252,12 @@ async def add_deck_card(
user_id = int(current_user["user_id"])
# Verify deck exists and belongs to user
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
deck_stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
deck_result = await db.execute(deck_stmt)
deck = deck_result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
# Can't modify FINAL decks
if deck.status == "FINAL":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
@@ -353,15 +320,8 @@ async def get_deck_cards(
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
# Build conditions
conditions = [UserDeckCard.deck_id == deck_id]
if zone:
conditions.append(UserDeckCard.zone == zone)
# Fetch cards
stmt = select(UserDeckCard).where(*conditions).order_by(UserDeckCard.id)
result = await db.execute(stmt)
deck_cards = result.scalars().all()
# Use service to get cards
deck_cards = await _deck_mgr.get_deck_cards(db=db, deck_id=deck_id, zone=zone)
# Fetch card details from local mirror
card_ids = [dc.card_id for dc in deck_cards]
@@ -471,6 +431,8 @@ async def remove_deck_card(
# ===== Deck Precedents =====
# Note: Precedent endpoints use direct DB operations as DeckManager
# does not yet have precedent-specific methods.
@router.get("/precedents", response_model=PrecedentListResponse)
async def list_precedents(
@@ -585,46 +547,22 @@ async def use_precedent(
"""Clone a precedent into a new draft deck for the current user."""
user_id = int(current_user["user_id"])
# Get precedent
stmt = select(DeckPrecedent).where(DeckPrecedent.id == precedent_id)
result = await db.execute(stmt)
precedent = result.scalar_one_or_none()
if not precedent:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Precedent not found")
# Create new deck from precedent
new_deck = UserDeck(
user_id=user_id,
name=f"Copy of {precedent.name}",
format=precedent.format,
is_precedent=False,
)
db.add(new_deck)
await db.flush()
# Copy cards from precedent
card_stmt = select(DeckPrecedentCard).where(DeckPrecedentCard.precedent_id == precedent_id)
card_result = await db.execute(card_stmt)
precedent_cards = card_result.scalars().all()
for pc in precedent_cards:
new_dc = UserDeckCard(
deck_id=new_deck.id,
card_id=pc.card_id,
quantity=pc.quantity,
zone=pc.zone,
try:
new_deck = await _deck_mgr.clone_precedent(
db=db,
precedent_id=precedent_id,
user_id=user_id,
)
db.add(new_dc)
await db.flush()
return {
"message": "Precedent cloned into new deck",
"deck_id": new_deck.id,
"deck_name": new_deck.name,
"card_count": len(precedent_cards),
}
return {
"message": "Precedent cloned into new deck",
"deck_id": new_deck.id,
"deck_name": new_deck.name,
"card_count": len(await _deck_mgr.get_deck_cards(db=db, deck_id=new_deck.id)),
}
except ValueError as e:
if "not found" in str(e).lower():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ===== Card Search Integration =====
@@ -697,6 +635,8 @@ async def search_cards_for_deck(
# ===== Card Suggestions =====
# Note: Suggestion endpoints use direct DB operations as DeckManager
# does not yet have suggestion-specific methods.
@router.get("/{deck_id}/suggestions", response_model=SuggestionListResponse)
async def get_deck_suggestions(