New service with S3 upload functions for the refractor card art pipeline. backfill_variant_image_url reads rendered PNGs from disk, uploads to S3, and sets image_url on BattingCard/PitchingCard rows. 18 tests covering key construction, URL formatting, upload params, and error swallowing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
351 lines
13 KiB
Python
351 lines
13 KiB
Python
"""
|
|
Unit tests for app/services/card_storage.py — S3 upload utility.
|
|
|
|
This module covers:
|
|
- S3 key construction for variant cards (batting, pitching, zero-padded cardset)
|
|
- Full S3 URL construction with cache-busting date param
|
|
- put_object call validation (correct params, return value)
|
|
- End-to-end backfill: read PNG from disk, upload to S3, update DB row
|
|
|
|
Why we test S3 key construction separately:
|
|
The key format is a contract used by both the renderer and the URL builder.
|
|
Validating it in isolation catches regressions before they corrupt stored URLs.
|
|
|
|
Why we test URL construction separately:
|
|
The cache-bust param (?d=...) must be appended consistently so that clients
|
|
invalidate cached images after a re-render. Testing it independently prevents
|
|
the formatter from silently changing.
|
|
|
|
Why we test upload params:
|
|
ContentType and CacheControl must be set exactly so that S3 serves images
|
|
with the correct headers. A missing header is a silent misconfiguration.
|
|
|
|
Why we test backfill error swallowing:
|
|
The backfill function is called as a background task — it must never raise
|
|
exceptions that would abort a card render response. We verify that S3 failures
|
|
and missing files are both silently logged, not propagated.
|
|
|
|
Test isolation:
|
|
All tests use unittest.mock; no real S3 calls or DB connections are made.
|
|
The `backfill_variant_image_url` tests patch `get_s3_client` and the DB
|
|
model classes at the card_storage module level so lazy imports work correctly.
|
|
"""
|
|
|
|
import os
|
|
from datetime import date
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
# Set env before importing module so db_engine doesn't try to connect
|
|
os.environ.setdefault("DATABASE_TYPE", "postgresql")
|
|
os.environ.setdefault("POSTGRES_PASSWORD", "test-dummy")
|
|
|
|
from app.services.card_storage import (
|
|
build_s3_key,
|
|
build_s3_url,
|
|
upload_card_to_s3,
|
|
backfill_variant_image_url,
|
|
S3_BUCKET,
|
|
S3_REGION,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestBuildS3Key
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBuildS3Key:
|
|
"""Tests for build_s3_key — S3 object key construction.
|
|
|
|
The key format must match the existing card-creation pipeline so that
|
|
the database API and card-creation tool write to the same S3 paths.
|
|
"""
|
|
|
|
def test_batting_card_key(self):
|
|
"""batting card type produces 'battingcard.png' in the key."""
|
|
key = build_s3_key(cardset_id=27, player_id=42, variant=1, card_type="batting")
|
|
assert key == "cards/cardset-027/player-42/v1/battingcard.png"
|
|
|
|
def test_pitching_card_key(self):
|
|
"""pitching card type produces 'pitchingcard.png' in the key."""
|
|
key = build_s3_key(cardset_id=27, player_id=99, variant=2, card_type="pitching")
|
|
assert key == "cards/cardset-027/player-99/v2/pitchingcard.png"
|
|
|
|
def test_cardset_zero_padded_to_three_digits(self):
|
|
"""Single-digit cardset IDs are zero-padded to three characters."""
|
|
key = build_s3_key(cardset_id=5, player_id=1, variant=0, card_type="batting")
|
|
assert "cardset-005" in key
|
|
|
|
def test_cardset_two_digit_zero_padded(self):
|
|
"""Two-digit cardset IDs are zero-padded correctly."""
|
|
key = build_s3_key(cardset_id=27, player_id=1, variant=0, card_type="batting")
|
|
assert "cardset-027" in key
|
|
|
|
def test_cardset_three_digit_no_padding(self):
|
|
"""Three-digit cardset IDs are not altered."""
|
|
key = build_s3_key(cardset_id=100, player_id=1, variant=0, card_type="batting")
|
|
assert "cardset-100" in key
|
|
|
|
def test_variant_included_in_key(self):
|
|
"""Variant number is included in the path so variants have distinct keys."""
|
|
key_v0 = build_s3_key(
|
|
cardset_id=27, player_id=1, variant=0, card_type="batting"
|
|
)
|
|
key_v3 = build_s3_key(
|
|
cardset_id=27, player_id=1, variant=3, card_type="batting"
|
|
)
|
|
assert "/v0/" in key_v0
|
|
assert "/v3/" in key_v3
|
|
assert key_v0 != key_v3
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestBuildS3Url
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBuildS3Url:
|
|
"""Tests for build_s3_url — full URL construction with cache-bust param.
|
|
|
|
The URL format must be predictable so clients can construct and verify
|
|
image URLs without querying the database.
|
|
"""
|
|
|
|
def test_url_contains_bucket_and_region(self):
|
|
"""URL includes bucket name and region in the S3 hostname."""
|
|
key = "cards/cardset-027/player-42/v1/battingcard.png"
|
|
render_date = date(2026, 4, 6)
|
|
url = build_s3_url(key, render_date)
|
|
assert S3_BUCKET in url
|
|
assert S3_REGION in url
|
|
|
|
def test_url_contains_s3_key(self):
|
|
"""URL path includes the full S3 key."""
|
|
key = "cards/cardset-027/player-42/v1/battingcard.png"
|
|
render_date = date(2026, 4, 6)
|
|
url = build_s3_url(key, render_date)
|
|
assert key in url
|
|
|
|
def test_url_has_cache_bust_param(self):
|
|
"""URL ends with ?d=<render_date> for cache invalidation."""
|
|
key = "cards/cardset-027/player-42/v1/battingcard.png"
|
|
render_date = date(2026, 4, 6)
|
|
url = build_s3_url(key, render_date)
|
|
assert "?d=2026-04-06" in url
|
|
|
|
def test_url_format_full(self):
|
|
"""Full URL matches expected S3 pattern exactly."""
|
|
key = "cards/cardset-027/player-1/v0/battingcard.png"
|
|
render_date = date(2025, 11, 8)
|
|
url = build_s3_url(key, render_date)
|
|
expected = (
|
|
f"https://{S3_BUCKET}.s3.{S3_REGION}.amazonaws.com/{key}?d=2025-11-08"
|
|
)
|
|
assert url == expected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestUploadCardToS3
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUploadCardToS3:
|
|
"""Tests for upload_card_to_s3 — S3 put_object call validation.
|
|
|
|
We verify the exact parameters passed to put_object so that S3 serves
|
|
images with the correct Content-Type and Cache-Control headers.
|
|
"""
|
|
|
|
def test_put_object_called_with_correct_params(self):
|
|
"""put_object is called once with bucket, key, body, ContentType, CacheControl."""
|
|
mock_client = MagicMock()
|
|
png_bytes = b"\x89PNG\r\n\x1a\n"
|
|
s3_key = "cards/cardset-027/player-42/v1/battingcard.png"
|
|
|
|
upload_card_to_s3(mock_client, png_bytes, s3_key)
|
|
|
|
mock_client.put_object.assert_called_once_with(
|
|
Bucket=S3_BUCKET,
|
|
Key=s3_key,
|
|
Body=png_bytes,
|
|
ContentType="image/png",
|
|
CacheControl="public, max-age=300",
|
|
)
|
|
|
|
def test_upload_returns_none(self):
|
|
"""upload_card_to_s3 returns None (callers should not rely on a return value)."""
|
|
mock_client = MagicMock()
|
|
result = upload_card_to_s3(mock_client, b"PNG", "some/key.png")
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestBackfillVariantImageUrl
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBackfillVariantImageUrl:
|
|
"""Tests for backfill_variant_image_url — end-to-end disk→S3→DB path.
|
|
|
|
The function is fire-and-forget: it reads a PNG from disk, uploads to S3,
|
|
then updates the appropriate card model's image_url. All errors are caught
|
|
and logged; the function must never raise.
|
|
|
|
Test strategy:
|
|
- Use tmp_path for temporary PNG files so no filesystem state leaks.
|
|
- Patch get_s3_client at the module level to intercept the S3 call.
|
|
- Patch BattingCard/PitchingCard at the module level (lazy import target).
|
|
"""
|
|
|
|
def test_batting_card_image_url_updated(self, tmp_path):
|
|
"""BattingCard.image_url is updated after a successful upload."""
|
|
png_path = tmp_path / "card.png"
|
|
png_path.write_bytes(b"\x89PNG\r\n\x1a\n fake png data")
|
|
|
|
mock_s3 = MagicMock()
|
|
mock_card = MagicMock()
|
|
|
|
with (
|
|
patch("app.services.card_storage.get_s3_client", return_value=mock_s3),
|
|
patch("app.db_engine.BattingCard") as MockBatting,
|
|
):
|
|
MockBatting.get.return_value = mock_card
|
|
|
|
backfill_variant_image_url(
|
|
player_id=42,
|
|
variant=1,
|
|
card_type="batting",
|
|
cardset_id=27,
|
|
png_path=str(png_path),
|
|
)
|
|
|
|
MockBatting.get.assert_called_once_with(
|
|
MockBatting.player == 42, MockBatting.variant == 1
|
|
)
|
|
assert mock_card.image_url is not None
|
|
mock_card.save.assert_called_once()
|
|
|
|
def test_pitching_card_image_url_updated(self, tmp_path):
|
|
"""PitchingCard.image_url is updated after a successful upload."""
|
|
png_path = tmp_path / "card.png"
|
|
png_path.write_bytes(b"\x89PNG\r\n\x1a\n fake png data")
|
|
|
|
mock_s3 = MagicMock()
|
|
mock_card = MagicMock()
|
|
|
|
with (
|
|
patch("app.services.card_storage.get_s3_client", return_value=mock_s3),
|
|
patch("app.db_engine.PitchingCard") as MockPitching,
|
|
):
|
|
MockPitching.get.return_value = mock_card
|
|
|
|
backfill_variant_image_url(
|
|
player_id=99,
|
|
variant=2,
|
|
card_type="pitching",
|
|
cardset_id=27,
|
|
png_path=str(png_path),
|
|
)
|
|
|
|
MockPitching.get.assert_called_once_with(
|
|
MockPitching.player == 99, MockPitching.variant == 2
|
|
)
|
|
assert mock_card.image_url is not None
|
|
mock_card.save.assert_called_once()
|
|
|
|
def test_s3_upload_called_with_png_bytes(self, tmp_path):
|
|
"""The PNG bytes read from disk are passed to put_object."""
|
|
png_bytes = b"\x89PNG\r\n\x1a\n real png content"
|
|
png_path = tmp_path / "card.png"
|
|
png_path.write_bytes(png_bytes)
|
|
|
|
mock_s3 = MagicMock()
|
|
|
|
with (
|
|
patch("app.services.card_storage.get_s3_client", return_value=mock_s3),
|
|
patch("app.db_engine.BattingCard") as MockBatting,
|
|
):
|
|
MockBatting.get.return_value = MagicMock()
|
|
|
|
backfill_variant_image_url(
|
|
player_id=1,
|
|
variant=0,
|
|
card_type="batting",
|
|
cardset_id=5,
|
|
png_path=str(png_path),
|
|
)
|
|
|
|
mock_s3.put_object.assert_called_once()
|
|
call_kwargs = mock_s3.put_object.call_args.kwargs
|
|
assert call_kwargs["Body"] == png_bytes
|
|
|
|
def test_s3_error_is_swallowed(self, tmp_path):
|
|
"""If S3 raises an exception, backfill swallows it and returns normally.
|
|
|
|
The function is called as a background task — it must never propagate
|
|
exceptions that would abort the calling request handler.
|
|
"""
|
|
png_path = tmp_path / "card.png"
|
|
png_path.write_bytes(b"PNG data")
|
|
|
|
mock_s3 = MagicMock()
|
|
mock_s3.put_object.side_effect = Exception("S3 connection refused")
|
|
|
|
with (
|
|
patch("app.services.card_storage.get_s3_client", return_value=mock_s3),
|
|
patch("app.db_engine.BattingCard"),
|
|
):
|
|
# Must not raise
|
|
backfill_variant_image_url(
|
|
player_id=1,
|
|
variant=0,
|
|
card_type="batting",
|
|
cardset_id=27,
|
|
png_path=str(png_path),
|
|
)
|
|
|
|
def test_missing_file_is_swallowed(self, tmp_path):
|
|
"""If the PNG file does not exist, backfill swallows the error and returns.
|
|
|
|
Render failures may leave no file on disk; the background task must
|
|
handle this gracefully rather than crashing the request.
|
|
"""
|
|
missing_path = str(tmp_path / "nonexistent.png")
|
|
|
|
with (
|
|
patch("app.services.card_storage.get_s3_client"),
|
|
patch("app.db_engine.BattingCard"),
|
|
):
|
|
# Must not raise
|
|
backfill_variant_image_url(
|
|
player_id=1,
|
|
variant=0,
|
|
card_type="batting",
|
|
cardset_id=27,
|
|
png_path=missing_path,
|
|
)
|
|
|
|
def test_db_error_is_swallowed(self, tmp_path):
|
|
"""If the DB save raises, backfill swallows it and returns normally."""
|
|
png_path = tmp_path / "card.png"
|
|
png_path.write_bytes(b"PNG data")
|
|
|
|
mock_s3 = MagicMock()
|
|
mock_card = MagicMock()
|
|
mock_card.save.side_effect = Exception("DB connection lost")
|
|
|
|
with (
|
|
patch("app.services.card_storage.get_s3_client", return_value=mock_s3),
|
|
patch("app.db_engine.BattingCard") as MockBatting,
|
|
):
|
|
MockBatting.get.return_value = mock_card
|
|
|
|
# Must not raise
|
|
backfill_variant_image_url(
|
|
player_id=1,
|
|
variant=0,
|
|
card_type="batting",
|
|
cardset_id=27,
|
|
png_path=str(png_path),
|
|
)
|