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
+2
View File
@@ -14,6 +14,7 @@ from app.routers import card_router
from app.routers import interactions
from app.routers import refresh
from app.routers import card_import
from app.routers import user_data
__all__ = [
"auth",
@@ -26,4 +27,5 @@ __all__ = [
"interactions",
"refresh",
"card_import",
"user_data",
]
+1 -1
View File
@@ -28,8 +28,8 @@ from app.schemas.card_import_schemas import (
CardImportStatusResponse,
CardMatchResult,
CardImportSummary,
MessageResponse,
)
from app.schemas.generic_schemas import MessageResponse
from app.schemas.user_deck_schemas import DeckCardResponse, DeckCardListResponse
router = APIRouter()
+120 -1
View File
@@ -13,7 +13,9 @@ from app.core.database import mtg_get_db
from app.core.redis_client import cache_get, cache_set
from app.services.card_search_service import CardSearchService
from app.services.deck_suggestion_service import DeckSuggestionService
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
from app.models.user_deck import UserDeck
from app.models.mtg_models import MtgCard
from app.schemas.card_search_schemas import CardSearchResponse, CardResponse, SetResponse, CardTypeResponse
router = APIRouter(prefix="/api/cards", tags=["Card Search"])
@@ -33,6 +35,7 @@ async def search_cards_endpoint(
Search cards with filters.
Supports filtering by type, set, and color in addition to name search.
Uses fuzzy matching as a fallback when exact/partial matches are not found.
"""
cache_key = f"card_search:{q}:{card_type}:{set_code}:{color}:{limit}:{offset}"
@@ -41,7 +44,10 @@ async def search_cards_endpoint(
if cached:
return {"cached": True, "results": cached}
# Search cards
# Normalize the search query for consistency
normalized_query = FuzzyCardMatcher.normalize_card_name(q)
# Search cards using the existing service
results = await CardSearchService.search_cards(
db=db,
query=q,
@@ -52,12 +58,125 @@ async def search_cards_endpoint(
offset=offset,
)
# If no results found, try fuzzy matching as a fallback
if results["total"] == 0:
fuzzy_results = await _fuzzy_search_fallback(
db=db,
query=q,
normalized_query=normalized_query,
card_type=card_type,
set_code=set_code,
color=color,
limit=limit,
offset=offset,
)
results = fuzzy_results
# Add fuzzy matching metadata to results
results["query_normalized"] = normalized_query
results["fuzzy_match"] = True
# Cache results for 5 minutes
await cache_set(cache_key, str(results), ttl=300)
return {"cached": False, "results": results}
async def _fuzzy_search_fallback(
db: AsyncSession,
query: str,
normalized_query: str,
card_type: Optional[str] = None,
set_code: Optional[str] = None,
color: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> Dict[str, Any]:
"""
Fallback fuzzy search when exact/partial matches yield no results.
Fetches all cards matching the type/set/color filters, then uses
fuzzy matching to find the best card name matches.
"""
from sqlalchemy import select, or_
# Fetch candidate cards based on non-name filters
conditions = []
if card_type:
conditions.append(MtgCard.type_line.ilike(f"%{card_type}%"))
if set_code:
conditions.append(MtgCard.set_code == set_code)
if color:
colors = [c.strip() for c in color.upper().split(",")]
for c in colors:
if c in ["W", "U", "B", "R", "G"]:
conditions.append(MtgCard.colors.ilike(f"%{c}%"))
# If no filters, fetch a broader set for fuzzy matching
if not conditions:
stmt = select(MtgCard).limit(limit * 5)
else:
stmt = select(MtgCard).where(*conditions).limit(limit * 5)
result = await db.execute(stmt)
candidate_cards = result.scalars().all()
if not candidate_cards:
return {
"cards": [],
"total": 0,
"page": offset // limit + 1,
"page_size": limit,
"total_pages": 0,
"fuzzy_fallback": True,
"message": "No cards found matching your query.",
}
# Build candidate name list and lookup
candidate_names = [card.name for card in candidate_cards if card.name]
card_lookup = {card.name.lower(): card for card in candidate_cards if card.name}
# Use fuzzy matching to find best matches
matched_name, confidence, match_type = FuzzyCardMatcher.find_best_match(
normalized_query, candidate_names, threshold=FuzzyCardMatcher.MIN_MATCH_THRESHOLD
)
# Build results from fuzzy matches
card_list = []
if matched_name and matched_name.lower() in card_lookup:
card = card_lookup[matched_name.lower()]
card_data = {
"id": card.id,
"name": card.name,
"mana_cost": card.mana_cost,
"type_line": card.type_line,
"oracle_text": card.oracle_text,
"power": card.power,
"toughness": card.toughness,
"rarity": card.rarity,
"layout": card.layout,
"colors": card.colors,
"set_code": card.set_code,
"set_name": card.set_name,
"fuzzy_match": True,
"match_confidence": confidence,
"match_type": match_type,
"original_query": query,
}
card_list.append(card_data)
return {
"cards": card_list,
"total": len(card_list),
"page": offset // limit + 1,
"page_size": limit,
"total_pages": (len(card_list) + limit - 1) // limit if card_list else 0,
"fuzzy_fallback": True,
"match_type": match_type,
"confidence": confidence,
}
@router.get("/{card_id}", response_model=CardResponse)
async def get_card_endpoint(
card_id: int,
+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(
+11 -3
View File
@@ -19,14 +19,23 @@ from app.models.user_data import (
UserGroup, GroupMember, GroupChatMessage, UserNetwork,
NetworkMember, UserPreference, UserActivityLog
)
from app.schemas.user_card_collection import (
CardCollectionCreate,
CardCollectionUpdate,
CardCollectionResponse,
CardCollectionListResponse,
WishlistCreate,
WishlistUpdate,
WishlistResponse,
WishlistListResponse,
)
from app.schemas.generic_schemas import MessageResponse, CountResponse, ErrorDetail
from app.schemas.user_data_schemas import (
SessionCleanupResponse,
DeckVersionCreate, DeckVersionUpdate, DeckVersionResponse, DeckVersionListResponse,
GameReplayCreate, GameReplayUpdate, GameReplayResponse, GameReplayListResponse,
GameOutcomeCreate, GameOutcomeResponse, GameOutcomeListResponse,
UserStatisticsResponse, StatisticsUpdateResponse,
CardCollectionCreate, CardCollectionUpdate, CardCollectionResponse, CardCollectionListResponse,
WishlistCreate, WishlistUpdate, WishlistResponse, WishlistListResponse,
GroupCreate, GroupUpdate, GroupResponse, GroupListResponse,
GroupMemberCreate, GroupMemberUpdate, GroupMemberRemove,
GroupChatMessageCreate, GroupChatMessageResponse, GroupChatMessageListResponse,
@@ -34,7 +43,6 @@ from app.schemas.user_data_schemas import (
NetworkMemberCreate,
UserPreferenceUpdate, UserPreferenceResponse,
ActivityLogEntry, ActivityLogListResponse,
MessageResponse, CountResponse, ErrorDetail
)
router = APIRouter()