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) <noreply@anthropic.com>
This commit is contained in:
parent
1169599b8d
commit
777fd0e440
@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
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 ..dependencies import oauth2_scheme, valid_token
|
||||||
from ..services.refractor_init import initialize_card_refractor, _determine_card_type
|
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:
|
if player_name is not None:
|
||||||
result["player_name"] = player_name
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
311
tests/test_refractor_image_url.py
Normal file
311
tests/test_refractor_image_url.py
Normal file
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user