paper-dynasty-database/tests/test_players_card_render.py
Cal Corum c0f862636a fix(render): schedule image_url backfill on cache hits
The card render endpoint's file-on-disk cache short-circuit returned
FileResponse immediately when the cached PNG existed, bypassing the
backfill BackgroundTask that populates battingcard.image_url /
pitchingcard.image_url. If a variant row was later recreated with
image_url=NULL while the PNG was still cached, the DB column stayed
NULL forever, breaking every downstream consumer that reads image_url for
display.

Extract the backfill-scheduling logic into a _schedule_variant_image_backfill
helper and call it on both the cache-hit and full-render paths. The
helper's internal guards (variant > 0, tier is None, row exists with
NULL image_url) keep the happy-path cache hit a no-op.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 10:12:23 -05:00

418 lines
14 KiB
Python

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