from datetime import UTC, datetime import pytest from resonance_engine.dsp.enums import DSPClientName from resonance_engine.tasks.enums import FanoutSource, TaskFinishedReason, TaskStatus from resonance_engine.tasks.models import CollectTask, FanoutTask from tests.unit.helpers import create_model @pytest.mark.db class TestFanoutTaskQuery: def test_update_dispatch_persists_fans_and_messages(self) -> None: task = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running ) FanoutTask.query.update_dispatch(task.id, fans_dispatched=42, messages_sent=7) updated = FanoutTask.query.where(FanoutTask.id == task.id).one() assert updated.fans_dispatched == 42 assert updated.messages_sent == 7 def test_update_dispatch_does_not_change_status(self) -> None: task = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running ) FanoutTask.query.update_dispatch(task.id, fans_dispatched=1, messages_sent=1) updated = FanoutTask.query.where(FanoutTask.id == task.id).one() assert updated.status == TaskStatus.running def test_close_stale_closes_undispatched_fanout_past_threshold(self) -> None: # messages_sent == 0, started far in the past → hits dispatch threshold fanout = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running, started_at=datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC), ) collect = create_model( CollectTask, fanout_task_id=fanout.id, dsp_client_name=DSPClientName.spotify_songwhip, status=TaskStatus.running, fans_total=5, started_at=datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC), ) closed = FanoutTask.query.close_stale(dispatch_stale_s=0) assert closed == 1 updated_fanout = FanoutTask.query.where(FanoutTask.id == fanout.id).one() assert updated_fanout.status == TaskStatus.done assert updated_fanout.finished_reason == TaskFinishedReason.timed_out updated_collect = CollectTask.query.where(CollectTask.id == collect.id).one() assert updated_collect.status == TaskStatus.stale assert updated_collect.finished_reason == TaskFinishedReason.timed_out def test_close_stale_never_closes_dispatched_fanout(self) -> None: # messages_sent > 0 — must never be closed by close_stale regardless of age. # Only close_if_all_collected and the DLQ Lambda can close dispatched fanouts. fanout = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running, started_at=datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC), messages_sent=2, fans_dispatched=20, ) create_model( CollectTask, fanout_task_id=fanout.id, dsp_client_name=DSPClientName.spotify_songwhip, status=TaskStatus.done, fans_total=10, ) closed = FanoutTask.query.close_stale(dispatch_stale_s=0) assert closed == 0 updated = FanoutTask.query.where(FanoutTask.id == fanout.id).one() assert updated.status == TaskStatus.running def test_close_stale_does_not_close_recent_undispatched_task(self) -> None: create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running, started_at=datetime(2026, 5, 25, 12, 0, 0, tzinfo=UTC), ) closed = FanoutTask.query.close_stale(dispatch_stale_s=365 * 86400) assert closed == 0 def test_close_stale_skips_already_done_fanout(self) -> None: create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.done, started_at=datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC), ) closed = FanoutTask.query.close_stale(dispatch_stale_s=0) assert closed == 0 def test_close_stale_skips_done_collect_tasks(self) -> None: # Even for undispatched fanouts, done collect siblings are untouched fanout = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running, started_at=datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC), ) collect = create_model( CollectTask, fanout_task_id=fanout.id, dsp_client_name=DSPClientName.spotify_songwhip, status=TaskStatus.done, fans_total=5, started_at=datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC), ) FanoutTask.query.close_stale(dispatch_stale_s=0) updated = CollectTask.query.where(CollectTask.id == collect.id).one() assert updated.status == TaskStatus.done assert updated.finished_reason is None def test_close_if_all_collected_closes_when_all_siblings_are_done(self) -> None: task = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running, fans_dispatched=3, messages_sent=1, ) create_model( CollectTask, fanout_task_id=task.id, dsp_client_name=DSPClientName.spotify_songwhip, status=TaskStatus.done, fans_total=3, ) closed = FanoutTask.query.close_if_all_collected(task.id) assert closed is True updated = FanoutTask.query.where(FanoutTask.id == task.id).one() assert updated.status == TaskStatus.done assert updated.finished_at is not None assert updated.finished_reason == TaskFinishedReason.done def test_close_if_all_collected_returns_false_when_sibling_still_running( self, ) -> None: task = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running, messages_sent=1, ) create_model( CollectTask, fanout_task_id=task.id, dsp_client_name=DSPClientName.spotify_smf_sme, status=TaskStatus.running, fans_total=5, ) closed = FanoutTask.query.close_if_all_collected(task.id) assert closed is False updated = FanoutTask.query.where(FanoutTask.id == task.id).one() assert updated.status == TaskStatus.running def test_close_if_all_collected_returns_false_when_batches_not_yet_picked_up( self, ) -> None: """Race guard: messages_sent=3 but only 1 CollectTask exists (others still queued in SQS and haven't created their row yet) → must NOT close.""" task = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running, messages_sent=3, ) create_model( CollectTask, fanout_task_id=task.id, dsp_client_name=DSPClientName.spotify_songwhip, status=TaskStatus.done, fans_total=5, ) closed = FanoutTask.query.close_if_all_collected(task.id) assert closed is False def test_close_if_all_collected_returns_false_when_messages_sent_not_committed( self, ) -> None: """Race guard: messages_sent=0 (update_dispatch not yet committed) must NOT close even if no running siblings exist.""" task = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running, messages_sent=0, ) closed = FanoutTask.query.close_if_all_collected(task.id) assert closed is False def test_close_if_all_collected_is_idempotent(self) -> None: task = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running, messages_sent=1, ) create_model( CollectTask, fanout_task_id=task.id, dsp_client_name=DSPClientName.spotify_songwhip, status=TaskStatus.done, fans_total=3, ) first = FanoutTask.query.close_if_all_collected(task.id) second = FanoutTask.query.close_if_all_collected(task.id) assert first is True assert second is False def test_close_if_all_collected_sets_timed_out_when_sibling_is_stale( self, ) -> None: task = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running, messages_sent=2, ) create_model( CollectTask, fanout_task_id=task.id, dsp_client_name=DSPClientName.spotify_songwhip, status=TaskStatus.done, fans_total=3, ) create_model( CollectTask, fanout_task_id=task.id, dsp_client_name=DSPClientName.spotify_smf_sme, status=TaskStatus.stale, fans_total=2, ) closed = FanoutTask.query.close_if_all_collected(task.id) assert closed is True updated = FanoutTask.query.where(FanoutTask.id == task.id).one() assert updated.status == TaskStatus.done assert updated.finished_reason == TaskFinishedReason.timed_out @pytest.mark.db class TestCollectTaskQuery: def test_aggregate_stats_counts_all_tasks(self) -> None: fanout1 = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.done ) fanout2 = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running ) create_model( CollectTask, fanout_task_id=fanout1.id, dsp_client_name=DSPClientName.spotify_songwhip, status=TaskStatus.done, fans_total=5, ) create_model( CollectTask, fanout_task_id=fanout2.id, dsp_client_name=DSPClientName.spotify_songwhip, status=TaskStatus.running, fans_total=3, ) result = CollectTask.query.aggregate_stats() # Must be exactly 2, not 2*2=4 (cross-join regression check) assert result.total == 2 assert result.running == 1 assert result.stale == 0 def test_aggregate_stats_filters_by_dsp_client_name(self) -> None: fanout = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.done ) create_model( CollectTask, fanout_task_id=fanout.id, dsp_client_name=DSPClientName.spotify_songwhip, status=TaskStatus.done, fans_total=5, ) create_model( CollectTask, fanout_task_id=fanout.id, dsp_client_name=DSPClientName.spotify_smf_sme, status=TaskStatus.done, fans_total=5, ) result = CollectTask.query.aggregate_stats( dsp_client_name=DSPClientName.spotify_songwhip ) assert result.total == 1 def test_aggregate_stats_returns_zero_when_empty(self) -> None: result = CollectTask.query.aggregate_stats() assert result.total == 0 assert result.running == 0 assert result.stale == 0 assert result.last_started_at is None def test_aggregate_stats_counts_stale_tasks(self) -> None: fanout = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running ) create_model( CollectTask, fanout_task_id=fanout.id, dsp_client_name=DSPClientName.spotify_songwhip, status=TaskStatus.stale, fans_total=5, ) create_model( CollectTask, fanout_task_id=fanout.id, dsp_client_name=DSPClientName.spotify_songwhip, status=TaskStatus.running, fans_total=5, ) result = CollectTask.query.aggregate_stats() assert result.stale == 1 assert result.running == 1 def test_update_outcome_persists_all_counters(self) -> None: fanout_task = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running ) task = create_model( CollectTask, fanout_task_id=fanout_task.id, dsp_client_name=DSPClientName.spotify_songwhip, status=TaskStatus.running, fans_total=10, ) CollectTask.query.update_outcome( task.id, fans_processed=8, fans_errors=1, fans_stale_tokens=1, requests_rate_limited=0, fans_skipped=2, requests_skipped=5, requests=24, ) updated = CollectTask.query.where(CollectTask.id == task.id).one() assert updated.fans_processed == 8 assert updated.fans_errors == 1 assert updated.fans_stale_tokens == 1 assert updated.requests_rate_limited == 0 assert updated.fans_skipped == 2 assert updated.requests_skipped == 5 assert updated.requests == 24 def test_update_outcome_sets_status_done_and_finished_at(self) -> None: fanout_task = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running ) task = create_model( CollectTask, fanout_task_id=fanout_task.id, dsp_client_name=DSPClientName.spotify_songwhip, status=TaskStatus.running, fans_total=5, ) CollectTask.query.update_outcome( task.id, fans_processed=5, fans_errors=0, fans_stale_tokens=0, requests_rate_limited=0, fans_skipped=0, requests_skipped=0, requests=15, ) updated = CollectTask.query.where(CollectTask.id == task.id).one() assert updated.status == TaskStatus.done assert updated.finished_at is not None assert updated.finished_reason == TaskFinishedReason.done def test_mark_stale_one_marks_running_task_stale(self) -> None: fanout = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running ) task = create_model( CollectTask, fanout_task_id=fanout.id, dsp_client_name=DSPClientName.spotify_songwhip, status=TaskStatus.running, fans_total=5, ) changed = CollectTask.query.mark_stale_one(task.id) assert changed is True updated = CollectTask.query.where(CollectTask.id == task.id).one() assert updated.status == TaskStatus.stale assert updated.finished_at is not None assert updated.finished_reason == TaskFinishedReason.timed_out def test_mark_stale_one_noop_on_done_task(self) -> None: fanout = create_model( FanoutTask, source=FanoutSource.scheduled, status=TaskStatus.running ) task = create_model( CollectTask, fanout_task_id=fanout.id, dsp_client_name=DSPClientName.spotify_songwhip, status=TaskStatus.done, fans_total=5, ) changed = CollectTask.query.mark_stale_one(task.id) assert changed is False updated = CollectTask.query.where(CollectTask.id == task.id).one() assert updated.status == TaskStatus.done # untouched