- 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>
470 lines
15 KiB
Python
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"
|