import uuid from collections.abc import Iterator from unittest import mock import pytest from pytest_mock import MockerFixture from resonance_engine.dsp.enums import DSPClientName from resonance_engine.dsp.models import DSPClient from resonance_engine.dsp.stats import RequestStats from resonance_engine.fandata.collector import FanBatchOutcome from resonance_engine.fandata.enums import FanConnectionStatus from resonance_engine.fandata.models import ( FanConnection, FanConnectionFilter, ) from resonance_engine.fandata.types import FanRecord from resonance_engine.tasks.enums import FanoutSource, TaskFinishedReason, TaskStatus from resonance_engine.tasks.handlers import ( RunCollectDLQRequest, RunCollectRequest, RunFanoutRequest, run_collect, run_collect_dlq, run_fanout, ) from resonance_engine.tasks.models import CollectTask, FanoutTask from resonance_engine.tasks.planner import FanoutPlan, FanoutPlanResult from resonance_engine.tasks.types import FanCollectBatch from tests.unit.helpers import create_model, override_settings class TestRunFanout: @pytest.fixture(autouse=True) def gateway_mock(self, mocker: MockerFixture) -> mock.MagicMock: m = mocker.patch( "resonance_engine.tasks.planner.dsp_gateway", new_callable=mock.MagicMock, ) m.is_configured.return_value = True m.stats.return_value = RequestStats() return m @pytest.fixture(autouse=True) def sqs_mock(self, mocker: MockerFixture) -> mock.MagicMock: return mocker.patch( "resonance_engine.tasks.handlers.aws_sqs", new_callable=mock.MagicMock, ) @pytest.fixture(autouse=True) def fanout_async(self) -> Iterator[None]: with override_settings(fan_fanout_async_enabled=True): yield @pytest.mark.db def test_returns_results(self, mocker: MockerFixture) -> None: plan = FanoutPlan( fanout_count=5, fanout_count_source="seed", fanout_window_s=600, ) fan = FanRecord(fan_id="u1", token_encrypted="tok") mocker.patch( "resonance_engine.tasks.planner.plan_fanout", return_value=[ FanoutPlanResult( client_name=DSPClientName.spotify_songwhip, plan=plan, batch_size=26, fans=[fan, fan, fan], filters=FanConnectionFilter(), ), ], ) mocker.patch( "resonance_engine.tasks.handlers.queue_url_for", return_value=None, ) response = run_fanout(RunFanoutRequest(source=FanoutSource.scheduled)) assert len(response.results) == 1 assert response.results[0].client_name == DSPClientName.spotify_songwhip assert response.results[0].plan.fanout_count == 5 assert len(response.results[0].fans) == 3 @pytest.mark.db def test_dispatches_fans_to_sqs( self, sqs_mock: mock.MagicMock, mocker: MockerFixture, ) -> None: mocker.patch( "resonance_engine.tasks.utils.settings.sqs_fan_collect_spotify_songwhip_queue_url", "https://sqs.test/songwhip", ) client = create_model( DSPClient, name=DSPClientName.spotify_songwhip, nominal_rps=11 ) create_model( FanConnection, fan_id="u1", dsp_client_id=client.id, status=FanConnectionStatus.active, ) run_fanout(RunFanoutRequest(source=FanoutSource.scheduled)) sqs_mock.send_messages_batch.assert_called_once() call_args = sqs_mock.send_messages_batch.call_args assert call_args.kwargs["queue_url"] == "https://sqs.test/songwhip" bodies = list(call_args.kwargs["bodies"]) assert len(bodies) == 1 assert '"fans":[{"fan_id":"u1"' in bodies[0] @pytest.mark.db def test_fans_chunked_by_batch_size( self, sqs_mock: mock.MagicMock, mocker: MockerFixture, ) -> None: """Fans must be chunked by the planner's batch_size, not a fixed constant.""" mocker.patch( "resonance_engine.tasks.utils.settings.sqs_fan_collect_spotify_songwhip_queue_url", "https://sqs.test/songwhip", ) # Force batch_size=3 so 7 fans → ceil(7/3) = 3 messages mocker.patch( "resonance_engine.tasks.planner.get_batch_size_for", return_value=3 ) client = create_model( DSPClient, name=DSPClientName.spotify_songwhip, nominal_rps=11 ) for i in range(7): create_model( FanConnection, fan_id=f"u{i}", dsp_client_id=client.id, status=FanConnectionStatus.active, ) run_fanout(RunFanoutRequest(source=FanoutSource.scheduled)) import json bodies = list(sqs_mock.send_messages_batch.call_args.kwargs["bodies"]) assert len(bodies) == 3 # ceil(7 / 3) fan_counts = [len(json.loads(b)["fans"]) for b in bodies] assert fan_counts == [3, 3, 1] @pytest.mark.db def test_skips_dispatch_when_no_queue_configured( self, sqs_mock: mock.MagicMock, mocker: MockerFixture, ) -> None: mocker.patch( "resonance_engine.tasks.utils.settings.sqs_fan_collect_spotify_songwhip_queue_url", "", ) client = create_model( DSPClient, name=DSPClientName.spotify_songwhip, nominal_rps=11, ) create_model( FanConnection, fan_id="u1", dsp_client_id=client.id, status=FanConnectionStatus.active, ) run_fanout(RunFanoutRequest(source=FanoutSource.scheduled)) sqs_mock.send_messages_batch.assert_not_called() @pytest.mark.db def test_skips_dispatch_when_no_fans( self, sqs_mock: mock.MagicMock, mocker: MockerFixture, ) -> None: mocker.patch( "resonance_engine.tasks.utils.settings.sqs_fan_collect_spotify_songwhip_queue_url", "https://sqs.test/songwhip", ) create_model(DSPClient, name=DSPClientName.spotify_songwhip, nominal_rps=11) response = run_fanout(RunFanoutRequest(source=FanoutSource.scheduled)) sqs_mock.send_messages_batch.assert_not_called() assert response.task is None @pytest.mark.db def test_creates_fanout_task_with_correct_source( self, mocker: MockerFixture, ) -> None: mocker.patch( "resonance_engine.tasks.utils.settings.sqs_fan_collect_spotify_songwhip_queue_url", "https://sqs.test/songwhip", ) client = create_model( DSPClient, name=DSPClientName.spotify_songwhip, nominal_rps=11 ) create_model(FanConnection, fan_id="u1", dsp_client_id=client.id) response = run_fanout(RunFanoutRequest(source=FanoutSource.triggered)) task = response.task assert task is not None assert task.source == FanoutSource.triggered assert task.status == TaskStatus.running @pytest.mark.db def test_stamps_last_dispatched_at_on_connection( self, mocker: MockerFixture, ) -> None: mocker.patch( "resonance_engine.tasks.utils.settings.sqs_fan_collect_spotify_songwhip_queue_url", "https://sqs.test/songwhip", ) client = create_model( DSPClient, name=DSPClientName.spotify_songwhip, nominal_rps=11 ) create_model(FanConnection, fan_id="u1", dsp_client_id=client.id) run_fanout(RunFanoutRequest(source=FanoutSource.scheduled)) conn = FanConnection.query.where(FanConnection.fan_id == "u1").one() assert conn.last_dispatched_at is not None @pytest.mark.db def test_updates_dispatch_counters( self, mocker: MockerFixture, ) -> None: mocker.patch( "resonance_engine.tasks.utils.settings.sqs_fan_collect_spotify_songwhip_queue_url", "https://sqs.test/songwhip", ) client = create_model( DSPClient, name=DSPClientName.spotify_songwhip, nominal_rps=11 ) create_model(FanConnection, fan_id="u1", dsp_client_id=client.id) create_model(FanConnection, fan_id="u2", dsp_client_id=client.id) response = run_fanout(RunFanoutRequest(source=FanoutSource.scheduled)) task = response.task assert task is not None task.refresh() assert task.fans_dispatched == 2 assert task.messages_sent == 1 # both fans fit in one batch class TestRunCollect: @pytest.fixture(autouse=True) def collect_fans_mock(self, mocker: MockerFixture) -> mock.MagicMock: return mocker.patch( "resonance_engine.tasks.handlers.collector.collect_fans", return_value=FanBatchOutcome( fans_processed=2, fans_errors=0, fans_stale_tokens=0, fans_skipped=0, requests=6, requests_rate_limited=0, requests_skipped=0, ), ) @pytest.mark.db def test_aborts_when_dsp_client_not_found( self, collect_fans_mock: mock.MagicMock ) -> None: fanout_task_id = uuid.uuid4() run_collect( RunCollectRequest( batch=FanCollectBatch( fanout_task_id=fanout_task_id, dsp_client_name=DSPClientName.spotify_songwhip, fans=[FanRecord(fan_id="u1", token_encrypted="tok")], ) ) ) collect_fans_mock.assert_not_called() @pytest.mark.db def test_saves_outcome_to_collect_task(self) -> None: create_model(DSPClient, name=DSPClientName.spotify_songwhip, nominal_rps=11) fanout_task = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running ) batch = FanCollectBatch( fanout_task_id=fanout_task.id, dsp_client_name=DSPClientName.spotify_songwhip, fans=[FanRecord(fan_id="u1", token_encrypted="tok")], ) run_collect(RunCollectRequest(batch=batch)) task = CollectTask.query.get_one(batch.collect_task_id) assert task.status == TaskStatus.done assert task.fans_total == 1 assert task.fans_processed == 2 assert task.requests == 6 @pytest.mark.db def test_closes_fanout_when_last_batch(self) -> None: create_model(DSPClient, name=DSPClientName.spotify_songwhip, nominal_rps=11) # messages_sent=1: this collect batch is the only one dispatched fanout_task = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running, messages_sent=1, ) run_collect( RunCollectRequest( batch=FanCollectBatch( fanout_task_id=fanout_task.id, dsp_client_name=DSPClientName.spotify_songwhip, fans=[FanRecord(fan_id="u1", token_encrypted="tok")], ) ) ) fanout_task.refresh() assert fanout_task.status == TaskStatus.done assert fanout_task.finished_at is not None @pytest.mark.db def test_does_not_close_fanout_when_siblings_remain(self) -> None: create_model(DSPClient, name=DSPClientName.spotify_songwhip, nominal_rps=11) fanout_task = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running ) create_model( CollectTask, fanout_task_id=fanout_task.id, dsp_client_name=DSPClientName.spotify_smf_sme, status=TaskStatus.running, fans_total=5, ) run_collect( RunCollectRequest( batch=FanCollectBatch( fanout_task_id=fanout_task.id, dsp_client_name=DSPClientName.spotify_songwhip, fans=[FanRecord(fan_id="u1", token_encrypted="tok")], ) ) ) fanout_task.refresh() assert fanout_task.status == TaskStatus.running @pytest.mark.db class TestRunCollectDlq: def test_marks_existing_running_task_stale(self) -> None: fanout = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running ) collect = create_model( CollectTask, fanout_task_id=fanout.id, dsp_client_name=DSPClientName.spotify_songwhip, status=TaskStatus.running, fans_total=1, ) batch = FanCollectBatch( collect_task_id=collect.id, fanout_task_id=fanout.id, dsp_client_name=DSPClientName.spotify_songwhip, fans=[FanRecord(fan_id="u1", token_encrypted="tok")], ) run_collect_dlq(RunCollectDLQRequest(batch=batch)) collect.refresh() assert collect.status == TaskStatus.stale assert collect.finished_at is not None assert collect.finished_reason == TaskFinishedReason.timed_out def test_noop_when_task_already_done(self) -> None: fanout = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.done ) collect = create_model( CollectTask, fanout_task_id=fanout.id, dsp_client_name=DSPClientName.spotify_songwhip, status=TaskStatus.done, fans_total=1, ) batch = FanCollectBatch( collect_task_id=collect.id, fanout_task_id=fanout.id, dsp_client_name=DSPClientName.spotify_songwhip, fans=[FanRecord(fan_id="u1", token_encrypted="tok")], ) run_collect_dlq(RunCollectDLQRequest(batch=batch)) collect.refresh() assert collect.status == TaskStatus.done # untouched def test_creates_stale_task_when_none_exists(self) -> None: fanout = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running ) batch = FanCollectBatch( fanout_task_id=fanout.id, dsp_client_name=DSPClientName.spotify_songwhip, fans=[FanRecord(fan_id="u1", token_encrypted="tok")], ) run_collect_dlq(RunCollectDLQRequest(batch=batch)) task = CollectTask.query.get_one(batch.collect_task_id) assert task.status == TaskStatus.stale assert task.fanout_task_id == fanout.id assert task.fans_total == 1 assert task.finished_at is not None assert task.finished_reason == TaskFinishedReason.timed_out def test_closes_fanout_when_last_sibling(self) -> None: # messages_sent=1: this collect batch is the only one dispatched fanout = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running, messages_sent=1, ) collect = create_model( CollectTask, fanout_task_id=fanout.id, dsp_client_name=DSPClientName.spotify_songwhip, status=TaskStatus.running, fans_total=1, ) batch = FanCollectBatch( collect_task_id=collect.id, fanout_task_id=fanout.id, dsp_client_name=DSPClientName.spotify_songwhip, fans=[FanRecord(fan_id="u1", token_encrypted="tok")], ) run_collect_dlq(RunCollectDLQRequest(batch=batch)) fanout.refresh() assert fanout.status == TaskStatus.done assert fanout.finished_reason == TaskFinishedReason.timed_out def test_does_not_close_fanout_when_sibling_still_running(self) -> None: fanout = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running ) collect = create_model( CollectTask, fanout_task_id=fanout.id, dsp_client_name=DSPClientName.spotify_songwhip, status=TaskStatus.running, fans_total=1, ) create_model( CollectTask, fanout_task_id=fanout.id, dsp_client_name=DSPClientName.spotify_smf_sme, status=TaskStatus.running, fans_total=3, ) batch = FanCollectBatch( collect_task_id=collect.id, fanout_task_id=fanout.id, dsp_client_name=DSPClientName.spotify_songwhip, fans=[FanRecord(fan_id="u1", token_encrypted="tok")], ) run_collect_dlq(RunCollectDLQRequest(batch=batch)) fanout.refresh() assert fanout.status == TaskStatus.running