"""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"