""" 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= 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), )