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
+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,