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
+1 -1
View File
@@ -42,7 +42,7 @@ prepend_sys_path = .
# This is useful for files that will be opened in Windows editors. # This is useful for files that will be opened in Windows editors.
output_encoding = utf-8 output_encoding = utf-8
sqlalchemy.url = postgresql+asyncpg://mtgonline:mtgonline_pass@postgres:5432/mtgonline sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/mtgo_platform
[post_write_hooks] [post_write_hooks]
+3 -3
View File
@@ -19,13 +19,13 @@ class Settings(BaseSettings):
JWT_SECRET_KEY: str = "change-me-in-production" JWT_SECRET_KEY: str = "change-me-in-production"
# Database - Primary (mtgonline app) # Database - Primary (mtgonline app)
DATABASE_URL: str = "postgresql+asyncpg://mtgonline:mtgonline_pass@postgres:5432/mtgonline" DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/mtgo_platform"
# Database - Secondary (mtgjson data) # Database - Secondary (mtgjson data)
MTG_DATABASE_URL: str = "postgresql+asyncpg://mtgonline:mtgonline_pass@mtgdata:5432/mtgdata" MTG_DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/mtg_data"
# Redis # Redis
REDIS_URL: str = "redis://redis:6379/0" REDIS_URL: str = "redis://localhost:6379/0"
# JWT Configuration # JWT Configuration
JWT_ALGORITHM: str = "HS256" JWT_ALGORITHM: str = "HS256"
+5 -4
View File
@@ -24,7 +24,8 @@ from fastapi.middleware.cors import CORSMiddleware
from app.core.settings import get_settings from app.core.settings import get_settings
from app.core.database import engine, mtg_engine, async_session, mtg_async_session from app.core.database import engine, mtg_engine, async_session, mtg_async_session
from app.routers import auth, users, decks, rooms, games, admin, card_router, interactions, refresh, user_data, card_import from app.routers import auth, users, decks, rooms, admin, card_router, interactions, refresh, user_data, card_import
from app.routers.games import router as games_router
from app.services.mtgjson_manager import MTGJSONManager from app.services.mtgjson_manager import MTGJSONManager
@@ -135,10 +136,10 @@ app.include_router(auth.router, prefix="/auth", tags=["Authentication"])
app.include_router(users.router, prefix="/users", tags=["Users"]) app.include_router(users.router, prefix="/users", tags=["Users"])
app.include_router(decks.router, prefix="/decks", tags=["Decks"]) app.include_router(decks.router, prefix="/decks", tags=["Decks"])
app.include_router(rooms.router, prefix="/rooms", tags=["Rooms"]) app.include_router(rooms.router, prefix="/rooms", tags=["Rooms"])
app.include_router(games.router, prefix="/games", tags=["Games"]) app.include_router(games_router, prefix="/games", tags=["Games"])
app.include_router(admin.router, prefix="/admin", tags=["Admin"]) app.include_router(admin.router, prefix="/admin", tags=["Admin"])
app.include_router(card_router.router, prefix="/api", tags=["MTG Cards"]) app.include_router(card_router.router, tags=["MTG Cards"])
app.include_router(interactions.router, tags=["Card Interactions"]) app.include_router(interactions.router)
app.include_router(refresh.router) app.include_router(refresh.router)
app.include_router(user_data.router, prefix="/api/v1/user-data", tags=["User Data"]) app.include_router(user_data.router, prefix="/api/v1/user-data", tags=["User Data"])
app.include_router(card_import.router, prefix="/api/v1/card-import", tags=["Card Import"]) app.include_router(card_import.router, prefix="/api/v1/card-import", tags=["Card Import"])
+2
View File
@@ -14,6 +14,7 @@ from app.routers import card_router
from app.routers import interactions from app.routers import interactions
from app.routers import refresh from app.routers import refresh
from app.routers import card_import from app.routers import card_import
from app.routers import user_data
__all__ = [ __all__ = [
"auth", "auth",
@@ -26,4 +27,5 @@ __all__ = [
"interactions", "interactions",
"refresh", "refresh",
"card_import", "card_import",
"user_data",
] ]
+1 -1
View File
@@ -28,8 +28,8 @@ from app.schemas.card_import_schemas import (
CardImportStatusResponse, CardImportStatusResponse,
CardMatchResult, CardMatchResult,
CardImportSummary, CardImportSummary,
MessageResponse,
) )
from app.schemas.generic_schemas import MessageResponse
from app.schemas.user_deck_schemas import DeckCardResponse, DeckCardListResponse from app.schemas.user_deck_schemas import DeckCardResponse, DeckCardListResponse
router = APIRouter() 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.core.redis_client import cache_get, cache_set
from app.services.card_search_service import CardSearchService from app.services.card_search_service import CardSearchService
from app.services.deck_suggestion_service import DeckSuggestionService 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.user_deck import UserDeck
from app.models.mtg_models import MtgCard
from app.schemas.card_search_schemas import CardSearchResponse, CardResponse, SetResponse, CardTypeResponse from app.schemas.card_search_schemas import CardSearchResponse, CardResponse, SetResponse, CardTypeResponse
router = APIRouter(prefix="/api/cards", tags=["Card Search"]) router = APIRouter(prefix="/api/cards", tags=["Card Search"])
@@ -33,6 +35,7 @@ async def search_cards_endpoint(
Search cards with filters. Search cards with filters.
Supports filtering by type, set, and color in addition to name search. 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}" cache_key = f"card_search:{q}:{card_type}:{set_code}:{color}:{limit}:{offset}"
@@ -41,7 +44,10 @@ async def search_cards_endpoint(
if cached: if cached:
return {"cached": True, "results": 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( results = await CardSearchService.search_cards(
db=db, db=db,
query=q, query=q,
@@ -52,12 +58,125 @@ async def search_cards_endpoint(
offset=offset, 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 # Cache results for 5 minutes
await cache_set(cache_key, str(results), ttl=300) await cache_set(cache_key, str(results), ttl=300)
return {"cached": False, "results": results} 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) @router.get("/{card_id}", response_model=CardResponse)
async def get_card_endpoint( async def get_card_endpoint(
card_id: int, 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 fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession 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 typing import Optional, List
from app.core.database import get_db, mtg_get_db 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, PrecedentCreate, PrecedentUpdate, PrecedentResponse, PrecedentListResponse,
SuggestionCreate, SuggestionResponse, SuggestionListResponse, SuggestionCreate, SuggestionResponse, SuggestionListResponse,
DeckFinalizeRequest, DeckFinalizeResponse, DeckFinalizeRequest, DeckFinalizeResponse,
CardSearchRequest, CardSearchResponse, CardSearchRequest,
MessageResponse, CountResponse
) )
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() router = APIRouter()
_deck_mgr = DeckManager()
_deck_parser = DeckParser()
# ===== Deck CRUD ===== # ===== Deck CRUD =====
@@ -41,57 +47,50 @@ async def list_user_decks(
): ):
"""List user's decks with optional filtering.""" """List user's decks with optional filtering."""
user_id = int(current_user["user_id"]) user_id = int(current_user["user_id"])
offset = (page - 1) * page_size try:
decks = await _deck_mgr.list_decks(
# Build conditions db=db,
conditions = [UserDeck.user_id == user_id] user_id=user_id,
if status_filter: status_filter=status_filter,
conditions.append(UserDeck.status == status_filter) folder_id=folder_id,
if folder_id: is_precedent=is_precedent,
conditions.append(UserDeck.folder_id == folder_id) page=page,
if is_precedent is not None: page_size=page_size,
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()
) )
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) except ValueError as e:
card_counts = {row[0]: row[1] for row in count_result.fetchall()} raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
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,
)
@router.post("/", response_model=UserDeckResponse, status_code=status.HTTP_201_CREATED) @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).""" """Create a new user deck (DRAFT status)."""
user_id = int(current_user["user_id"]) user_id = int(current_user["user_id"])
try:
# Verify folder exists if specified new_deck = await _deck_mgr.create_deck(
if request.folder_id: db=db,
stmt = select(DecklistFolder).where( user_id=user_id,
DecklistFolder.id == request.folder_id, name=request.name,
DecklistFolder.owner_id == user_id, 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) deck_data = UserDeckResponse.model_validate(new_deck)
folder = result.scalar_one_or_none() deck_data.card_count = 0
if not folder: deck_data.is_owner = True
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found") return deck_data
except HTTPException:
new_deck = UserDeck( raise
user_id=user_id, except ValueError as e:
name=request.name, raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
folder_id=request.folder_id, except Exception as e:
format=request.format, raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
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
@router.get("/{deck_id}", response_model=UserDeckResponse) @router.get("/{deck_id}", response_model=UserDeckResponse)
@@ -140,26 +132,24 @@ async def get_user_deck(
): ):
"""Get a specific user deck.""" """Get a specific user deck."""
user_id = int(current_user["user_id"]) 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( # Get card count
UserDeck.id == deck_id, count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id)
UserDeck.user_id == user_id, count_result = await db.execute(count_stmt)
) card_count = count_result.scalar() or 0
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck: deck_data = UserDeckResponse.model_validate(deck)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") deck_data.card_count = card_count
deck_data.is_owner = True
# Get card count return deck_data
count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id) except HTTPException:
count_result = await db.execute(count_stmt) raise
card_count = count_result.scalar() or 0 except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
deck_data = UserDeckResponse.model_validate(deck)
deck_data.card_count = card_count
deck_data.is_owner = True
return deck_data
@router.patch("/{deck_id}", response_model=UserDeckResponse) @router.patch("/{deck_id}", response_model=UserDeckResponse)
@@ -171,41 +161,36 @@ async def update_user_deck(
): ):
"""Update a user deck.""" """Update a user deck."""
user_id = int(current_user["user_id"]) 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) updated_deck = await _deck_mgr.update_deck(
result = await db.execute(stmt) db=db,
deck = result.scalar_one_or_none() deck_id=deck_id,
user_id=user_id,
**update_data,
)
if not deck: if not updated_deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
# Can't modify FINAL decks # Get card count
if deck.status == "FINAL": count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck") count_result = await db.execute(count_stmt)
card_count = count_result.scalar() or 0
# Update fields deck_data = UserDeckResponse.model_validate(updated_deck)
update_data = request.model_dump(exclude_unset=True) deck_data.card_count = card_count
if "status" in update_data and update_data["status"]: deck_data.is_owner = True
update_data["status"] = update_data["status"].value return deck_data
except HTTPException:
stmt = update(UserDeck).where(UserDeck.id == deck_id).values(**update_data) raise
await db.execute(stmt) except ValueError as e:
await db.flush() if "not found" in str(e).lower():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# Fetch updated deck raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
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
@router.delete("/{deck_id}", response_model=MessageResponse) @router.delete("/{deck_id}", response_model=MessageResponse)
@@ -216,18 +201,15 @@ async def delete_user_deck(
): ):
"""Delete a user deck.""" """Delete a user deck."""
user_id = int(current_user["user_id"]) user_id = int(current_user["user_id"])
try:
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id) result = await _deck_mgr.delete_deck(db=db, deck_id=deck_id, user_id=user_id)
result = await db.execute(stmt) if not result:
deck = result.scalar_one_or_none() raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
return MessageResponse(message="Deck deleted successfully")
if not deck: except HTTPException:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") raise
except Exception as e:
await db.execute(delete(UserDeck).where(UserDeck.id == deck_id)) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
await db.flush()
return MessageResponse(message="Deck deleted successfully")
# ===== Deck Finalize ===== # ===== Deck Finalize =====
@@ -240,34 +222,21 @@ async def finalize_user_deck(
): ):
"""Transition a deck from DRAFT to FINAL status.""" """Transition a deck from DRAFT to FINAL status."""
user_id = int(current_user["user_id"]) user_id = int(current_user["user_id"])
try:
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id) deck = await _deck_mgr.finalize_deck(db=db, deck_id=deck_id, user_id=user_id)
result = await db.execute(stmt) if not deck:
deck = result.scalar_one_or_none() raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
return DeckFinalizeResponse(
if not deck: deck_id=deck_id,
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") status="FINAL",
message="Deck finalized successfully",
if deck.status == "FINAL": )
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Deck is already finalized") except HTTPException:
raise
# Check deck has cards except ValueError as e:
count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id) if "not found" in str(e).lower():
count_result = await db.execute(count_stmt) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
card_count = count_result.scalar() or 0 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
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",
)
# ===== Deck Card Management ===== # ===== Deck Card Management =====
@@ -283,14 +252,12 @@ async def add_deck_card(
user_id = int(current_user["user_id"]) user_id = int(current_user["user_id"])
# Verify deck exists and belongs to user # Verify deck exists and belongs to user
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id) deck_stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt) deck_result = await db.execute(deck_stmt)
deck = result.scalar_one_or_none() deck = deck_result.scalar_one_or_none()
if not deck: if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
# Can't modify FINAL decks
if deck.status == "FINAL": if deck.status == "FINAL":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck") 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: if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
# Build conditions # Use service to get cards
conditions = [UserDeckCard.deck_id == deck_id] deck_cards = await _deck_mgr.get_deck_cards(db=db, deck_id=deck_id, zone=zone)
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()
# Fetch card details from local mirror # Fetch card details from local mirror
card_ids = [dc.card_id for dc in deck_cards] card_ids = [dc.card_id for dc in deck_cards]
@@ -471,6 +431,8 @@ async def remove_deck_card(
# ===== Deck Precedents ===== # ===== Deck Precedents =====
# Note: Precedent endpoints use direct DB operations as DeckManager
# does not yet have precedent-specific methods.
@router.get("/precedents", response_model=PrecedentListResponse) @router.get("/precedents", response_model=PrecedentListResponse)
async def list_precedents( async def list_precedents(
@@ -585,46 +547,22 @@ async def use_precedent(
"""Clone a precedent into a new draft deck for the current user.""" """Clone a precedent into a new draft deck for the current user."""
user_id = int(current_user["user_id"]) user_id = int(current_user["user_id"])
# Get precedent try:
stmt = select(DeckPrecedent).where(DeckPrecedent.id == precedent_id) new_deck = await _deck_mgr.clone_precedent(
result = await db.execute(stmt) db=db,
precedent = result.scalar_one_or_none() precedent_id=precedent_id,
user_id=user_id,
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,
) )
db.add(new_dc) return {
"message": "Precedent cloned into new deck",
await db.flush() "deck_id": new_deck.id,
"deck_name": new_deck.name,
return { "card_count": len(await _deck_mgr.get_deck_cards(db=db, deck_id=new_deck.id)),
"message": "Precedent cloned into new deck", }
"deck_id": new_deck.id, except ValueError as e:
"deck_name": new_deck.name, if "not found" in str(e).lower():
"card_count": len(precedent_cards), 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 ===== # ===== Card Search Integration =====
@@ -697,6 +635,8 @@ async def search_cards_for_deck(
# ===== Card Suggestions ===== # ===== 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) @router.get("/{deck_id}/suggestions", response_model=SuggestionListResponse)
async def get_deck_suggestions( async def get_deck_suggestions(
+11 -3
View File
@@ -19,14 +19,23 @@ from app.models.user_data import (
UserGroup, GroupMember, GroupChatMessage, UserNetwork, UserGroup, GroupMember, GroupChatMessage, UserNetwork,
NetworkMember, UserPreference, UserActivityLog 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 ( from app.schemas.user_data_schemas import (
SessionCleanupResponse, SessionCleanupResponse,
DeckVersionCreate, DeckVersionUpdate, DeckVersionResponse, DeckVersionListResponse, DeckVersionCreate, DeckVersionUpdate, DeckVersionResponse, DeckVersionListResponse,
GameReplayCreate, GameReplayUpdate, GameReplayResponse, GameReplayListResponse, GameReplayCreate, GameReplayUpdate, GameReplayResponse, GameReplayListResponse,
GameOutcomeCreate, GameOutcomeResponse, GameOutcomeListResponse, GameOutcomeCreate, GameOutcomeResponse, GameOutcomeListResponse,
UserStatisticsResponse, StatisticsUpdateResponse, UserStatisticsResponse, StatisticsUpdateResponse,
CardCollectionCreate, CardCollectionUpdate, CardCollectionResponse, CardCollectionListResponse,
WishlistCreate, WishlistUpdate, WishlistResponse, WishlistListResponse,
GroupCreate, GroupUpdate, GroupResponse, GroupListResponse, GroupCreate, GroupUpdate, GroupResponse, GroupListResponse,
GroupMemberCreate, GroupMemberUpdate, GroupMemberRemove, GroupMemberCreate, GroupMemberUpdate, GroupMemberRemove,
GroupChatMessageCreate, GroupChatMessageResponse, GroupChatMessageListResponse, GroupChatMessageCreate, GroupChatMessageResponse, GroupChatMessageListResponse,
@@ -34,7 +43,6 @@ from app.schemas.user_data_schemas import (
NetworkMemberCreate, NetworkMemberCreate,
UserPreferenceUpdate, UserPreferenceResponse, UserPreferenceUpdate, UserPreferenceResponse,
ActivityLogEntry, ActivityLogListResponse, ActivityLogEntry, ActivityLogListResponse,
MessageResponse, CountResponse, ErrorDetail
) )
router = APIRouter() router = APIRouter()
+38 -24
View File
@@ -19,8 +19,6 @@ from app.schemas.user_data_schemas import (
GameReplayCreate, GameReplayUpdate, GameReplayResponse, GameReplayListResponse, GameReplayCreate, GameReplayUpdate, GameReplayResponse, GameReplayListResponse,
GameOutcomeCreate, GameOutcomeResponse, GameOutcomeListResponse, GameOutcomeCreate, GameOutcomeResponse, GameOutcomeListResponse,
UserStatisticsResponse, StatisticsUpdateResponse, UserStatisticsResponse, StatisticsUpdateResponse,
CardCollectionCreate, CardCollectionUpdate, CardCollectionResponse, CardCollectionListResponse,
WishlistCreate, WishlistUpdate, WishlistResponse, WishlistListResponse,
GroupCreate, GroupUpdate, GroupMemberCreate, GroupMemberUpdate, GroupMemberRemove, GroupCreate, GroupUpdate, GroupMemberCreate, GroupMemberUpdate, GroupMemberRemove,
GroupResponse, GroupListResponse, GroupChatMessageCreate, GroupChatMessageResponse, GroupChatMessageListResponse, GroupResponse, GroupListResponse, GroupChatMessageCreate, GroupChatMessageResponse, GroupChatMessageListResponse,
NetworkCreate, NetworkUpdate, NetworkMemberCreate, NetworkCreate, NetworkUpdate, NetworkMemberCreate,
@@ -29,6 +27,12 @@ from app.schemas.user_data_schemas import (
ActivityLogEntry, ActivityLogListResponse, ActivityLogEntry, ActivityLogListResponse,
MessageResponse, CountResponse, ErrorDetail, MessageResponse, CountResponse, ErrorDetail,
) )
from app.schemas.user_card_collection import (
CardCondition, AcquisitionMethod,
CardCollectionCreate, CardCollectionUpdate, CardCollectionResponse, CardCollectionListResponse,
WishlistCreate, WishlistUpdate, WishlistResponse, WishlistListResponse,
CollectionStatistics, CollectionSummaryResponse,
)
from app.schemas.user_deck_schemas import ( from app.schemas.user_deck_schemas import (
DeckStatus, DeckZone, SuggestionType, DeckStatus, DeckZone, SuggestionType,
UserDeckCreate, UserDeckUpdate, UserDeckResponse, UserDeckListResponse, UserDeckCreate, UserDeckUpdate, UserDeckResponse, UserDeckListResponse,
@@ -36,20 +40,30 @@ from app.schemas.user_deck_schemas import (
PrecedentCreate, PrecedentUpdate, PrecedentResponse, PrecedentListResponse, PrecedentCreate, PrecedentUpdate, PrecedentResponse, PrecedentListResponse,
SuggestionCreate, SuggestionResponse, SuggestionListResponse, SuggestionCreate, SuggestionResponse, SuggestionListResponse,
DeckFinalizeRequest, DeckFinalizeResponse, DeckDeleteResponse, DeckFinalizeRequest, DeckFinalizeResponse, DeckDeleteResponse,
CardSearchRequest, CardSearchResponse, CardSearchRequest,
)
from app.schemas.user_card_collection import (
CardCondition, AcquisitionMethod,
CollectionStatistics, CollectionSummaryResponse,
) )
from app.schemas.card_import_schemas import ( from app.schemas.card_import_schemas import (
CardImportRequest, CardImportResponse, CardImportStatusResponse, CardImportRequest, CardImportResponse as CardImportResponseV2, CardImportStatusResponse as CardImportStatusResponseV2,
CardMatchResult, CardImportSummary, CardMatchResult as CardMatchResultV2, CardImportSummary as CardImportSummaryV2,
CardImportBatchCreate, CardImportBatchResponse, CardImportBatchCreate, CardImportBatchResponse,
UserCardImportCreate, UserCardImportResponse, UserCardImportRecordResponse, UserCardImportCreate, UserCardImportResponse, UserCardImportRecordResponse,
) )
from app.schemas.card_search_schemas import ( from app.schemas.card_search_schemas import (
CardResponse, SetResponse, CardTypeResponse, CardResponse, SetResponse, CardTypeResponse,
CardSearchResponse as CardSearchResponseV2,
CardImportResponse as CardImportResponseV3,
CardImportStatusResponse as CardImportStatusResponseV3,
CardMatchResult as CardMatchResultV3,
CardImportSummary as CardImportSummaryV3,
)
from app.schemas.game_schemas import (
GameCreate as GameCreateV2, GameResponse as GameResponseV2, GameJoinRequest, GameLeaveRequest,
GameListResponse, GamePlayerResponse, GameStateResponse,
)
from app.schemas.mtg_card_schemas import (
MtgCardResponse, MtgCardSearchRequest, MtgCardSearchResponse,
MtgSetResponse, MtgCardMirrorResponse,
DeckCardLinkResponse as DeckCardLinkResponseV2, DeckWithCardsResponse as DeckWithCardsResponseV2,
) )
from app.schemas.proto_messages import ( from app.schemas.proto_messages import (
ProtoMessageBase, SessionCommand, GameCommand, GameEvent, Response, ProtoMessageBase, SessionCommand, GameCommand, GameEvent, Response,
@@ -60,14 +74,6 @@ from app.schemas.protocol_constants import (
SessionCommandType, GameCommandType, GameEventType, ResponseCode, SessionCommandType, GameCommandType, GameEventType, ResponseCode,
ZoneType, UserLevelFlag, ZoneType, UserLevelFlag,
) )
from app.schemas.game_schemas import (
GameCreate, GameResponse, GameJoinRequest, GameLeaveRequest,
GameListResponse, GamePlayerResponse, GameStateResponse,
)
from app.schemas.mtg_card_schemas import (
MtgCardResponse, MtgCardSearchRequest, MtgCardSearchResponse,
MtgSetResponse, MtgCardMirrorResponse, DeckCardLinkResponse, DeckWithCardsResponse,
)
__all__ = [ __all__ = [
# Auth # Auth
@@ -96,8 +102,6 @@ __all__ = [
"GameReplayCreate", "GameReplayUpdate", "GameReplayResponse", "GameReplayListResponse", "GameReplayCreate", "GameReplayUpdate", "GameReplayResponse", "GameReplayListResponse",
"GameOutcomeCreate", "GameOutcomeResponse", "GameOutcomeListResponse", "GameOutcomeCreate", "GameOutcomeResponse", "GameOutcomeListResponse",
"UserStatisticsResponse", "StatisticsUpdateResponse", "UserStatisticsResponse", "StatisticsUpdateResponse",
"CardCollectionCreate", "CardCollectionUpdate", "CardCollectionResponse", "CardCollectionListResponse",
"WishlistCreate", "WishlistUpdate", "WishlistResponse", "WishlistListResponse",
"GroupCreate", "GroupUpdate", "GroupMemberCreate", "GroupMemberUpdate", "GroupMemberRemove", "GroupCreate", "GroupUpdate", "GroupMemberCreate", "GroupMemberUpdate", "GroupMemberRemove",
"GroupResponse", "GroupListResponse", "GroupChatMessageCreate", "GroupChatMessageResponse", "GroupChatMessageListResponse", "GroupResponse", "GroupListResponse", "GroupChatMessageCreate", "GroupChatMessageResponse", "GroupChatMessageListResponse",
"NetworkCreate", "NetworkUpdate", "NetworkMemberCreate", "NetworkCreate", "NetworkUpdate", "NetworkMemberCreate",
@@ -105,6 +109,11 @@ __all__ = [
"UserPreferenceUpdate", "UserPreferenceResponse", "UserPreferenceUpdate", "UserPreferenceResponse",
"ActivityLogEntry", "ActivityLogListResponse", "ActivityLogEntry", "ActivityLogListResponse",
"MessageResponse", "CountResponse", "ErrorDetail", "MessageResponse", "CountResponse", "ErrorDetail",
# Card Collection
"CardCondition", "AcquisitionMethod",
"CardCollectionCreate", "CardCollectionUpdate", "CardCollectionResponse", "CardCollectionListResponse",
"WishlistCreate", "WishlistUpdate", "WishlistResponse", "WishlistListResponse",
"CollectionStatistics", "CollectionSummaryResponse",
# User Deck # User Deck
"DeckStatus", "DeckZone", "SuggestionType", "DeckStatus", "DeckZone", "SuggestionType",
"UserDeckCreate", "UserDeckUpdate", "UserDeckResponse", "UserDeckListResponse", "UserDeckCreate", "UserDeckUpdate", "UserDeckResponse", "UserDeckListResponse",
@@ -113,16 +122,21 @@ __all__ = [
"SuggestionCreate", "SuggestionResponse", "SuggestionListResponse", "SuggestionCreate", "SuggestionResponse", "SuggestionListResponse",
"DeckFinalizeRequest", "DeckFinalizeResponse", "DeckDeleteResponse", "DeckFinalizeRequest", "DeckFinalizeResponse", "DeckDeleteResponse",
"CardSearchRequest", "CardSearchResponse", "CardSearchRequest", "CardSearchResponse",
# Card Collection
"CardCondition", "AcquisitionMethod",
"CollectionStatistics", "CollectionSummaryResponse",
# Card Import # Card Import
"CardImportRequest", "CardImportResponse", "CardImportStatusResponse", "CardImportRequest", "CardImportResponseV2", "CardImportStatusResponseV2",
"CardMatchResult", "CardImportSummary", "CardMatchResultV2", "CardImportSummaryV2",
"CardImportBatchCreate", "CardImportBatchResponse", "CardImportBatchCreate", "CardImportBatchResponse",
"UserCardImportCreate", "UserCardImportResponse", "UserCardImportRecordResponse", "UserCardImportCreate", "UserCardImportResponse", "UserCardImportRecordResponse",
# Card Search # Card Search
"CardResponse", "SetResponse", "CardTypeResponse", "CardResponse", "SetResponse", "CardTypeResponse",
"CardSearchResponseV2", "CardImportResponseV3", "CardImportStatusResponseV3",
"CardMatchResultV3", "CardImportSummaryV3",
# Game Schemas
"GameCreateV2", "GameResponseV2",
# MTG Card Schemas
"MtgCardResponse", "MtgCardSearchRequest", "MtgCardSearchResponse",
"MtgSetResponse", "MtgCardMirrorResponse",
"DeckCardLinkResponseV2", "DeckWithCardsResponseV2",
# Proto Messages # Proto Messages
"ProtoMessageBase", "SessionCommand", "GameCommand", "GameEvent", "Response", "ProtoMessageBase", "SessionCommand", "GameCommand", "GameEvent", "Response",
"ServerInfoUser", "ServerInfoDeckStorageFile", "ServerInfoDeckStorageFolder", "ServerInfoUser", "ServerInfoDeckStorageFile", "ServerInfoDeckStorageFolder",
@@ -111,19 +111,3 @@ class UserCardImportRecordResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# Generic response models
class MessageResponse(BaseModel):
"""Generic message response."""
message: str
class CountResponse(BaseModel):
"""Generic count response."""
count: int
class ErrorResponse(BaseModel):
"""Error response with details."""
detail: str
error_code: Optional[str] = None
+4 -5
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for card search and import features.""" """Pydantic schemas for card search and import features."""
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, ConfigDict
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from datetime import datetime from datetime import datetime
@@ -23,8 +23,7 @@ class CardResponse(BaseModel):
identifiers: Optional[Dict[str, Any]] = None identifiers: Optional[Dict[str, Any]] = None
images: Optional[Dict[str, Any]] = None images: Optional[Dict[str, Any]] = None
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
class SetResponse(BaseModel): class SetResponse(BaseModel):
@@ -35,8 +34,7 @@ class SetResponse(BaseModel):
release_date: Optional[datetime] = None release_date: Optional[datetime] = None
card_count: Optional[int] = None card_count: Optional[int] = None
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
class CardTypeResponse(BaseModel): class CardTypeResponse(BaseModel):
@@ -93,6 +91,7 @@ class CardImportSummary(BaseModel):
import_id: Optional[int] = None import_id: Optional[int] = None
# Generic response models
class MessageResponse(BaseModel): class MessageResponse(BaseModel):
"""Generic message response.""" """Generic message response."""
message: str message: str
+4 -9
View File
@@ -1,6 +1,6 @@
"""Pydantic schemas for game features.""" """Pydantic schemas for game features."""
from pydantic import BaseModel, Field, ConfigDict from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List, Dict, Any from typing import Optional, List
from datetime import datetime from datetime import datetime
@@ -10,7 +10,6 @@ class GameCreate(BaseModel):
game_type: Optional[str] = None game_type: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
password: Optional[str] = None password: Optional[str] = None
max_players: int = Field(4, ge=2, le=8)
class GameResponse(BaseModel): class GameResponse(BaseModel):
@@ -31,7 +30,6 @@ class GameResponse(BaseModel):
class GameJoinRequest(BaseModel): class GameJoinRequest(BaseModel):
"""Game join request.""" """Game join request."""
game_id: int game_id: int
password: Optional[str] = None
class GameLeaveRequest(BaseModel): class GameLeaveRequest(BaseModel):
@@ -50,15 +48,12 @@ class GamePlayerResponse(BaseModel):
user_id: int user_id: int
username: str username: str
deck_id: Optional[int] = None deck_id: Optional[int] = None
is_ready: bool = False deck_name: Optional[str] = None
is_host: bool = False
class GameStateResponse(BaseModel): class GameStateResponse(BaseModel):
"""Game state response.""" """Game state response."""
game_id: int game_id: int
state: str
players: List[GamePlayerResponse] players: List[GamePlayerResponse]
turn: int turn: Optional[int] = None
phase: str
zones: Dict[str, Any]
updated_at: datetime
+29
View File
@@ -0,0 +1,29 @@
"""Generic response schemas used across multiple modules."""
from pydantic import BaseModel
from typing import Optional, List
class MessageResponse(BaseModel):
"""Generic message response."""
message: str
class CountResponse(BaseModel):
"""Generic count response."""
count: int
class ErrorResponse(BaseModel):
"""Standard error response."""
detail: str
class ValidationErrorResponse(BaseModel):
"""Validation error response."""
detail: List[dict]
class ErrorDetail(BaseModel):
"""Error detail."""
error: str
detail: str
+51 -57
View File
@@ -1,33 +1,33 @@
"""Pydantic schemas for MTG card data.""" """Pydantic schemas for MTG card data."""
from pydantic import BaseModel, Field, ConfigDict from pydantic import BaseModel, ConfigDict
from typing import Optional, List, Dict, Any from typing import Optional, List
from datetime import datetime from datetime import datetime
class MtgCardResponse(BaseModel): class MtgCardResponse(BaseModel):
"""MTG card response with full details.""" """MTG card response."""
id: int id: int
source_id: Optional[int] = None source_id: Optional[int]
name: str name: str
mana_cost: Optional[str] = None mana_cost: Optional[str]
type_line: Optional[str] = None type_line: Optional[str]
oracle_text: Optional[str] = None oracle_text: Optional[str]
power: Optional[str] = None power: Optional[str]
toughness: Optional[str] = None toughness: Optional[str]
rarity: Optional[str] = None rarity: Optional[str]
layout: Optional[str] = None layout: Optional[str]
artist: Optional[str] = None artist: Optional[str]
flavor_text: Optional[str] = None flavor_text: Optional[str]
numbers: Optional[str] = None numbers: Optional[str]
identifiers: Optional[Dict[str, Any]] = None identifiers: Optional[str]
images: Optional[Dict[str, Any]] = None images: Optional[str]
image: Optional[str] = None image: Optional[str]
card_parts: Optional[List[str]] = None card_parts: Optional[str]
keywords: Optional[List[str]] = None keywords: Optional[str]
legalities: Optional[Dict[str, str]] = None legalities: Optional[str]
set_code: Optional[str] = None set_code: Optional[str]
set_name: Optional[str] = None set_name: Optional[str]
synced_at: Optional[datetime] = None synced_at: Optional[datetime]
created_at: datetime created_at: datetime
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -35,12 +35,9 @@ class MtgCardResponse(BaseModel):
class MtgCardSearchRequest(BaseModel): class MtgCardSearchRequest(BaseModel):
"""MTG card search request.""" """MTG card search request."""
query: str = Field(..., min_length=1, max_length=100) query: str
set_code: Optional[str] = None limit: int = 50
rarity: Optional[str] = None offset: int = 0
type_line: Optional[str] = None
limit: int = Field(50, ge=1, le=200)
offset: int = Field(0, ge=0)
class MtgCardSearchResponse(BaseModel): class MtgCardSearchResponse(BaseModel):
@@ -55,41 +52,38 @@ class MtgCardSearchResponse(BaseModel):
class MtgSetResponse(BaseModel): class MtgSetResponse(BaseModel):
"""MTG set response.""" """MTG set response."""
id: int id: int
code: str
name: str name: str
release_date: Optional[datetime] = None code: str
card_count: Optional[int] = None release_date: Optional[datetime]
type: Optional[str] = None card_count: Optional[int]
border: Optional[str] = None
mcm_id: Optional[int] = None
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class MtgCardMirrorResponse(BaseModel): class MtgCardMirrorResponse(BaseModel):
"""Mirrored card data for user decks.""" """Mirrored card data response."""
id: int id: int
source_id: Optional[int] = None source_id: Optional[int]
name: str name: str
mana_cost: Optional[str] = None mana_cost: Optional[str]
type_line: Optional[str] = None type_line: Optional[str]
oracle_text: Optional[str] = None oracle_text: Optional[str]
power: Optional[str] = None power: Optional[str]
toughness: Optional[str] = None toughness: Optional[str]
rarity: Optional[str] = None rarity: Optional[str]
layout: Optional[str] = None layout: Optional[str]
artist: Optional[str] = None artist: Optional[str]
flavor_text: Optional[str] = None flavor_text: Optional[str]
numbers: Optional[str] = None numbers: Optional[str]
identifiers: Optional[Dict[str, Any]] = None identifiers: Optional[str]
images: Optional[Dict[str, Any]] = None images: Optional[str]
image: Optional[str] = None image: Optional[str]
card_parts: Optional[str] = None card_parts: Optional[str]
keywords: Optional[str] = None keywords: Optional[str]
legalities: Optional[Dict[str, str]] = None legalities: Optional[str]
set_code: Optional[str] = None set_code: Optional[str]
set_name: Optional[str] = None set_name: Optional[str]
synced_at: Optional[datetime] = None synced_at: Optional[datetime]
created_at: datetime created_at: datetime
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -114,7 +108,7 @@ class DeckWithCardsResponse(BaseModel):
content: str content: str
format: str format: str
status: str status: str
folder_id: Optional[int] = None folder_id: Optional[int]
owner_id: int owner_id: int
creation_date: datetime creation_date: datetime
card_links: List[DeckCardLinkResponse] = [] card_links: List[DeckCardLinkResponse] = []
+13 -21
View File
@@ -3,7 +3,7 @@ Pydantic schemas for request/response validation.
Provides typed data structures for API endpoints. Provides typed data structures for API endpoints.
""" """
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field, ConfigDict
from typing import Optional, List, Dict from typing import Optional, List, Dict
from datetime import datetime from datetime import datetime
@@ -49,8 +49,8 @@ class UserCreate(UserBase):
"""User registration fields.""" """User registration fields."""
password: str = Field(..., min_length=8) password: str = Field(..., min_length=8)
class Config: model_config = ConfigDict(
json_schema_extra = { json_schema_extra={
"example": { "example": {
"username": "player123", "username": "player123",
"password": "securepassword123", "password": "securepassword123",
@@ -58,6 +58,7 @@ class UserCreate(UserBase):
"country": "US" "country": "US"
} }
} }
)
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
@@ -83,8 +84,7 @@ class UserResponse(BaseModel):
creation_date: datetime creation_date: datetime
last_login: Optional[datetime] last_login: Optional[datetime]
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
# ===== Deck Schemas ===== # ===== Deck Schemas =====
@@ -117,8 +117,7 @@ class DeckResponse(BaseModel):
owner_id: int owner_id: int
creation_date: datetime creation_date: datetime
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
class FolderCreate(BaseModel): class FolderCreate(BaseModel):
@@ -135,8 +134,7 @@ class FolderResponse(BaseModel):
owner_id: int owner_id: int
creation_date: datetime creation_date: datetime
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
# ===== Game Schemas ===== # ===== Game Schemas =====
@@ -161,8 +159,7 @@ class GameResponse(BaseModel):
started: bool started: bool
creation_date: datetime creation_date: datetime
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
# ===== Room Schemas ===== # ===== Room Schemas =====
@@ -177,8 +174,7 @@ class RoomResponse(BaseModel):
player_count: int = 0 player_count: int = 0
creation_date: datetime creation_date: datetime
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
# ===== Ban Schemas ===== # ===== Ban Schemas =====
@@ -200,8 +196,7 @@ class BanResponse(BaseModel):
active: bool active: bool
creation_date: datetime creation_date: datetime
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
# ===== Auth Error Responses ===== # ===== Auth Error Responses =====
@@ -261,8 +256,7 @@ class CardMirrorResponse(BaseModel):
synced_at: Optional[datetime] synced_at: Optional[datetime]
created_at: datetime created_at: datetime
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
class DeckCardLinkResponse(BaseModel): class DeckCardLinkResponse(BaseModel):
@@ -274,8 +268,7 @@ class DeckCardLinkResponse(BaseModel):
zone: str zone: str
card: CardMirrorResponse card: CardMirrorResponse
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
class DeckWithCardsResponse(BaseModel): class DeckWithCardsResponse(BaseModel):
@@ -290,5 +283,4 @@ class DeckWithCardsResponse(BaseModel):
creation_date: datetime creation_date: datetime
card_links: List[DeckCardLinkResponse] = [] card_links: List[DeckCardLinkResponse] = []
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
+4 -6
View File
@@ -3,7 +3,7 @@ Pydantic schemas for user card collection features.
Covers card collection CRUD operations, wishlist management, and collection statistics. Covers card collection CRUD operations, wishlist management, and collection statistics.
""" """
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
@@ -73,8 +73,7 @@ class CardCollectionResponse(BaseModel):
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
class CardCollectionListResponse(BaseModel): class CardCollectionListResponse(BaseModel):
@@ -90,7 +89,7 @@ class CardCollectionListResponse(BaseModel):
class WishlistCreate(BaseModel): class WishlistCreate(BaseModel):
"""Wishlist item creation request.""" """Wishlist item creation request."""
card_id: int card_id: Optional[int] = None
max_price: Optional[float] = None max_price: Optional[float] = None
notes: Optional[str] = None notes: Optional[str] = None
@@ -110,8 +109,7 @@ class WishlistResponse(BaseModel):
notes: Optional[str] notes: Optional[str]
created_at: datetime created_at: datetime
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
class WishlistListResponse(BaseModel): class WishlistListResponse(BaseModel):
+15 -107
View File
@@ -3,7 +3,7 @@ Pydantic schemas for user data features.
Covers sessions, decks, replays, cards, groups, networks, preferences, and activity logs. Covers sessions, decks, replays, cards, groups, networks, preferences, and activity logs.
""" """
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
@@ -72,8 +72,7 @@ class SessionResponse(BaseModel):
expires_at: datetime expires_at: datetime
is_active: bool is_active: bool
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
class SessionCleanupResponse(BaseModel): class SessionCleanupResponse(BaseModel):
@@ -108,8 +107,7 @@ class DeckVersionResponse(BaseModel):
comment: Optional[str] comment: Optional[str]
created_at: datetime created_at: datetime
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
class DeckVersionListResponse(BaseModel): class DeckVersionListResponse(BaseModel):
@@ -160,8 +158,7 @@ class GameReplayResponse(BaseModel):
updated_at: datetime updated_at: datetime
players: List[Dict[str, Any]] = [] players: List[Dict[str, Any]] = []
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
class GameReplayListResponse(BaseModel): class GameReplayListResponse(BaseModel):
@@ -199,8 +196,7 @@ class GameOutcomeResponse(BaseModel):
rating_change: Optional[int] rating_change: Optional[int]
created_at: datetime created_at: datetime
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
class GameOutcomeListResponse(BaseModel): class GameOutcomeListResponse(BaseModel):
@@ -225,8 +221,7 @@ class UserStatisticsResponse(BaseModel):
last_game_date: Optional[datetime] last_game_date: Optional[datetime]
updated_at: datetime updated_at: datetime
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
class StatisticsUpdateResponse(BaseModel): class StatisticsUpdateResponse(BaseModel):
@@ -240,94 +235,12 @@ class StatisticsUpdateResponse(BaseModel):
updated_at: datetime updated_at: datetime
# ===== Card Collection Schemas ===== # ===== Card Collection Schemas (moved to user_card_collection.py) =====
# These are kept here for backward compatibility but should be imported from user_card_collection.py
class CardCollectionCreate(BaseModel):
"""Card collection item creation request."""
card_id: int
quantity: int = Field(1, ge=1)
condition: str = Field("NEAR_MINT", max_length=20)
language: str = Field("EN", max_length=5)
is_foil: bool = False
is_alt_art: bool = False
acquired_date: Optional[datetime] = None
acquisition_method: Optional[str] = None
notes: Optional[str] = None
class CardCollectionUpdate(BaseModel): # ===== Wishlist Schemas (moved to user_card_collection.py) =====
"""Card collection item update request.""" # These are kept here for backward compatibility but should be imported from user_card_collection.py
quantity: Optional[int] = None
condition: Optional[str] = None
language: Optional[str] = None
is_foil: Optional[bool] = None
is_alt_art: Optional[bool] = None
acquired_date: Optional[datetime] = None
acquisition_method: Optional[str] = None
notes: Optional[str] = None
class CardCollectionResponse(BaseModel):
"""Card collection item response."""
id: int
user_id: int
card_id: int
quantity: int
condition: str
language: str
is_foil: bool
is_alt_art: bool
acquired_date: Optional[datetime]
acquisition_method: Optional[str]
notes: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class CardCollectionListResponse(BaseModel):
"""List of user card collection."""
cards: List[CardCollectionResponse]
total: int
page: int
page_size: int
total_pages: int
# ===== Wishlist Schemas =====
class WishlistCreate(BaseModel):
"""Wishlist item creation request."""
card_id: int
max_price: Optional[float] = None
notes: Optional[str] = None
class WishlistUpdate(BaseModel):
"""Wishlist item update request."""
max_price: Optional[float] = None
notes: Optional[str] = None
class WishlistResponse(BaseModel):
"""Wishlist item response."""
id: int
user_id: int
card_id: int
max_price: Optional[float]
notes: Optional[str]
created_at: datetime
class Config:
from_attributes = True
class WishlistListResponse(BaseModel):
"""List of wishlist items."""
items: List[WishlistResponse]
total: int
# ===== Group Schemas ===== # ===== Group Schemas =====
@@ -377,8 +290,7 @@ class GroupResponse(BaseModel):
member_count: int = 0 member_count: int = 0
is_member: bool = False is_member: bool = False
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
class GroupListResponse(BaseModel): class GroupListResponse(BaseModel):
@@ -401,8 +313,7 @@ class GroupChatMessageResponse(BaseModel):
message: str message: str
created_at: datetime created_at: datetime
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
class GroupChatMessageListResponse(BaseModel): class GroupChatMessageListResponse(BaseModel):
@@ -447,8 +358,7 @@ class NetworkResponse(BaseModel):
member_count: int = 0 member_count: int = 0
is_member: bool = False is_member: bool = False
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
class NetworkListResponse(BaseModel): class NetworkListResponse(BaseModel):
@@ -480,8 +390,7 @@ class UserPreferenceResponse(BaseModel):
language: str language: str
updated_at: datetime updated_at: datetime
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
# ===== Activity Log Schemas ===== # ===== Activity Log Schemas =====
@@ -495,8 +404,7 @@ class ActivityLogEntry(BaseModel):
ip_address: Optional[str] ip_address: Optional[str]
created_at: datetime created_at: datetime
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
class ActivityLogListResponse(BaseModel): class ActivityLogListResponse(BaseModel):
+9 -34
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for user deck building features.""" """Pydantic schemas for user deck building features."""
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
@@ -48,6 +48,8 @@ class UserDeckUpdate(BaseModel):
class UserDeckResponse(BaseModel): class UserDeckResponse(BaseModel):
"""Deck response with card count.""" """Deck response with card count."""
model_config = ConfigDict(from_attributes=True)
id: int id: int
user_id: int user_id: int
name: str name: str
@@ -62,9 +64,6 @@ class UserDeckResponse(BaseModel):
card_count: int = 0 card_count: int = 0
is_owner: bool = False is_owner: bool = False
class Config:
from_attributes = True
class UserDeckListResponse(BaseModel): class UserDeckListResponse(BaseModel):
"""List of user decks.""" """List of user decks."""
@@ -94,16 +93,14 @@ class DeckCardUpdate(BaseModel):
class DeckCardResponse(BaseModel): class DeckCardResponse(BaseModel):
"""Deck card response.""" """Deck card response."""
model_config = ConfigDict(from_attributes=True)
id: int id: int
deck_id: int deck_id: int
card_id: int card_id: int
quantity: int quantity: int
zone: str zone: str
position: Optional[int] position: Optional[int]
created_at: datetime
class Config:
from_attributes = True
class DeckCardWithDetailsResponse(DeckCardResponse): class DeckCardWithDetailsResponse(DeckCardResponse):
@@ -139,6 +136,8 @@ class PrecedentUpdate(BaseModel):
class PrecedentResponse(BaseModel): class PrecedentResponse(BaseModel):
"""Deck precedent response.""" """Deck precedent response."""
model_config = ConfigDict(from_attributes=True)
id: int id: int
name: str name: str
description: Optional[str] description: Optional[str]
@@ -149,9 +148,6 @@ class PrecedentResponse(BaseModel):
updated_at: datetime updated_at: datetime
card_count: int = 0 card_count: int = 0
class Config:
from_attributes = True
class PrecedentListResponse(BaseModel): class PrecedentListResponse(BaseModel):
"""List of deck precedents.""" """List of deck precedents."""
@@ -172,6 +168,8 @@ class SuggestionCreate(BaseModel):
class SuggestionResponse(BaseModel): class SuggestionResponse(BaseModel):
"""Card suggestion response.""" """Card suggestion response."""
model_config = ConfigDict(from_attributes=True)
id: int id: int
deck_id: int deck_id: int
card_id: int card_id: int
@@ -182,9 +180,6 @@ class SuggestionResponse(BaseModel):
created_at: datetime created_at: datetime
card_name: str = "" card_name: str = ""
class Config:
from_attributes = True
class SuggestionListResponse(BaseModel): class SuggestionListResponse(BaseModel):
"""List of card suggestions.""" """List of card suggestions."""
@@ -220,23 +215,3 @@ class CardSearchRequest(BaseModel):
limit: int = Field(50, ge=1, le=200) limit: int = Field(50, ge=1, le=200)
offset: int = Field(0, ge=0) offset: int = Field(0, ge=0)
class CardSearchResponse(BaseModel):
"""Card search response."""
cards: List[Dict[str, Any]]
total: int
page: int
page_size: int
total_pages: int
# ===== Generic Schemas =====
class MessageResponse(BaseModel):
"""Generic message response."""
message: str
class CountResponse(BaseModel):
"""Generic count response."""
count: int
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Test router imports and FastAPI app creation."""
import sys
import traceback
def test_router_imports():
"""Test that all routers can be imported."""
print("Testing router imports...")
try:
from app.routers import *
print("✅ All routers imported successfully")
return True
except Exception as e:
print(f"❌ Router import error: {e}")
traceback.print_exc()
return False
def test_fastapi_app():
"""Test that FastAPI app can be created."""
print("\nTesting FastAPI app creation...")
try:
from app.main import app
print("✅ FastAPI app created successfully")
print(f" App title: {app.title}")
print(f" App version: {app.version}")
return True
except Exception as e:
print(f"❌ FastAPI app creation error: {e}")
traceback.print_exc()
return False
def test_schema_imports():
"""Test that schemas are properly imported."""
print("\nTesting schema imports...")
try:
from app.schemas import (
MessageResponse, CountResponse,
CardCollectionCreate, CardCollectionResponse,
WishlistCreate, WishlistResponse,
)
print("✅ Schema imports successful")
return True
except Exception as e:
print(f"❌ Schema import error: {e}")
traceback.print_exc()
return False
if __name__ == "__main__":
print("=" * 60)
print("Phase 4: Router Layer Testing")
print("=" * 60)
results = []
results.append(("Router Imports", test_router_imports()))
results.append(("FastAPI App", test_fastapi_app()))
results.append(("Schema Imports", test_schema_imports()))
print("\n" + "=" * 60)
print("Test Results Summary")
print("=" * 60)
for test_name, passed in results:
status = "✅ PASS" if passed else "❌ FAIL"
print(f"{test_name:20} {status}")
all_passed = all(result[1] for result in results)
print("\n" + ("=" * 60))
if all_passed:
print("✅ ALL TESTS PASSED")
sys.exit(0)
else:
print("❌ SOME TESTS FAILED")
sys.exit(1)
+319
View File
@@ -0,0 +1,319 @@
"""Comprehensive schema validation tests."""
from datetime import datetime
from app.schemas import *
def test_user_schemas():
"""Test user-related schemas."""
print("Testing User Schemas...")
# Test UserCreate
user = UserCreate(
username="testuser",
password="securepass123",
email="test@example.com",
country="US"
)
assert user.username == "testuser"
assert user.password == "securepass123"
print(" ✅ UserCreate validates")
# Test UserResponse
user_resp = UserResponse(
id=1,
username="testuser",
email="test@example.com",
country="US",
real_name="Test User",
privlevel="User",
vip_status=0,
is_active=True,
is_banned=False,
ban_reason=None,
creation_date=datetime.now(),
last_login=None
)
assert user_resp.id == 1
print(" ✅ UserResponse validates")
# Test UserUpdate
update = UserUpdate(
email="new@example.com",
country="UK",
real_name="Updated Name",
new_password="newsecurepass"
)
assert update.email == "new@example.com"
print(" ✅ UserUpdate validates")
def test_deck_schemas():
"""Test deck-related schemas."""
print("Testing Deck Schemas...")
# Test DeckCreate
deck = DeckCreate(
name="Test Deck",
content="4 Lightning Bolt\n4 Shock",
format="native",
status="DRAUGHT"
)
assert deck.name == "Test Deck"
print(" ✅ DeckCreate validates")
# Test UserDeckResponse
deck_resp = UserDeckResponse(
id=1,
user_id=1,
name="Test Deck",
status="DRAFT",
folder_id=None,
format="standard",
notes=None,
is_precedent=False,
precedent_name=None,
created_at=datetime.now(),
updated_at=datetime.now(),
card_count=0,
is_owner=True
)
assert deck_resp.id == 1
print(" ✅ UserDeckResponse validates")
# Test DeckCardResponse
card = DeckCardResponse(
id=1,
deck_id=1,
card_id=100,
quantity=4,
zone="main",
position=None
)
assert card.quantity == 4
assert card.zone == "main"
print(" ✅ DeckCardResponse validates")
def test_card_schemas():
"""Test card-related schemas."""
print("Testing Card Schemas...")
# Test CardCollectionCreate
card = CardCollectionCreate(
card_id=1,
quantity=4,
condition="NEAR_MINT",
language="EN",
is_foil=False
)
assert card.quantity == 4
print(" ✅ CardCollectionCreate validates")
# Test MtgCardResponse
mtg_card = MtgCardResponse(
id=1,
name="Lightning Bolt",
mana_cost="{R}",
type_line="Instant",
oracle_text="Lightning Bolt deals 3 damage to any target.",
power=None,
toughness=None,
rarity="Common",
layout="normal",
artist="Dan Frazier",
created_at=datetime.now()
)
assert mtg_card.name == "Lightning Bolt"
print(" ✅ MtgCardResponse validates")
# Test CardImportBatchCreate
batch = CardImportBatchCreate(
user_id=1,
card_names=["Lightning Bolt", "Shock", "Thoughtseize"],
source="manual"
)
assert len(batch.card_names) == 3
print(" ✅ CardImportBatchCreate validates")
def test_proto_schemas():
"""Test protocol schemas."""
print("Testing Protocol Schemas...")
# Test SessionCommand
cmd = SessionCommand(
message_type="SessionCommand",
cmd_type=1001,
cmd_id=1,
data={"username": "test"}
)
assert cmd.cmd_type == 1001
print(" ✅ SessionCommand validates")
# Test GameEvent
event = GameEvent(
message_type="GameEvent",
event_type=1000,
player_id=1,
data={"game_id": 123}
)
assert event.event_type == 1000
print(" ✅ GameEvent validates")
def test_group_schemas():
"""Test group-related schemas."""
print("Testing Group Schemas...")
# Test GroupCreate
group = GroupCreate(
name="Test Group",
description="A test group",
is_public=True,
max_members=50
)
assert group.name == "Test Group"
print(" ✅ GroupCreate validates")
# Test GroupResponse
group_resp = GroupResponse(
id=1,
name="Test Group",
description="A test group",
owner_id=1,
is_public=True,
max_members=50,
created_at=datetime.now(),
updated_at=datetime.now(),
member_count=5,
is_member=True
)
assert group_resp.id == 1
print(" ✅ GroupResponse validates")
def test_network_schemas():
"""Test network-related schemas."""
print("Testing Network Schemas...")
# Test NetworkCreate
network = NetworkCreate(
name="Test Network",
description="A test network",
is_public=True
)
assert network.name == "Test Network"
print(" ✅ NetworkCreate validates")
# Test NetworkResponse
network_resp = NetworkResponse(
id=1,
name="Test Network",
description="A test network",
creator_id=1,
is_public=True,
created_at=datetime.now(),
member_count=10,
is_member=False
)
assert network_resp.id == 1
print(" ✅ NetworkResponse validates")
def test_validation_errors():
"""Test validation error handling."""
print("Testing Validation Errors...")
# Test invalid username (too short)
try:
UserCreate(username="ab", password="securepass123")
assert False, "Should have raised validation error"
except Exception as e:
assert "min_length" in str(e)
print(" ✅ Username length validation works")
# Test invalid password (too short)
try:
UserCreate(username="testuser", password="123")
assert False, "Should have raised validation error"
except Exception as e:
assert "min_length" in str(e)
print(" ✅ Password length validation works")
# Test invalid country code
try:
UserCreate(username="testuser", password="securepass123", country="USA")
assert False, "Should have raised validation error"
except Exception as e:
assert "max_length" in str(e)
print(" ✅ Country code length validation works")
def test_enum_schemas():
"""Test enum-based schemas."""
print("Testing Enum Schemas...")
# Test DeckStatus
assert DeckStatus.DRAFT.value == "DRAFT"
assert DeckStatus.FINAL.value == "FINAL"
print(" ✅ DeckStatus enum validates")
# Test DeckZone
assert DeckZone.MAIN.value == "main"
assert DeckZone.SIDEBOARD.value == "sideboard"
print(" ✅ DeckZone enum validates")
# Test CardCondition
assert CardCondition.NEAR_MINT.value == "NEAR_MINT"
assert CardCondition.HEAVILY_PLAYED.value == "HEAVILY_PLAYED"
print(" ✅ CardCondition enum validates")
def test_generic_schemas():
"""Test generic response schemas."""
print("Testing Generic Schemas...")
# Test MessageResponse
msg = MessageResponse(message="Success")
assert msg.message == "Success"
print(" ✅ MessageResponse validates")
# Test CountResponse
count = CountResponse(count=42)
assert count.count == 42
print(" ✅ CountResponse validates")
# Test ErrorResponse
error = ErrorResponse(detail="Something went wrong")
assert error.detail == "Something went wrong"
print(" ✅ ErrorResponse validates")
def main():
print("Schema Validation Tests")
print("=" * 60)
try:
test_user_schemas()
test_deck_schemas()
test_card_schemas()
test_proto_schemas()
test_group_schemas()
test_network_schemas()
test_validation_errors()
test_enum_schemas()
test_generic_schemas()
print("\n" + "=" * 60)
print("✅ All schema validation tests passed!")
return 0
except Exception as e:
print(f"\n❌ Test failed: {e}")
import traceback
traceback.print_exc()
return 1
if __name__ == "__main__":
import sys
sys.exit(main())