From 777fd0e4404ccd68a4cdeea8d7fd7467b4ba1552 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Mon, 6 Apr 2026 17:12:41 -0500 Subject: [PATCH] feat: include image_url in refractor cards API response Adds image_url field to each card state entry in the GET /api/v2/refractor/cards response. Resolved by looking up the variant BattingCard/PitchingCard row. Returns null when no image has been rendered yet. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/routers_v2/refractor.py | 20 +- tests/test_refractor_image_url.py | 311 ++++++++++++++++++++++++++++++ 2 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 tests/test_refractor_image_url.py diff --git a/app/routers_v2/refractor.py b/app/routers_v2/refractor.py index ba9c4c5..d680a32 100644 --- a/app/routers_v2/refractor.py +++ b/app/routers_v2/refractor.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query import logging from typing import Optional -from ..db_engine import model_to_dict +from ..db_engine import model_to_dict, BattingCard, PitchingCard from ..dependencies import oauth2_scheme, valid_token from ..services.refractor_init import initialize_card_refractor, _determine_card_type @@ -67,6 +67,24 @@ def _build_card_state_response(state, player_name=None) -> dict: if player_name is not None: result["player_name"] = player_name + # Resolve image_url from the variant card row + image_url = None + if state.variant and state.variant > 0: + card_type = ( + state.track.card_type if hasattr(state, "track") and state.track else None + ) + if card_type: + CardModel = BattingCard if card_type == "batter" else PitchingCard + try: + variant_card = CardModel.get( + (CardModel.player_id == state.player_id) + & (CardModel.variant == state.variant) + ) + image_url = variant_card.image_url + except CardModel.DoesNotExist: + pass + result["image_url"] = image_url + return result diff --git a/tests/test_refractor_image_url.py b/tests/test_refractor_image_url.py new file mode 100644 index 0000000..ccdae09 --- /dev/null +++ b/tests/test_refractor_image_url.py @@ -0,0 +1,311 @@ +"""Tests for image_url field in refractor cards API response. + +What: Verifies that GET /api/v2/refractor/cards includes image_url in each card state +item, pulling the URL from the variant BattingCard or PitchingCard row. + +Why: The refractor card art pipeline stores rendered card image URLs in the +BattingCard/PitchingCard rows. The Discord bot and website need image_url in +the /refractor/cards response so they can display variant art without a separate +lookup. These tests guard against regressions where image_url is accidentally +dropped from the response serialization. + +Test cases: + test_cards_response_includes_image_url -- BattingCard with image_url set; verify + the value appears in the /cards response. + test_cards_response_image_url_null_when_not_set -- BattingCard with image_url=None; + verify null is returned (not omitted). + +Uses the shared-memory SQLite TestClient pattern from test_refractor_state_api.py +so no PostgreSQL connection is required. +""" + +import os + +os.environ.setdefault("API_TOKEN", "test") + +import pytest +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient +from peewee import SqliteDatabase + +from app.db_engine import ( + BattingCard, + BattingSeasonStats, + Card, + Cardset, + Decision, + Event, + MlbPlayer, + Pack, + PackType, + PitchingCard, + PitchingSeasonStats, + Player, + ProcessedGame, + Rarity, + RefractorCardState, + RefractorCosmetic, + RefractorTierBoost, + RefractorTrack, + Roster, + RosterSlot, + ScoutClaim, + ScoutOpportunity, + StratGame, + StratPlay, + Team, +) + +AUTH_HEADER = {"Authorization": "Bearer test"} + +# --------------------------------------------------------------------------- +# SQLite database + model list +# --------------------------------------------------------------------------- + +_img_url_db = SqliteDatabase( + "file:imgurlapitest?mode=memory&cache=shared", + uri=True, + pragmas={"foreign_keys": 1}, +) + +# Full model list matching the existing state API tests — needed so all FK +# constraints resolve in SQLite. +_IMG_URL_MODELS = [ + Rarity, + Event, + Cardset, + MlbPlayer, + Player, + BattingCard, + PitchingCard, + Team, + PackType, + Pack, + Card, + Roster, + RosterSlot, + StratGame, + StratPlay, + Decision, + ScoutOpportunity, + ScoutClaim, + BattingSeasonStats, + PitchingSeasonStats, + ProcessedGame, + RefractorTrack, + RefractorCardState, + RefractorTierBoost, + RefractorCosmetic, +] + + +@pytest.fixture(autouse=False) +def setup_img_url_db(): + """Bind image-url test models to shared-memory SQLite and create tables. + + What: Initialises the in-process SQLite database before each test and drops + all tables afterwards to ensure test isolation. + + Why: SQLite shared-memory databases persist between tests in the same + process unless tables are dropped. Creating and dropping around each test + guarantees a clean state without requiring a real PostgreSQL instance. + """ + _img_url_db.bind(_IMG_URL_MODELS) + _img_url_db.connect(reuse_if_open=True) + _img_url_db.create_tables(_IMG_URL_MODELS) + yield _img_url_db + _img_url_db.drop_tables(list(reversed(_IMG_URL_MODELS)), safe=True) + + +def _build_image_url_app() -> FastAPI: + """Minimal FastAPI app with refractor router for image_url tests.""" + from app.routers_v2.refractor import router as refractor_router + + app = FastAPI() + + @app.middleware("http") + async def db_middleware(request: Request, call_next): + _img_url_db.connect(reuse_if_open=True) + return await call_next(request) + + app.include_router(refractor_router) + return app + + +@pytest.fixture +def img_url_client(setup_img_url_db): + """FastAPI TestClient backed by shared-memory SQLite for image_url tests.""" + with TestClient(_build_image_url_app()) as c: + yield c + + +# --------------------------------------------------------------------------- +# Seed helpers +# --------------------------------------------------------------------------- + + +def _make_rarity(): + r, _ = Rarity.get_or_create( + value=10, name="IU_Common", defaults={"color": "#ffffff"} + ) + return r + + +def _make_cardset(): + cs, _ = Cardset.get_or_create( + name="IU Test Set", + defaults={"description": "image url test cardset", "total_cards": 1}, + ) + return cs + + +def _make_player(name: str = "Test Player") -> Player: + return Player.create( + p_name=name, + rarity=_make_rarity(), + cardset=_make_cardset(), + set_num=1, + pos_1="CF", + image="https://example.com/img.png", + mlbclub="TST", + franchise="TST", + description="image url test", + ) + + +def _make_team(suffix: str = "IU") -> Team: + return Team.create( + abbrev=suffix, + sname=suffix, + lname=f"Team {suffix}", + gmid=99900 + len(suffix), + gmname=f"gm_{suffix.lower()}", + gsheet="https://docs.google.com/iu_test", + wallet=500, + team_value=1000, + collection_value=1000, + season=11, + is_ai=False, + ) + + +def _make_track(card_type: str = "batter") -> RefractorTrack: + track, _ = RefractorTrack.get_or_create( + name=f"IU {card_type} Track", + defaults=dict( + card_type=card_type, + formula="pa", + t1_threshold=100, + t2_threshold=300, + t3_threshold=700, + t4_threshold=1200, + ), + ) + return track + + +def _make_batting_card(player: Player, variant: int, 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 _make_card_state( + player: Player, + team: Team, + track: RefractorTrack, + variant: int, + current_tier: int = 1, + current_value: float = 150.0, +) -> RefractorCardState: + import datetime + + return RefractorCardState.create( + player=player, + team=team, + track=track, + current_tier=current_tier, + current_value=current_value, + fully_evolved=False, + last_evaluated_at=datetime.datetime(2026, 4, 1, 12, 0, 0), + variant=variant, + ) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_cards_response_includes_image_url(setup_img_url_db, img_url_client): + """GET /api/v2/refractor/cards includes image_url when the variant BattingCard has one. + + What: Seeds a RefractorCardState at variant=1 and a matching BattingCard with + image_url set. Calls the /cards endpoint and asserts that image_url in the + response matches the seeded URL. + + Why: This is the primary happy-path test for the image_url feature. If the + DB lookup in _build_card_state_response fails or the field is accidentally + omitted from the response dict, this test will catch it. + """ + player = _make_player("Homer Simpson") + team = _make_team("IU1") + track = _make_track("batter") + + expected_url = ( + "https://s3.example.com/cards/cardset-001/player-1/v1/battingcard.png" + ) + _make_batting_card(player, variant=1, image_url=expected_url) + _make_card_state(player, team, track, variant=1) + + resp = img_url_client.get( + f"/api/v2/refractor/cards?team_id={team.id}&evaluated_only=false", + headers=AUTH_HEADER, + ) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["count"] == 1 + item = data["items"][0] + assert "image_url" in item, "image_url key missing from response" + assert item["image_url"] == expected_url + + +def test_cards_response_image_url_null_when_not_set(setup_img_url_db, img_url_client): + """GET /api/v2/refractor/cards returns image_url: null when BattingCard.image_url is None. + + What: Seeds a BattingCard with image_url=None and a RefractorCardState at + variant=1. Verifies the response contains image_url with a null value. + + Why: The image_url field must always be present in the response (even when + null) so API consumers can rely on its presence. Returning null rather than + omitting the key is the correct contract — omitting it would break consumers + that check for the key's presence to determine upload status. + """ + player = _make_player("Bart Simpson") + team = _make_team("IU2") + track = _make_track("batter") + + _make_batting_card(player, variant=1, image_url=None) + _make_card_state(player, team, track, variant=1) + + resp = img_url_client.get( + f"/api/v2/refractor/cards?team_id={team.id}&evaluated_only=false", + headers=AUTH_HEADER, + ) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["count"] == 1 + item = data["items"][0] + assert "image_url" in item, "image_url key missing from response" + assert item["image_url"] is None