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:
+1
-1
@@ -42,7 +42,7 @@ prepend_sys_path = .
|
||||
# This is useful for files that will be opened in Windows editors.
|
||||
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]
|
||||
|
||||
@@ -19,13 +19,13 @@ class Settings(BaseSettings):
|
||||
JWT_SECRET_KEY: str = "change-me-in-production"
|
||||
|
||||
# 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)
|
||||
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_URL: str = "redis://redis:6379/0"
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
# JWT Configuration
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
|
||||
+5
-4
@@ -24,7 +24,8 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.settings import get_settings
|
||||
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
|
||||
|
||||
|
||||
@@ -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(decks.router, prefix="/decks", tags=["Decks"])
|
||||
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(card_router.router, prefix="/api", tags=["MTG Cards"])
|
||||
app.include_router(interactions.router, tags=["Card Interactions"])
|
||||
app.include_router(card_router.router, tags=["MTG Cards"])
|
||||
app.include_router(interactions.router)
|
||||
app.include_router(refresh.router)
|
||||
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"])
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
+86
-146
@@ -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,28 +47,18 @@ async def list_user_decks(
|
||||
):
|
||||
"""List user's decks with optional filtering."""
|
||||
user_id = int(current_user["user_id"])
|
||||
offset = (page - 1) * page_size
|
||||
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,
|
||||
)
|
||||
|
||||
# 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
|
||||
# Build response with card counts
|
||||
deck_ids = [d.id for d in decks]
|
||||
card_counts = {}
|
||||
if deck_ids:
|
||||
@@ -85,6 +81,7 @@ async def list_user_decks(
|
||||
deck_data.is_owner = True
|
||||
deck_responses.append(deck_data)
|
||||
|
||||
total = len(decks)
|
||||
return UserDeckListResponse(
|
||||
decks=deck_responses,
|
||||
total=total,
|
||||
@@ -92,6 +89,8 @@ async def list_user_decks(
|
||||
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,19 +101,9 @@ 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,
|
||||
)
|
||||
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(
|
||||
try:
|
||||
new_deck = await _deck_mgr.create_deck(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
name=request.name,
|
||||
folder_id=request.folder_id,
|
||||
@@ -123,13 +112,16 @@ async def create_user_deck(
|
||||
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
|
||||
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,14 +132,8 @@ async def get_user_deck(
|
||||
):
|
||||
"""Get a specific 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()
|
||||
|
||||
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")
|
||||
|
||||
@@ -160,6 +146,10 @@ async def get_user_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,31 +161,20 @@ async def update_user_deck(
|
||||
):
|
||||
"""Update 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")
|
||||
|
||||
# Can't modify FINAL decks
|
||||
if deck.status == "FINAL":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
|
||||
|
||||
# Update fields
|
||||
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 = update(UserDeck).where(UserDeck.id == deck_id).values(**update_data)
|
||||
await db.execute(stmt)
|
||||
await db.flush()
|
||||
updated_deck = await _deck_mgr.update_deck(
|
||||
db=db,
|
||||
deck_id=deck_id,
|
||||
user_id=user_id,
|
||||
**update_data,
|
||||
)
|
||||
|
||||
# Fetch updated deck
|
||||
stmt = select(UserDeck).where(UserDeck.id == deck_id)
|
||||
result = await db.execute(stmt)
|
||||
updated_deck = result.scalar_one_or_none()
|
||||
if not updated_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)
|
||||
@@ -206,6 +185,12 @@ async def update_user_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:
|
||||
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")
|
||||
|
||||
await db.execute(delete(UserDeck).where(UserDeck.id == deck_id))
|
||||
await db.flush()
|
||||
|
||||
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()
|
||||
|
||||
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")
|
||||
|
||||
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",
|
||||
)
|
||||
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(
|
||||
try:
|
||||
new_deck = await _deck_mgr.clone_precedent(
|
||||
db=db,
|
||||
precedent_id=precedent_id,
|
||||
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)
|
||||
|
||||
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),
|
||||
"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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -19,8 +19,6 @@ from app.schemas.user_data_schemas import (
|
||||
GameReplayCreate, GameReplayUpdate, GameReplayResponse, GameReplayListResponse,
|
||||
GameOutcomeCreate, GameOutcomeResponse, GameOutcomeListResponse,
|
||||
UserStatisticsResponse, StatisticsUpdateResponse,
|
||||
CardCollectionCreate, CardCollectionUpdate, CardCollectionResponse, CardCollectionListResponse,
|
||||
WishlistCreate, WishlistUpdate, WishlistResponse, WishlistListResponse,
|
||||
GroupCreate, GroupUpdate, GroupMemberCreate, GroupMemberUpdate, GroupMemberRemove,
|
||||
GroupResponse, GroupListResponse, GroupChatMessageCreate, GroupChatMessageResponse, GroupChatMessageListResponse,
|
||||
NetworkCreate, NetworkUpdate, NetworkMemberCreate,
|
||||
@@ -29,6 +27,12 @@ from app.schemas.user_data_schemas import (
|
||||
ActivityLogEntry, ActivityLogListResponse,
|
||||
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 (
|
||||
DeckStatus, DeckZone, SuggestionType,
|
||||
UserDeckCreate, UserDeckUpdate, UserDeckResponse, UserDeckListResponse,
|
||||
@@ -36,20 +40,30 @@ from app.schemas.user_deck_schemas import (
|
||||
PrecedentCreate, PrecedentUpdate, PrecedentResponse, PrecedentListResponse,
|
||||
SuggestionCreate, SuggestionResponse, SuggestionListResponse,
|
||||
DeckFinalizeRequest, DeckFinalizeResponse, DeckDeleteResponse,
|
||||
CardSearchRequest, CardSearchResponse,
|
||||
)
|
||||
from app.schemas.user_card_collection import (
|
||||
CardCondition, AcquisitionMethod,
|
||||
CollectionStatistics, CollectionSummaryResponse,
|
||||
CardSearchRequest,
|
||||
)
|
||||
from app.schemas.card_import_schemas import (
|
||||
CardImportRequest, CardImportResponse, CardImportStatusResponse,
|
||||
CardMatchResult, CardImportSummary,
|
||||
CardImportRequest, CardImportResponse as CardImportResponseV2, CardImportStatusResponse as CardImportStatusResponseV2,
|
||||
CardMatchResult as CardMatchResultV2, CardImportSummary as CardImportSummaryV2,
|
||||
CardImportBatchCreate, CardImportBatchResponse,
|
||||
UserCardImportCreate, UserCardImportResponse, UserCardImportRecordResponse,
|
||||
)
|
||||
from app.schemas.card_search_schemas import (
|
||||
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 (
|
||||
ProtoMessageBase, SessionCommand, GameCommand, GameEvent, Response,
|
||||
@@ -60,14 +74,6 @@ from app.schemas.protocol_constants import (
|
||||
SessionCommandType, GameCommandType, GameEventType, ResponseCode,
|
||||
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__ = [
|
||||
# Auth
|
||||
@@ -96,8 +102,6 @@ __all__ = [
|
||||
"GameReplayCreate", "GameReplayUpdate", "GameReplayResponse", "GameReplayListResponse",
|
||||
"GameOutcomeCreate", "GameOutcomeResponse", "GameOutcomeListResponse",
|
||||
"UserStatisticsResponse", "StatisticsUpdateResponse",
|
||||
"CardCollectionCreate", "CardCollectionUpdate", "CardCollectionResponse", "CardCollectionListResponse",
|
||||
"WishlistCreate", "WishlistUpdate", "WishlistResponse", "WishlistListResponse",
|
||||
"GroupCreate", "GroupUpdate", "GroupMemberCreate", "GroupMemberUpdate", "GroupMemberRemove",
|
||||
"GroupResponse", "GroupListResponse", "GroupChatMessageCreate", "GroupChatMessageResponse", "GroupChatMessageListResponse",
|
||||
"NetworkCreate", "NetworkUpdate", "NetworkMemberCreate",
|
||||
@@ -105,6 +109,11 @@ __all__ = [
|
||||
"UserPreferenceUpdate", "UserPreferenceResponse",
|
||||
"ActivityLogEntry", "ActivityLogListResponse",
|
||||
"MessageResponse", "CountResponse", "ErrorDetail",
|
||||
# Card Collection
|
||||
"CardCondition", "AcquisitionMethod",
|
||||
"CardCollectionCreate", "CardCollectionUpdate", "CardCollectionResponse", "CardCollectionListResponse",
|
||||
"WishlistCreate", "WishlistUpdate", "WishlistResponse", "WishlistListResponse",
|
||||
"CollectionStatistics", "CollectionSummaryResponse",
|
||||
# User Deck
|
||||
"DeckStatus", "DeckZone", "SuggestionType",
|
||||
"UserDeckCreate", "UserDeckUpdate", "UserDeckResponse", "UserDeckListResponse",
|
||||
@@ -113,16 +122,21 @@ __all__ = [
|
||||
"SuggestionCreate", "SuggestionResponse", "SuggestionListResponse",
|
||||
"DeckFinalizeRequest", "DeckFinalizeResponse", "DeckDeleteResponse",
|
||||
"CardSearchRequest", "CardSearchResponse",
|
||||
# Card Collection
|
||||
"CardCondition", "AcquisitionMethod",
|
||||
"CollectionStatistics", "CollectionSummaryResponse",
|
||||
# Card Import
|
||||
"CardImportRequest", "CardImportResponse", "CardImportStatusResponse",
|
||||
"CardMatchResult", "CardImportSummary",
|
||||
"CardImportRequest", "CardImportResponseV2", "CardImportStatusResponseV2",
|
||||
"CardMatchResultV2", "CardImportSummaryV2",
|
||||
"CardImportBatchCreate", "CardImportBatchResponse",
|
||||
"UserCardImportCreate", "UserCardImportResponse", "UserCardImportRecordResponse",
|
||||
# Card Search
|
||||
"CardResponse", "SetResponse", "CardTypeResponse",
|
||||
"CardSearchResponseV2", "CardImportResponseV3", "CardImportStatusResponseV3",
|
||||
"CardMatchResultV3", "CardImportSummaryV3",
|
||||
# Game Schemas
|
||||
"GameCreateV2", "GameResponseV2",
|
||||
# MTG Card Schemas
|
||||
"MtgCardResponse", "MtgCardSearchRequest", "MtgCardSearchResponse",
|
||||
"MtgSetResponse", "MtgCardMirrorResponse",
|
||||
"DeckCardLinkResponseV2", "DeckWithCardsResponseV2",
|
||||
# Proto Messages
|
||||
"ProtoMessageBase", "SessionCommand", "GameCommand", "GameEvent", "Response",
|
||||
"ServerInfoUser", "ServerInfoDeckStorageFile", "ServerInfoDeckStorageFolder",
|
||||
|
||||
@@ -111,19 +111,3 @@ class UserCardImportRecordResponse(BaseModel):
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""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 datetime import datetime
|
||||
|
||||
@@ -23,8 +23,7 @@ class CardResponse(BaseModel):
|
||||
identifiers: Optional[Dict[str, Any]] = None
|
||||
images: Optional[Dict[str, Any]] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SetResponse(BaseModel):
|
||||
@@ -35,8 +34,7 @@ class SetResponse(BaseModel):
|
||||
release_date: Optional[datetime] = None
|
||||
card_count: Optional[int] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CardTypeResponse(BaseModel):
|
||||
@@ -93,6 +91,7 @@ class CardImportSummary(BaseModel):
|
||||
import_id: Optional[int] = None
|
||||
|
||||
|
||||
# Generic response models
|
||||
class MessageResponse(BaseModel):
|
||||
"""Generic message response."""
|
||||
message: str
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Pydantic schemas for game features."""
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Optional, List, Dict, Any
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ class GameCreate(BaseModel):
|
||||
game_type: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
max_players: int = Field(4, ge=2, le=8)
|
||||
|
||||
|
||||
class GameResponse(BaseModel):
|
||||
@@ -31,7 +30,6 @@ class GameResponse(BaseModel):
|
||||
class GameJoinRequest(BaseModel):
|
||||
"""Game join request."""
|
||||
game_id: int
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
class GameLeaveRequest(BaseModel):
|
||||
@@ -50,15 +48,12 @@ class GamePlayerResponse(BaseModel):
|
||||
user_id: int
|
||||
username: str
|
||||
deck_id: Optional[int] = None
|
||||
is_ready: bool = False
|
||||
is_host: bool = False
|
||||
deck_name: Optional[str] = None
|
||||
|
||||
|
||||
class GameStateResponse(BaseModel):
|
||||
"""Game state response."""
|
||||
game_id: int
|
||||
state: str
|
||||
players: List[GamePlayerResponse]
|
||||
turn: int
|
||||
phase: str
|
||||
zones: Dict[str, Any]
|
||||
updated_at: datetime
|
||||
turn: Optional[int] = None
|
||||
|
||||
@@ -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
|
||||
@@ -1,33 +1,33 @@
|
||||
"""Pydantic schemas for MTG card data."""
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Optional, List, Dict, Any
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class MtgCardResponse(BaseModel):
|
||||
"""MTG card response with full details."""
|
||||
"""MTG card response."""
|
||||
id: int
|
||||
source_id: Optional[int] = None
|
||||
source_id: Optional[int]
|
||||
name: str
|
||||
mana_cost: Optional[str] = None
|
||||
type_line: Optional[str] = None
|
||||
oracle_text: Optional[str] = None
|
||||
power: Optional[str] = None
|
||||
toughness: Optional[str] = None
|
||||
rarity: Optional[str] = None
|
||||
layout: Optional[str] = None
|
||||
artist: Optional[str] = None
|
||||
flavor_text: Optional[str] = None
|
||||
numbers: Optional[str] = None
|
||||
identifiers: Optional[Dict[str, Any]] = None
|
||||
images: Optional[Dict[str, Any]] = None
|
||||
image: Optional[str] = None
|
||||
card_parts: Optional[List[str]] = None
|
||||
keywords: Optional[List[str]] = None
|
||||
legalities: Optional[Dict[str, str]] = None
|
||||
set_code: Optional[str] = None
|
||||
set_name: Optional[str] = None
|
||||
synced_at: Optional[datetime] = None
|
||||
mana_cost: Optional[str]
|
||||
type_line: Optional[str]
|
||||
oracle_text: Optional[str]
|
||||
power: Optional[str]
|
||||
toughness: Optional[str]
|
||||
rarity: Optional[str]
|
||||
layout: Optional[str]
|
||||
artist: Optional[str]
|
||||
flavor_text: Optional[str]
|
||||
numbers: Optional[str]
|
||||
identifiers: Optional[str]
|
||||
images: Optional[str]
|
||||
image: Optional[str]
|
||||
card_parts: Optional[str]
|
||||
keywords: Optional[str]
|
||||
legalities: Optional[str]
|
||||
set_code: Optional[str]
|
||||
set_name: Optional[str]
|
||||
synced_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -35,12 +35,9 @@ class MtgCardResponse(BaseModel):
|
||||
|
||||
class MtgCardSearchRequest(BaseModel):
|
||||
"""MTG card search request."""
|
||||
query: str = Field(..., min_length=1, max_length=100)
|
||||
set_code: Optional[str] = None
|
||||
rarity: Optional[str] = None
|
||||
type_line: Optional[str] = None
|
||||
limit: int = Field(50, ge=1, le=200)
|
||||
offset: int = Field(0, ge=0)
|
||||
query: str
|
||||
limit: int = 50
|
||||
offset: int = 0
|
||||
|
||||
|
||||
class MtgCardSearchResponse(BaseModel):
|
||||
@@ -55,41 +52,38 @@ class MtgCardSearchResponse(BaseModel):
|
||||
class MtgSetResponse(BaseModel):
|
||||
"""MTG set response."""
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
release_date: Optional[datetime] = None
|
||||
card_count: Optional[int] = None
|
||||
type: Optional[str] = None
|
||||
border: Optional[str] = None
|
||||
mcm_id: Optional[int] = None
|
||||
code: str
|
||||
release_date: Optional[datetime]
|
||||
card_count: Optional[int]
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class MtgCardMirrorResponse(BaseModel):
|
||||
"""Mirrored card data for user decks."""
|
||||
"""Mirrored card data response."""
|
||||
id: int
|
||||
source_id: Optional[int] = None
|
||||
source_id: Optional[int]
|
||||
name: str
|
||||
mana_cost: Optional[str] = None
|
||||
type_line: Optional[str] = None
|
||||
oracle_text: Optional[str] = None
|
||||
power: Optional[str] = None
|
||||
toughness: Optional[str] = None
|
||||
rarity: Optional[str] = None
|
||||
layout: Optional[str] = None
|
||||
artist: Optional[str] = None
|
||||
flavor_text: Optional[str] = None
|
||||
numbers: Optional[str] = None
|
||||
identifiers: Optional[Dict[str, Any]] = None
|
||||
images: Optional[Dict[str, Any]] = None
|
||||
image: Optional[str] = None
|
||||
card_parts: Optional[str] = None
|
||||
keywords: Optional[str] = None
|
||||
legalities: Optional[Dict[str, str]] = None
|
||||
set_code: Optional[str] = None
|
||||
set_name: Optional[str] = None
|
||||
synced_at: Optional[datetime] = None
|
||||
mana_cost: Optional[str]
|
||||
type_line: Optional[str]
|
||||
oracle_text: Optional[str]
|
||||
power: Optional[str]
|
||||
toughness: Optional[str]
|
||||
rarity: Optional[str]
|
||||
layout: Optional[str]
|
||||
artist: Optional[str]
|
||||
flavor_text: Optional[str]
|
||||
numbers: Optional[str]
|
||||
identifiers: Optional[str]
|
||||
images: Optional[str]
|
||||
image: Optional[str]
|
||||
card_parts: Optional[str]
|
||||
keywords: Optional[str]
|
||||
legalities: Optional[str]
|
||||
set_code: Optional[str]
|
||||
set_name: Optional[str]
|
||||
synced_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -114,7 +108,7 @@ class DeckWithCardsResponse(BaseModel):
|
||||
content: str
|
||||
format: str
|
||||
status: str
|
||||
folder_id: Optional[int] = None
|
||||
folder_id: Optional[int]
|
||||
owner_id: int
|
||||
creation_date: datetime
|
||||
card_links: List[DeckCardLinkResponse] = []
|
||||
|
||||
@@ -3,7 +3,7 @@ Pydantic schemas for request/response validation.
|
||||
|
||||
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 datetime import datetime
|
||||
|
||||
@@ -49,7 +49,7 @@ class UserCreate(UserBase):
|
||||
"""User registration fields."""
|
||||
password: str = Field(..., min_length=8)
|
||||
|
||||
class Config:
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"username": "player123",
|
||||
@@ -58,6 +58,7 @@ class UserCreate(UserBase):
|
||||
"country": "US"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
@@ -83,8 +84,7 @@ class UserResponse(BaseModel):
|
||||
creation_date: datetime
|
||||
last_login: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===== Deck Schemas =====
|
||||
@@ -117,8 +117,7 @@ class DeckResponse(BaseModel):
|
||||
owner_id: int
|
||||
creation_date: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class FolderCreate(BaseModel):
|
||||
@@ -135,8 +134,7 @@ class FolderResponse(BaseModel):
|
||||
owner_id: int
|
||||
creation_date: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===== Game Schemas =====
|
||||
@@ -161,8 +159,7 @@ class GameResponse(BaseModel):
|
||||
started: bool
|
||||
creation_date: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===== Room Schemas =====
|
||||
@@ -177,8 +174,7 @@ class RoomResponse(BaseModel):
|
||||
player_count: int = 0
|
||||
creation_date: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===== Ban Schemas =====
|
||||
@@ -200,8 +196,7 @@ class BanResponse(BaseModel):
|
||||
active: bool
|
||||
creation_date: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===== Auth Error Responses =====
|
||||
@@ -261,8 +256,7 @@ class CardMirrorResponse(BaseModel):
|
||||
synced_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DeckCardLinkResponse(BaseModel):
|
||||
@@ -274,8 +268,7 @@ class DeckCardLinkResponse(BaseModel):
|
||||
zone: str
|
||||
card: CardMirrorResponse
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DeckWithCardsResponse(BaseModel):
|
||||
@@ -290,5 +283,4 @@ class DeckWithCardsResponse(BaseModel):
|
||||
creation_date: datetime
|
||||
card_links: List[DeckCardLinkResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -3,7 +3,7 @@ Pydantic schemas for user card collection features.
|
||||
|
||||
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 datetime import datetime
|
||||
from enum import Enum
|
||||
@@ -73,8 +73,7 @@ class CardCollectionResponse(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CardCollectionListResponse(BaseModel):
|
||||
@@ -90,7 +89,7 @@ class CardCollectionListResponse(BaseModel):
|
||||
|
||||
class WishlistCreate(BaseModel):
|
||||
"""Wishlist item creation request."""
|
||||
card_id: int
|
||||
card_id: Optional[int] = None
|
||||
max_price: Optional[float] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
@@ -110,8 +109,7 @@ class WishlistResponse(BaseModel):
|
||||
notes: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class WishlistListResponse(BaseModel):
|
||||
|
||||
@@ -3,7 +3,7 @@ Pydantic schemas for user data features.
|
||||
|
||||
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 datetime import datetime
|
||||
from enum import Enum
|
||||
@@ -72,8 +72,7 @@ class SessionResponse(BaseModel):
|
||||
expires_at: datetime
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SessionCleanupResponse(BaseModel):
|
||||
@@ -108,8 +107,7 @@ class DeckVersionResponse(BaseModel):
|
||||
comment: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DeckVersionListResponse(BaseModel):
|
||||
@@ -160,8 +158,7 @@ class GameReplayResponse(BaseModel):
|
||||
updated_at: datetime
|
||||
players: List[Dict[str, Any]] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class GameReplayListResponse(BaseModel):
|
||||
@@ -199,8 +196,7 @@ class GameOutcomeResponse(BaseModel):
|
||||
rating_change: Optional[int]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class GameOutcomeListResponse(BaseModel):
|
||||
@@ -225,8 +221,7 @@ class UserStatisticsResponse(BaseModel):
|
||||
last_game_date: Optional[datetime]
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class StatisticsUpdateResponse(BaseModel):
|
||||
@@ -240,94 +235,12 @@ class StatisticsUpdateResponse(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# ===== Card Collection Schemas =====
|
||||
|
||||
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
|
||||
# ===== 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 CardCollectionUpdate(BaseModel):
|
||||
"""Card collection item update request."""
|
||||
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
|
||||
# ===== Wishlist Schemas (moved to user_card_collection.py) =====
|
||||
# These are kept here for backward compatibility but should be imported from user_card_collection.py
|
||||
|
||||
|
||||
# ===== Group Schemas =====
|
||||
@@ -377,8 +290,7 @@ class GroupResponse(BaseModel):
|
||||
member_count: int = 0
|
||||
is_member: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class GroupListResponse(BaseModel):
|
||||
@@ -401,8 +313,7 @@ class GroupChatMessageResponse(BaseModel):
|
||||
message: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class GroupChatMessageListResponse(BaseModel):
|
||||
@@ -447,8 +358,7 @@ class NetworkResponse(BaseModel):
|
||||
member_count: int = 0
|
||||
is_member: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class NetworkListResponse(BaseModel):
|
||||
@@ -480,8 +390,7 @@ class UserPreferenceResponse(BaseModel):
|
||||
language: str
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===== Activity Log Schemas =====
|
||||
@@ -495,8 +404,7 @@ class ActivityLogEntry(BaseModel):
|
||||
ip_address: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ActivityLogListResponse(BaseModel):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""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 datetime import datetime
|
||||
from enum import Enum
|
||||
@@ -48,6 +48,8 @@ class UserDeckUpdate(BaseModel):
|
||||
|
||||
class UserDeckResponse(BaseModel):
|
||||
"""Deck response with card count."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
name: str
|
||||
@@ -62,9 +64,6 @@ class UserDeckResponse(BaseModel):
|
||||
card_count: int = 0
|
||||
is_owner: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserDeckListResponse(BaseModel):
|
||||
"""List of user decks."""
|
||||
@@ -94,16 +93,14 @@ class DeckCardUpdate(BaseModel):
|
||||
|
||||
class DeckCardResponse(BaseModel):
|
||||
"""Deck card response."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
deck_id: int
|
||||
card_id: int
|
||||
quantity: int
|
||||
zone: str
|
||||
position: Optional[int]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DeckCardWithDetailsResponse(DeckCardResponse):
|
||||
@@ -139,6 +136,8 @@ class PrecedentUpdate(BaseModel):
|
||||
|
||||
class PrecedentResponse(BaseModel):
|
||||
"""Deck precedent response."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
@@ -149,9 +148,6 @@ class PrecedentResponse(BaseModel):
|
||||
updated_at: datetime
|
||||
card_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PrecedentListResponse(BaseModel):
|
||||
"""List of deck precedents."""
|
||||
@@ -172,6 +168,8 @@ class SuggestionCreate(BaseModel):
|
||||
|
||||
class SuggestionResponse(BaseModel):
|
||||
"""Card suggestion response."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
deck_id: int
|
||||
card_id: int
|
||||
@@ -182,9 +180,6 @@ class SuggestionResponse(BaseModel):
|
||||
created_at: datetime
|
||||
card_name: str = ""
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SuggestionListResponse(BaseModel):
|
||||
"""List of card suggestions."""
|
||||
@@ -220,23 +215,3 @@ class CardSearchRequest(BaseModel):
|
||||
limit: int = Field(50, ge=1, le=200)
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user