import os import pytest from apps.competitions.models import Competition, Season from apps.ingestion.models import IngestionError, IngestionRun from apps.ingestion.services.sync import run_sync_job from apps.players.models import Player from apps.providers.exceptions import ProviderRateLimitError from apps.providers.models import ExternalMapping from apps.stats.models import PlayerSeason, PlayerSeasonStats from apps.teams.models import Team @pytest.mark.django_db def test_run_full_sync_creates_domain_objects(settings): settings.PROVIDER_DEFAULT_NAMESPACE = "mvp_demo" run = run_sync_job(provider_namespace="mvp_demo", job_type=IngestionRun.JobType.FULL_SYNC) assert run.status == IngestionRun.RunStatus.SUCCESS assert Competition.objects.count() >= 1 assert Team.objects.count() >= 1 assert Season.objects.count() >= 1 assert Player.objects.count() >= 1 assert PlayerSeason.objects.count() >= 1 assert PlayerSeasonStats.objects.count() >= 1 @pytest.mark.django_db def test_full_sync_is_idempotent(settings): settings.PROVIDER_DEFAULT_NAMESPACE = "mvp_demo" run_sync_job(provider_namespace="mvp_demo", job_type=IngestionRun.JobType.FULL_SYNC) counts_after_first = { "competition": Competition.objects.count(), "team": Team.objects.count(), "season": Season.objects.count(), "player": Player.objects.count(), "player_season": PlayerSeason.objects.count(), "player_stats": PlayerSeasonStats.objects.count(), } run_sync_job(provider_namespace="mvp_demo", job_type=IngestionRun.JobType.FULL_SYNC) counts_after_second = { "competition": Competition.objects.count(), "team": Team.objects.count(), "season": Season.objects.count(), "player": Player.objects.count(), "player_season": PlayerSeason.objects.count(), "player_stats": PlayerSeasonStats.objects.count(), } assert counts_after_first == counts_after_second @pytest.mark.django_db def test_incremental_sync_runs_successfully(settings): settings.PROVIDER_DEFAULT_NAMESPACE = "mvp_demo" run = run_sync_job( provider_namespace="mvp_demo", job_type=IngestionRun.JobType.INCREMENTAL, cursor="demo-cursor", ) assert run.status == IngestionRun.RunStatus.SUCCESS assert run.records_processed > 0 @pytest.mark.django_db def test_run_sync_handles_rate_limit(settings): settings.PROVIDER_DEFAULT_NAMESPACE = "mvp_demo" os.environ["PROVIDER_MVP_FORCE_RATE_LIMIT"] = "1" with pytest.raises(ProviderRateLimitError): run_sync_job(provider_namespace="mvp_demo", job_type=IngestionRun.JobType.FULL_SYNC) run = IngestionRun.objects.order_by("-id").first() assert run is not None assert run.status == IngestionRun.RunStatus.FAILED assert IngestionError.objects.filter(ingestion_run=run).exists() os.environ.pop("PROVIDER_MVP_FORCE_RATE_LIMIT", None) @pytest.mark.django_db def test_balldontlie_sync_idempotency_with_stable_payload(monkeypatch): class StableProvider: def sync_all(self): return { "competitions": [ { "external_id": "competition-nba", "name": "NBA", "slug": "nba", "competition_type": "league", "gender": "men", "level": 1, "country": {"name": "United States", "iso2_code": "US", "iso3_code": "USA"}, "is_active": True, } ], "teams": [ { "external_id": "team-14", "name": "Los Angeles Lakers", "short_name": "LAL", "slug": "los-angeles-lakers", "country": {"name": "United States", "iso2_code": "US", "iso3_code": "USA"}, "is_national_team": False, } ], "seasons": [ { "external_id": "season-2024", "label": "2024-2025", "start_date": "2024-10-01", "end_date": "2025-06-30", "is_current": False, } ], "players": [ { "external_id": "player-237", "first_name": "LeBron", "last_name": "James", "full_name": "LeBron James", "birth_date": None, "nationality": {"name": "United States", "iso2_code": "US", "iso3_code": "USA"}, "nominal_position": {"code": "SF", "name": "Small Forward"}, "inferred_role": {"code": "wing", "name": "Wing"}, "height_cm": None, "weight_kg": None, "dominant_hand": "unknown", "is_active": True, "aliases": [], } ], "player_stats": [ { "external_id": "ps-2024-237-14", "player_external_id": "player-237", "team_external_id": "team-14", "competition_external_id": "competition-nba", "season_external_id": "season-2024", "games_played": 2, "games_started": 0, "minutes_played": 68, "points": 25, "rebounds": 9, "assists": 8, "steals": 1.5, "blocks": 0.5, "turnovers": 3.5, "fg_pct": 55.0, "three_pct": 45.0, "ft_pct": 95.0, "usage_rate": None, "true_shooting_pct": None, "player_efficiency_rating": None, } ], "player_careers": [ { "external_id": "career-2024-237-14", "player_external_id": "player-237", "team_external_id": "team-14", "competition_external_id": "competition-nba", "season_external_id": "season-2024", "role_code": "", "shirt_number": None, "start_date": "2024-10-01", "end_date": "2025-06-30", "notes": "Imported from balldontlie aggregated box scores", } ], } def sync_incremental(self, *, cursor: str | None = None): payload = self.sync_all() payload["cursor"] = cursor return payload monkeypatch.setattr("apps.ingestion.services.sync.get_provider", lambda namespace: StableProvider()) run_sync_job(provider_namespace="balldontlie", job_type=IngestionRun.JobType.FULL_SYNC) counts_first = { "competition": Competition.objects.count(), "team": Team.objects.count(), "season": Season.objects.count(), "player": Player.objects.count(), "player_season": PlayerSeason.objects.count(), "player_stats": PlayerSeasonStats.objects.count(), "mapping": ExternalMapping.objects.filter(provider_namespace="balldontlie").count(), } run_sync_job(provider_namespace="balldontlie", job_type=IngestionRun.JobType.FULL_SYNC) counts_second = { "competition": Competition.objects.count(), "team": Team.objects.count(), "season": Season.objects.count(), "player": Player.objects.count(), "player_season": PlayerSeason.objects.count(), "player_stats": PlayerSeasonStats.objects.count(), "mapping": ExternalMapping.objects.filter(provider_namespace="balldontlie").count(), } assert counts_first == counts_second