paper-dynasty-database/tests/test_card_of_the_week.py
Cal Corum f9c25c5632 fix: address review feedback on card-of-the-week PR (#212)
- Add `= None` default to `Optional[str]` field in CardOfTheWeekResponse
  (Pydantic v1 convention: Optional fields should have explicit None default)
- Remove unused `_PACK_COUNTER` dead variable from test module
- Remove unused `cardset` parameter from `_make_pack_type` helper and
  update all 6 call sites

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:01:33 -05:00

470 lines
15 KiB
Python

"""Tests for GET /api/v2/cards/featured/card-of-the-week.
What: Verifies the card-of-the-week endpoint returns the highest-rated card
opened (via pack) within the last N days, with all fields needed for a Discord
embed in a single call.
Why: This endpoint feeds the automated content pipeline (n8n / Discord
scheduled posts). Correctness of tiebreak logic, AI-exclusion, and the 404
empty-window case are all contract-level guarantees that must not regress.
Test matrix
-----------
test_happy_path_single_eligible_card
One eligible card in the window — endpoint returns its fields.
test_empty_window_returns_404
No cards in the window — endpoint returns 404 with "no featured card
this week".
test_ai_team_excluded_by_default
AI-team card is present but excluded by default; non-AI card is
returned.
test_include_ai_true_includes_ai_team_card
?include_ai=true lifts the AI exclusion and returns the AI card when it
has the highest rating.
test_tiebreak_highest_rating_wins
Two cards in the window; the one with higher value is returned.
test_tiebreak_same_rating_newest_pack_wins
Two cards with equal value; the one with the newer pack open_time is
returned, proving the secondary sort is deterministic.
All tests use an in-memory SQLite database (no PostgreSQL required) via the
shared TestClient pattern established in test_refractor_image_url.py.
"""
import os
os.environ.setdefault("API_TOKEN", "test-token")
os.environ.setdefault("DATABASE_TYPE", "postgresql")
os.environ.setdefault("POSTGRES_PASSWORD", "test-dummy")
from datetime import datetime, timedelta
import pytest
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from peewee import SqliteDatabase
from app.db_engine import (
BattingSeasonStats,
Card,
Cardset,
Decision,
Event,
MlbPlayer,
Pack,
PackType,
PitchingSeasonStats,
Player,
ProcessedGame,
Rarity,
RefractorBoostAudit,
RefractorCardState,
RefractorTrack,
Roster,
RosterSlot,
ScoutClaim,
ScoutOpportunity,
StratGame,
StratPlay,
Team,
BattingCard,
PitchingCard,
)
# ---------------------------------------------------------------------------
# In-memory SQLite setup — shared named database so TestClient and fixtures
# share the same connection within a test.
# ---------------------------------------------------------------------------
_cotw_db = SqliteDatabase(
"file:cotwtest?mode=memory&cache=shared",
uri=True,
pragmas={"foreign_keys": 1},
)
_COTW_MODELS = [
Rarity,
Event,
Cardset,
MlbPlayer,
Player,
BattingCard,
PitchingCard,
Team,
PackType,
Pack,
Card,
Roster,
RosterSlot,
StratGame,
StratPlay,
Decision,
BattingSeasonStats,
PitchingSeasonStats,
ProcessedGame,
ScoutOpportunity,
ScoutClaim,
RefractorTrack,
RefractorCardState,
RefractorBoostAudit,
]
@pytest.fixture(autouse=False)
def cotw_db():
"""Bind all models to the shared-memory SQLite database.
What: Creates all tables before each test and drops them after, ensuring
test isolation within the same process.
Why: SQLite shared-memory databases persist across tests in the same
process if tables are not explicitly dropped. The autouse=False means
tests must request this fixture (or the cotw_client fixture that depends
on it) explicitly, avoiding interference with the conftest autouse fixture.
"""
_cotw_db.bind(_COTW_MODELS)
_cotw_db.connect(reuse_if_open=True)
_cotw_db.create_tables(_COTW_MODELS)
yield _cotw_db
_cotw_db.drop_tables(list(reversed(_COTW_MODELS)), safe=True)
def _build_cotw_app() -> FastAPI:
"""Minimal FastAPI app with featured router and SQLite middleware."""
from app.routers_v2.featured import router as featured_router
app = FastAPI()
@app.middleware("http")
async def db_middleware(request: Request, call_next):
_cotw_db.connect(reuse_if_open=True)
return await call_next(request)
app.include_router(featured_router)
return app
@pytest.fixture
def cotw_client(cotw_db):
"""FastAPI TestClient backed by in-memory SQLite for COTW endpoint tests."""
with TestClient(_build_cotw_app()) as c:
yield c
# ---------------------------------------------------------------------------
# Seed helpers
# ---------------------------------------------------------------------------
_RARITY_COUNTER = [0]
_TEAM_COUNTER = [0]
def _make_rarity(name: str = "Common") -> Rarity:
_RARITY_COUNTER[0] += 1
unique_name = f"COTW_{name}_{_RARITY_COUNTER[0]}"
return Rarity.create(value=_RARITY_COUNTER[0], name=unique_name, color="#ffffff")
def _make_cardset(name: str = "COTW Set") -> Cardset:
return Cardset.create(name=name, description="cotw test", total_cards=10)
def _make_player(
rarity: Rarity,
cardset: Cardset,
name: str = "Test Player",
image: str = "https://example.com/card.png",
) -> Player:
return Player.create(
p_name=name,
rarity=rarity,
cardset=cardset,
set_num=1,
pos_1="1B",
image=image,
mlbclub="TST",
franchise="TST",
description="cotw test player",
)
def _make_team(is_ai: bool = False, suffix: str = "") -> Team:
_TEAM_COUNTER[0] += 1
idx = _TEAM_COUNTER[0]
abbrev = f"CT{idx}{suffix}"[:20]
return Team.create(
abbrev=abbrev,
sname=f"COTW{idx}",
lname=f"COTW Team {idx}",
gmid=9_000_000 + idx,
gmname=f"cotw_gm_{idx}",
gsheet="https://docs.google.com/cotw",
wallet=500,
team_value=1000,
collection_value=1000,
season=11,
is_ai=1 if is_ai else 0,
)
def _make_pack_type() -> PackType:
return PackType.create(
name="COTW Pack Type",
card_count=5,
description="cotw test pack type",
cost=100,
)
def _make_pack(team: Team, pack_type: PackType, open_time: datetime) -> Pack:
return Pack.create(
team=team,
pack_type=pack_type,
open_time=open_time,
)
def _make_card(player: Player, team: Team, pack: Pack, value: int = 50) -> Card:
return Card.create(
player=player,
team=team,
pack=pack,
value=value,
)
def _recent(days_ago: float = 1.0) -> datetime:
"""Return a datetime that is `days_ago` days before now (within the 7-day window)."""
return datetime.utcnow() - timedelta(days=days_ago)
def _old(days_ago: float = 14.0) -> datetime:
"""Return a datetime that is `days_ago` days before now (outside the 7-day window)."""
return datetime.utcnow() - timedelta(days=days_ago)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_happy_path_single_eligible_card(cotw_db, cotw_client):
"""GET /api/v2/cards/featured/card-of-the-week returns the eligible card.
What: Seeds one card with pack.open_time within the 7-day window. Calls
the endpoint and asserts that all required response fields are present and
match the seeded data.
Why: This is the primary happy-path test. If any field is missing, the
n8n integration will fail silently at embed-render time.
"""
rarity = _make_rarity("MVP")
cardset = _make_cardset("COTW Happy Set")
player = _make_player(
rarity,
cardset,
name="Happy Gilmore",
image="https://s3.example.com/happy.png",
)
team = _make_team()
pt = _make_pack_type()
pack = _make_pack(team, pt, open_time=_recent(1))
card = _make_card(player, team, pack, value=85)
resp = cotw_client.get("/api/v2/cards/featured/card-of-the-week")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["card_id"] == card.id
assert data["player_name"] == "Happy Gilmore"
assert data["team_name"] == team.sname
assert data["team_abbrev"] == team.abbrev
assert data["rarity_name"] == rarity.name
assert data["rating"] == 85
assert data["card_image_url"] == "https://s3.example.com/happy.png"
assert data["cardset_name"] == "COTW Happy Set"
assert data["card_created_at"] is not None # open_time is set
def test_empty_window_returns_404(cotw_db, cotw_client):
"""GET /api/v2/cards/featured/card-of-the-week returns 404 when no card is in window.
What: Seeds a card whose pack was opened 14 days ago (outside the 7-day
window). Asserts the endpoint returns 404.
Why: The n8n workflow must gracefully handle weeks with no new activity.
The 404 is the agreed contract; a 200 with empty data would require
different handling on the consumer side.
"""
rarity = _make_rarity("Common")
cardset = _make_cardset("COTW Old Set")
player = _make_player(rarity, cardset, name="Old Timer")
team = _make_team()
pt = _make_pack_type()
pack = _make_pack(team, pt, open_time=_old(14))
_make_card(player, team, pack, value=70)
resp = cotw_client.get("/api/v2/cards/featured/card-of-the-week")
assert resp.status_code == 404
assert "no featured card this week" in resp.json()["detail"]
def test_ai_team_excluded_by_default(cotw_db, cotw_client):
"""AI-team cards are excluded by default; non-AI card is returned instead.
What: Seeds two cards in the window — one on an AI team (high rating) and
one on a human team (lower rating). The default request must return the
human-team card.
Why: AI cards artificially inflate ratings and should not appear in the
public content feed. The default must be exclusion-safe without callers
needing to remember the ?include_ai=false param.
"""
rarity = _make_rarity("MVP")
cardset = _make_cardset("AI Excl Set")
pt = _make_pack_type()
ai_team = _make_team(is_ai=True, suffix="AI")
human_team = _make_team(is_ai=False, suffix="HU")
ai_player = _make_player(
rarity, cardset, name="Robot Arm", image="https://s3.example.com/robot.png"
)
human_player = _make_player(
rarity, cardset, name="Human Arm", image="https://s3.example.com/human.png"
)
ai_pack = _make_pack(ai_team, pt, open_time=_recent(1))
human_pack = _make_pack(human_team, pt, open_time=_recent(1))
_make_card(ai_player, ai_team, ai_pack, value=99) # higher rating, AI team
human_card = _make_card(
human_player, human_team, human_pack, value=80
) # lower rating, human
resp = cotw_client.get("/api/v2/cards/featured/card-of-the-week")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["card_id"] == human_card.id, (
"Expected the human-team card; AI card should be excluded by default"
)
assert data["player_name"] == "Human Arm"
def test_include_ai_true_includes_ai_team_card(cotw_db, cotw_client):
"""?include_ai=true lifts AI exclusion and returns the AI card when it has top rating.
What: Same setup as test_ai_team_excluded_by_default, but calls with
?include_ai=true. The AI card (higher rating) must be returned.
Why: Admin tooling and diagnostics may need to inspect AI-team cards. The
opt-in parameter must work correctly or the exclusion logic is broken.
"""
rarity = _make_rarity("MVP")
cardset = _make_cardset("AI Incl Set")
pt = _make_pack_type()
ai_team = _make_team(is_ai=True, suffix="AI2")
human_team = _make_team(is_ai=False, suffix="HU2")
ai_player = _make_player(
rarity, cardset, name="Cyborg King", image="https://s3.example.com/cyborg.png"
)
human_player = _make_player(
rarity, cardset, name="Regular Joe", image="https://s3.example.com/joe.png"
)
ai_pack = _make_pack(ai_team, pt, open_time=_recent(1))
human_pack = _make_pack(human_team, pt, open_time=_recent(1))
ai_card = _make_card(ai_player, ai_team, ai_pack, value=99)
_make_card(human_player, human_team, human_pack, value=80)
resp = cotw_client.get("/api/v2/cards/featured/card-of-the-week?include_ai=true")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["card_id"] == ai_card.id, (
"Expected the AI-team card when include_ai=true and it has the highest rating"
)
assert data["player_name"] == "Cyborg King"
def test_tiebreak_highest_rating_wins(cotw_db, cotw_client):
"""The card with the highest rating is returned when two cards are in the window.
What: Seeds two cards opened at the same time with different values (80 and
90). Asserts the card with value=90 is returned.
Why: Primary sort must be rating descending. If the sort order is wrong the
weekly content feed could highlight mediocre cards instead of standouts.
"""
rarity = _make_rarity("HoF")
cardset = _make_cardset("Tiebreak Rating Set")
pt = _make_pack_type()
team = _make_team()
open_time = _recent(2)
p1 = _make_player(
rarity, cardset, name="Low Rater", image="https://s3.example.com/low.png"
)
p2 = _make_player(
rarity, cardset, name="High Rater", image="https://s3.example.com/high.png"
)
pack1 = _make_pack(team, pt, open_time=open_time)
pack2 = _make_pack(team, pt, open_time=open_time)
_make_card(p1, team, pack1, value=80)
card_high = _make_card(p2, team, pack2, value=90)
resp = cotw_client.get("/api/v2/cards/featured/card-of-the-week")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["card_id"] == card_high.id, (
"Highest-rating card must win the primary sort"
)
assert data["rating"] == 90
def test_tiebreak_same_rating_newest_pack_wins(cotw_db, cotw_client):
"""When two cards have identical ratings, the one from the newer pack is returned.
What: Seeds two cards with the same value (75) but opened at different times
(3 days ago vs 1 day ago). Asserts the newer card is returned.
Why: The secondary sort (open_time DESC) must be deterministic so the same
card is returned on every call within a calendar day — important for
cache-friendliness and avoiding flip-flop behavior in the Discord post.
"""
rarity = _make_rarity("All-Star")
cardset = _make_cardset("Tiebreak Newest Set")
pt = _make_pack_type()
team = _make_team()
p_old = _make_player(
rarity,
cardset,
name="Old Pack Player",
image="https://s3.example.com/old_pack.png",
)
p_new = _make_player(
rarity,
cardset,
name="New Pack Player",
image="https://s3.example.com/new_pack.png",
)
pack_old = _make_pack(team, pt, open_time=_recent(3))
pack_new = _make_pack(team, pt, open_time=_recent(1))
_make_card(p_old, team, pack_old, value=75)
card_new = _make_card(p_new, team, pack_new, value=75)
resp = cotw_client.get("/api/v2/cards/featured/card-of-the-week")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["card_id"] == card_new.id, (
"Newer pack must win the secondary tiebreak when ratings are equal"
)
assert data["player_name"] == "New Pack Player"