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