"""Tests for the static PNG card render endpoint (get_batter_card). What: GET /api/v2/players/{player_id}/{card_type}card/{d}/{variant} These tests focus on the file-on-disk cache short-circuit and the _schedule_variant_image_backfill helper introduced to fix a bug where variant rows with image_url=NULL stayed NULL forever once the PNG was cached on disk. Why: The cache-hit path previously returned FileResponse immediately without scheduling the backfill BackgroundTask that populates battingcard.image_url / pitchingcard.image_url. If a variant row was later recreated with image_url=NULL while its PNG remained cached, the column stayed NULL forever — breaking every downstream consumer (Discord bot embeds, website collection views, refractor card listings). The fix extracts backfill-scheduling into _schedule_variant_image_backfill and calls it on both the cache-hit path and the full-render path. Test isolation: - All DB access uses an in-memory SQLite database (no PostgreSQL needed). - os.path.isfile is monkeypatched to simulate cache presence without touching the real filesystem. - FileResponse is monkeypatched to prevent actual file I/O. - backfill_variant_image_url is patched so no real S3 calls are made. - The ?tier= param and html=False are set as needed per test. """ import os import sys # Inject a mock apng module so importing players (which imports # apng_generator, which does `from apng import APNG`) succeeds in # environments where the optional C-extension is absent. sys.modules.setdefault( "apng", __import__("unittest.mock", fromlist=["MagicMock"]).MagicMock() ) os.environ.setdefault("DATABASE_TYPE", "postgresql") os.environ.setdefault("POSTGRES_PASSWORD", "test-dummy") os.environ.setdefault("API_TOKEN", "test-token") from unittest.mock import MagicMock, patch # noqa: E402 import pytest # noqa: E402 from fastapi import FastAPI, Request # noqa: E402 from fastapi.testclient import TestClient # noqa: E402 from peewee import SqliteDatabase # noqa: E402 from app.db_engine import ( # noqa: E402 BattingCard, BattingCardRatings, BattingSeasonStats, Card, CardPosition, Cardset, Decision, Event, MlbPlayer, Pack, PackType, PitchingCard, PitchingCardRatings, PitchingSeasonStats, Player, ProcessedGame, Rarity, RefractorBoostAudit, RefractorCardState, RefractorTrack, Roster, RosterSlot, ScoutClaim, ScoutOpportunity, StratGame, StratPlay, Team, ) # --------------------------------------------------------------------------- # SQLite in-memory database # --------------------------------------------------------------------------- _render_db = SqliteDatabase( "file:rendercardendpointtest?mode=memory&cache=shared", uri=True, pragmas={"foreign_keys": 1}, ) # Full model list in FK dependency order — parents before children. _RENDER_MODELS = [ Rarity, Event, Cardset, MlbPlayer, Player, BattingCard, BattingCardRatings, PitchingCard, PitchingCardRatings, CardPosition, Team, PackType, Pack, Card, Roster, RosterSlot, StratGame, StratPlay, Decision, ScoutOpportunity, ScoutClaim, BattingSeasonStats, PitchingSeasonStats, ProcessedGame, RefractorTrack, RefractorCardState, RefractorBoostAudit, ] @pytest.fixture(autouse=False) def setup_render_db(): """Bind render-test models to shared-memory SQLite and create tables. What: Creates all tables needed by the card render endpoint before each test and drops them afterwards. Why: SQLite shared-memory databases persist between tests in the same process. Creating and dropping around each test guarantees a clean state without needing a real PostgreSQL instance or a separate process. """ _render_db.bind(_RENDER_MODELS) _render_db.connect(reuse_if_open=True) _render_db.create_tables(_RENDER_MODELS) yield _render_db _render_db.drop_tables(list(reversed(_RENDER_MODELS)), safe=True) def _build_render_app() -> FastAPI: """Minimal FastAPI app with the players router for render tests.""" from app.routers_v2.players import router as players_router app = FastAPI() @app.middleware("http") async def db_middleware(request: Request, call_next): _render_db.connect(reuse_if_open=True) return await call_next(request) app.include_router(players_router) return app @pytest.fixture def render_client(setup_render_db): """FastAPI TestClient backed by in-memory SQLite for card render tests.""" with TestClient(_build_render_app()) as c: yield c # --------------------------------------------------------------------------- # Seed helpers # --------------------------------------------------------------------------- def _make_rarity() -> Rarity: r, _ = Rarity.get_or_create( value=20, name="RC_Common", defaults={"color": "#aabbcc"} ) return r def _make_cardset() -> Cardset: cs, _ = Cardset.get_or_create( name="RC Test Set", defaults={"description": "render card test cardset", "total_cards": 1}, ) return cs def _make_player() -> Player: return Player.create( p_name="RC Player", rarity=_make_rarity(), cardset=_make_cardset(), set_num=1, pos_1="CF", image="https://example.com/rc.png", mlbclub="TST", franchise="TST", description="RC test player", ) def _make_batting_card(player: Player, variant: int = 1, image_url=None) -> BattingCard: return BattingCard.create( player=player, variant=variant, steal_low=1, steal_high=3, steal_auto=False, steal_jump=1.0, bunting="N", hit_and_run="N", running=5, offense_col=1, hand="R", image_url=image_url, ) def _mock_file_response() -> MagicMock: """Return a MagicMock that behaves like a 200 FileResponse from TestClient.""" mock = MagicMock() mock.status_code = 200 mock.headers = {"content-type": "image/png"} return mock # --------------------------------------------------------------------------- # Tests: cache-hit path — _schedule_variant_image_backfill integration # --------------------------------------------------------------------------- def test_cache_hit_with_null_image_url_schedules_backfill( render_client, setup_render_db ): """Cache hit for a variant with image_url=NULL schedules a backfill task. What: Seeds a player and a BattingCard with variant=1 and image_url=None. Mocks os.path.isfile to return True (simulating a cached PNG on disk). Patches backfill_variant_image_url to capture calls. Asserts the backfill function was scheduled via background_tasks.add_task. Why: This is the exact bug scenario: variant row recreated with image_url=NULL while the cached PNG still exists on disk. The cache-hit path previously returned FileResponse immediately without scheduling the backfill, leaving image_url NULL forever. The fix must call _schedule_variant_image_backfill before returning. """ player = _make_player() _make_batting_card(player, variant=1, image_url=None) with ( patch("os.path.isfile", return_value=True), patch( "app.routers_v2.players.FileResponse", return_value=_mock_file_response(), ), patch("app.routers_v2.players.backfill_variant_image_url") as mock_backfill, ): resp = render_client.get( f"/api/v2/players/{player.player_id}/battingcard/2026-04-11/1" ) assert resp.status_code == 200 mock_backfill.assert_called_once() call_kwargs = mock_backfill.call_args.kwargs assert call_kwargs["player_id"] == player.player_id assert call_kwargs["variant"] == 1 assert call_kwargs["card_type"] == "batting" def test_cache_hit_with_populated_image_url_does_not_schedule_backfill( render_client, setup_render_db ): """Cache hit for a variant with image_url already set skips the backfill. What: Seeds a player and a BattingCard with variant=1 and a real image_url (simulating the happy-path row that already has S3 URL populated). Mocks os.path.isfile to return True. Asserts backfill_variant_image_url is NOT called. Why: The helper's internal guard checks card_row.image_url is None before scheduling. A cache hit for a row that already has image_url must be a true no-op — we must not enqueue a redundant S3 upload on every hit. """ player = _make_player() _make_batting_card( player, variant=1, image_url="https://paper-dynasty.s3.amazonaws.com/cards/test.png", ) with ( patch("os.path.isfile", return_value=True), patch( "app.routers_v2.players.FileResponse", return_value=_mock_file_response(), ), patch("app.routers_v2.players.backfill_variant_image_url") as mock_backfill, ): resp = render_client.get( f"/api/v2/players/{player.player_id}/battingcard/2026-04-11/1" ) assert resp.status_code == 200 mock_backfill.assert_not_called() def test_cache_hit_with_missing_card_row_does_not_crash(render_client, setup_render_db): """Cache hit when the BattingCard row doesn't exist is a no-op, no crash. What: Seeds a player but no BattingCard row for variant=1. Mocks os.path.isfile to return True. Asserts the endpoint returns 200 and backfill_variant_image_url is NOT called. Why: The _schedule_variant_image_backfill helper catches DoesNotExist and passes. This test ensures the endpoint does not crash when the PNG exists on disk but the DB row has been deleted — an edge case that can occur during data corrections. """ player = _make_player() # Deliberately do NOT create a BattingCard row. with ( patch("os.path.isfile", return_value=True), patch( "app.routers_v2.players.FileResponse", return_value=_mock_file_response(), ), patch("app.routers_v2.players.backfill_variant_image_url") as mock_backfill, ): resp = render_client.get( f"/api/v2/players/{player.player_id}/battingcard/2026-04-11/1" ) assert resp.status_code == 200 mock_backfill.assert_not_called() def test_cache_hit_with_variant_zero_does_not_schedule_backfill( render_client, setup_render_db ): """Cache hit for variant=0 (base card) does not schedule a backfill. What: Seeds a player and a BattingCard with variant=0 (base card). Mocks os.path.isfile to return True. Asserts backfill_variant_image_url is NOT called. Why: variant=0 is the base card convention. Base cards are not stored as variant rows and do not need image_url backfill. The helper guards against this with `if variant == 0: return False`. """ player = _make_player() _make_batting_card(player, variant=0, image_url=None) with ( patch("os.path.isfile", return_value=True), patch( "app.routers_v2.players.FileResponse", return_value=_mock_file_response(), ), patch("app.routers_v2.players.backfill_variant_image_url") as mock_backfill, ): resp = render_client.get( f"/api/v2/players/{player.player_id}/battingcard/2026-04-11/0" ) assert resp.status_code == 200 mock_backfill.assert_not_called() def test_cache_hit_with_tier_override_does_not_schedule_backfill( render_client, setup_render_db ): """Cache hit with ?tier= query param bypasses cache and does not schedule backfill. What: Seeds a player and a BattingCard with variant=1 and image_url=None. Passes ?tier=2 to the endpoint. The ?tier= override intentionally bypasses the file-on-disk cache (html is False, but tier is not None so the short-circuit does not fire). Asserts backfill_variant_image_url is NOT called from the cache-hit path. Why: ?tier= is a dev preview mode that overrides the refractor tier for visual testing. These renders do NOT correspond to real variant card rows and must not trigger a backfill. The helper guards against this with `if tier is not None: return False`. Additionally, the cache-hit condition itself requires `tier is None`, so the entire short-circuit is bypassed — this test confirms the no-op behaviour even if the guard were ever relaxed. """ player = _make_player() _make_batting_card(player, variant=1, image_url=None) with ( patch("os.path.isfile", return_value=True), patch("app.routers_v2.players.backfill_variant_image_url") as mock_backfill, ): # When ?tier= is set, the cache-hit branch is skipped entirely # (tier is not None), so the endpoint proceeds to the full render path. # Mock the browser and screenshot to avoid Playwright launch. with ( patch("app.routers_v2.players.get_browser") as mock_browser, patch( "app.routers_v2.players.FileResponse", return_value=_mock_file_response(), ), ): mock_page = MagicMock() mock_page.set_content = MagicMock(return_value=None) mock_page.screenshot = MagicMock(return_value=None) mock_page.close = MagicMock(return_value=None) mock_browser_instance = MagicMock() mock_browser_instance.new_page = MagicMock(return_value=mock_page) mock_browser.return_value = mock_browser_instance # Use ?tier=2 — this bypasses the cache-hit short-circuit render_client.get( f"/api/v2/players/{player.player_id}/battingcard/2026-04-11/1?tier=2" ) # With tier override, the cache-hit path is skipped entirely. # The full-render path calls _schedule_variant_image_backfill, but the # helper's `tier is not None` guard means backfill is still not scheduled. mock_backfill.assert_not_called()