From 507037f5b985954cb7d34cf9f796cb267b9c3cf0 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 10 Apr 2026 10:31:48 -0500 Subject: [PATCH] feat: add GET /api/v2/cards/featured/card-of-the-week endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes the highest-rated card from the past 7 days (configurable via ?days=) as a Discord-embed-ready payload. AI teams are excluded by default (?include_ai=true lifts the filter). Deterministic tiebreak: rating desc, pack open_time desc, card id desc. Roadmap: Phase 2.6c — lowest-friction entry into the automated content pipeline. Single-call response includes player name, team, rarity, rating, card image URL, and cardset name. Co-Authored-By: Claude Sonnet 4.6 --- app/main.py | 2 + app/routers_v2/featured.py | 127 +++++++++ tests/test_card_of_the_week.py | 470 +++++++++++++++++++++++++++++++++ 3 files changed, 599 insertions(+) create mode 100644 app/routers_v2/featured.py create mode 100644 tests/test_card_of_the_week.py diff --git a/app/main.py b/app/main.py index 2e6de86..972d68a 100644 --- a/app/main.py +++ b/app/main.py @@ -53,6 +53,7 @@ from .routers_v2 import ( # noqa: E402 scout_claims, refractor, season_stats, + featured, ) @@ -109,6 +110,7 @@ app.include_router(scout_opportunities.router) app.include_router(scout_claims.router) app.include_router(refractor.router) app.include_router(season_stats.router) +app.include_router(featured.router) @app.middleware("http") diff --git a/app/routers_v2/featured.py b/app/routers_v2/featured.py new file mode 100644 index 0000000..ee7563b --- /dev/null +++ b/app/routers_v2/featured.py @@ -0,0 +1,127 @@ +""" +Router: featured cards + +Provides endpoints that surface curated "featured" card selections for use +in automated content pipelines (n8n, Discord scheduled posts, etc.). + +Endpoints +--------- +GET /api/v2/cards/featured/card-of-the-week + Returns the single highest-rated eligible card created in the past N days, + with all fields needed to build a Discord embed in one call. Deterministic + for a given calendar day (cache-friendly). + +Design notes +------------ +- "Card created" is approximated by Pack.open_time — the timestamp when the + pack containing the card was opened. The Card model has no created_at + column; open_time is the closest available proxy and is semantically correct + (the card enters a team's collection at pack-open time). +- Rating is Card.value (the integer rating field set at card issuance). +- Tiebreak: highest value desc, then newest pack open_time desc, then + highest card.id desc — fully deterministic with no random element. +- AI teams (Team.is_ai is not NULL and != 0) are excluded by default. + Pass ?include_ai=true to lift that filter. +""" + +from datetime import datetime, timedelta +from typing import Optional + +import pydantic +from fastapi import APIRouter, HTTPException, Query + +from ..db_engine import Card, Cardset, Pack, Player, Rarity, Team + +router = APIRouter(prefix="/api/v2/cards/featured", tags=["featured"]) + + +class CardOfTheWeekResponse(pydantic.BaseModel): + """Single-call Discord embed payload for the card-of-the-week feature. + + All fields needed for a rich embed are present so the consumer (n8n, + Discord bot) never needs a follow-up API call. + """ + + card_id: int + player_name: str + team_name: str + team_abbrev: str + rarity_name: str + rating: int + card_image_url: str + cardset_name: str + card_created_at: Optional[str] # ISO-8601 string; None when pack has no open_time + + +@router.get( + "/card-of-the-week", + response_model=CardOfTheWeekResponse, + summary="Card of the Week", + description=( + "Returns the highest-rated card created (pack opened) within the last N days. " + "Excludes AI-team cards by default. Deterministic within a calendar day." + ), +) +async def card_of_the_week( + days: int = Query(default=7, ge=1, le=90, description="Look-back window in days"), + include_ai: bool = Query( + default=False, + description="Include cards owned by AI teams (excluded by default)", + ), +): + """Return the card-of-the-week for use in Discord embeds and n8n automation. + + Selection algorithm: + 1. Filter cards whose pack was opened within the last `days` days. + 2. Exclude AI-team cards unless include_ai=true. + 3. Order by Card.value DESC, Pack.open_time DESC, Card.id DESC. + 4. Return the first row as a flat Discord-ready payload. + + Returns 404 when no eligible card exists in the window. + """ + cutoff = datetime.utcnow() - timedelta(days=days) + + query = ( + Card.select(Card, Pack, Player, Team, Rarity, Cardset) + .join(Pack, on=(Card.pack == Pack.id)) + .switch(Card) + .join(Player, on=(Card.player == Player.player_id)) + .join(Rarity, on=(Player.rarity == Rarity.id)) + .switch(Player) + .join(Cardset, on=(Player.cardset == Cardset.id)) + .switch(Card) + .join(Team, on=(Card.team == Team.id)) + .where(Pack.open_time >= cutoff) + .where(Card.player.is_null(False)) + .where(Card.team.is_null(False)) + .where(Card.pack.is_null(False)) + ) + + if not include_ai: + # is_ai is an IntegerField(null=True); non-AI teams have is_ai=0 or NULL + query = query.where((Team.is_ai == 0) | (Team.is_ai.is_null(True))) + + # Deterministic tiebreak: highest rating first, then newest pack, then card id + query = query.order_by(Card.value.desc(), Pack.open_time.desc(), Card.id.desc()) + + card = query.first() + + if card is None: + raise HTTPException(status_code=404, detail="no featured card this week") + + pack_open_time = None + if card.pack and card.pack.open_time: + ot = card.pack.open_time + pack_open_time = ot.isoformat() if hasattr(ot, "isoformat") else str(ot) + + return CardOfTheWeekResponse( + card_id=card.id, + player_name=card.player.p_name, + team_name=card.team.sname, + team_abbrev=card.team.abbrev, + rarity_name=card.player.rarity.name, + rating=card.value, + card_image_url=card.player.image, + cardset_name=card.player.cardset.name, + card_created_at=pack_open_time, + ) diff --git a/tests/test_card_of_the_week.py b/tests/test_card_of_the_week.py new file mode 100644 index 0000000..246261d --- /dev/null +++ b/tests/test_card_of_the_week.py @@ -0,0 +1,470 @@ +"""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] +_PACK_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(cardset: Cardset) -> 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(cardset) + 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(cardset) + 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(cardset) + + 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(cardset) + + 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(cardset) + 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(cardset) + 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"