import uuid from datetime import timedelta import pytest from fansifter_common.utils import timezone from app.dsp.enums import DSPClientName from app.dsp.models import DSPClient from app.pipeline.enums import RunFinishedReason, RunSource, RunStatus from app.pipeline.exceptions import ( PipelineAlreadyRunningError, PipelineRunNotFoundError, ) from app.pipeline.models import PipelineRun from app.pipeline.services import ( activate_run, cancel_run, cleanup_stale_runs, create_queued_run, finalize_run, pause_run, resume_run, stall_run, ) from tests.unit.helpers import create_model, override_settings class TestPipelineServices: @pytest.mark.db def test_create_queued_run(self) -> None: client = create_model(DSPClient) run = create_queued_run(source=RunSource.manual, dsp_client_id=client.id) assert run.status == RunStatus.queued assert run.dsp_client_id == client.id assert run.source == RunSource.manual @pytest.mark.db def test_create_raises_if_active_run_exists(self) -> None: client = create_model(DSPClient) create_model( PipelineRun, dsp_client_id=client.id, status=RunStatus.running, started_at=timezone.now(), ) with pytest.raises(PipelineAlreadyRunningError): create_queued_run(source=RunSource.manual, dsp_client_id=client.id) @pytest.mark.db def test_create_cleans_up_stale_run(self) -> None: client = create_model(DSPClient) create_model( PipelineRun, dsp_client_id=client.id, status=RunStatus.running, started_at=timezone.now() - timedelta(seconds=9999), ) with override_settings(pipeline_run_stale_timeout_s=100): run = create_queued_run(source=RunSource.manual, dsp_client_id=client.id) assert run.status == RunStatus.queued stalled = PipelineRun.query.where( PipelineRun.dsp_client_id == client.id, PipelineRun.status == RunStatus.error, ).one() assert stalled.finished_reason == RunFinishedReason.stalled @pytest.mark.db def test_create_different_clients_can_run_simultaneously(self) -> None: client_1 = create_model(DSPClient, name=DSPClientName.spotify_smf) client_2 = create_model(DSPClient, name=DSPClientName.spotify_songwhip) create_model(PipelineRun, dsp_client_id=client_1.id, status=RunStatus.running) run = create_queued_run(source=RunSource.manual, dsp_client_id=client_2.id) assert run.status == RunStatus.queued @pytest.mark.db def test_activate_sets_running_status(self) -> None: run = create_model(PipelineRun, status=RunStatus.queued) activated = activate_run(run.id) assert activated.status == RunStatus.running assert activated.started_at is not None @pytest.mark.db def test_activate_raises_if_not_found(self) -> None: with pytest.raises(PipelineRunNotFoundError): activate_run(uuid.uuid4()) @pytest.mark.db def test_finalize_sets_done_status(self) -> None: run = create_model(PipelineRun, status=RunStatus.running) finalize_run(run.id) assert run.status == RunStatus.done assert run.finished_at is not None assert run.finished_reason == RunFinishedReason.done @pytest.mark.db def test_finalize_uses_custom_finished_reason(self) -> None: run = create_model(PipelineRun, status=RunStatus.running) finalize_run(run.id, finished_reason=RunFinishedReason.timed_out) assert run.finished_reason == RunFinishedReason.timed_out @pytest.mark.db def test_finalize_noop_if_not_found(self) -> None: finalize_run(uuid.uuid4()) # no exception @pytest.mark.db def test_pause_pauses_running_run(self) -> None: run = create_model(PipelineRun, status=RunStatus.running) result = pause_run(run.id) assert result is not None assert result.status == RunStatus.paused @pytest.mark.db @pytest.mark.parametrize( "status", [RunStatus.queued, RunStatus.paused, RunStatus.done, RunStatus.cancelled], ) def test_pause_noop_if_not_running(self, status: RunStatus) -> None: run = create_model(PipelineRun, status=status) result = pause_run(run.id) assert result is not None assert result.status == status @pytest.mark.db def test_pause_returns_none_if_not_found(self) -> None: assert pause_run(uuid.uuid4()) is None @pytest.mark.db def test_resume_resumes_paused_run(self) -> None: run = create_model(PipelineRun, status=RunStatus.paused) result = resume_run(run.id) assert result is not None assert result.status == RunStatus.running @pytest.mark.db @pytest.mark.parametrize( "status", [RunStatus.queued, RunStatus.running, RunStatus.done, RunStatus.cancelled], ) def test_resume_noop_if_not_paused(self, status: RunStatus) -> None: run = create_model(PipelineRun, status=status) result = resume_run(run.id) assert result is not None assert result.status == status @pytest.mark.db def test_resume_returns_none_if_not_found(self) -> None: assert resume_run(uuid.uuid4()) is None @pytest.mark.db @pytest.mark.parametrize( "status", [RunStatus.queued, RunStatus.running, RunStatus.paused] ) def test_cancel_cancels_active_run(self, status: RunStatus) -> None: run = create_model(PipelineRun, status=status) result = cancel_run(run.id) assert result is not None assert result.status == RunStatus.cancelled assert result.finished_reason == RunFinishedReason.cancelled assert result.finished_at is not None @pytest.mark.db @pytest.mark.parametrize( "status", [RunStatus.done, RunStatus.error, RunStatus.cancelled] ) def test_cancel_noop_if_already_finished(self, status: RunStatus) -> None: run = create_model(PipelineRun, status=status) result = cancel_run(run.id) assert result is not None assert result.status == status @pytest.mark.db def test_cancel_returns_none_if_not_found(self) -> None: assert cancel_run(uuid.uuid4()) is None @pytest.mark.db @pytest.mark.parametrize( "status", [RunStatus.queued, RunStatus.running, RunStatus.paused] ) def test_stall_stalls_active_run(self, status: RunStatus) -> None: run = create_model(PipelineRun, status=status) result = stall_run(run.id) assert result is not None assert result.status == RunStatus.error assert result.finished_reason == RunFinishedReason.stalled assert result.finished_at is not None @pytest.mark.db @pytest.mark.parametrize( "status", [RunStatus.done, RunStatus.error, RunStatus.cancelled] ) def test_stall_noop_if_already_finished(self, status: RunStatus) -> None: run = create_model(PipelineRun, status=status) result = stall_run(run.id) assert result is not None assert result.status == status @pytest.mark.db def test_stall_returns_none_if_not_found(self) -> None: assert stall_run(uuid.uuid4()) is None @pytest.mark.db def test_cleanup_marks_stale_run_as_error(self) -> None: run = create_model( PipelineRun, status=RunStatus.running, started_at=timezone.now() - timedelta(seconds=9999), ) with override_settings(pipeline_run_stale_timeout_s=100): cleanup_stale_runs(run.dsp_client_id) assert run.status == RunStatus.error assert run.finished_reason == RunFinishedReason.stalled @pytest.mark.db def test_cleanup_returns_cleaned_run_ids(self) -> None: run = create_model( PipelineRun, status=RunStatus.running, started_at=timezone.now() - timedelta(seconds=9999), ) with override_settings(pipeline_run_stale_timeout_s=100): cleaned = cleanup_stale_runs(run.dsp_client_id) assert run.id in cleaned @pytest.mark.db def test_cleanup_ignores_fresh_runs(self) -> None: run = create_model( PipelineRun, status=RunStatus.running, started_at=timezone.now() - timedelta(seconds=10), ) with override_settings(pipeline_run_stale_timeout_s=3600): cleaned = cleanup_stale_runs(run.dsp_client_id) assert cleaned == [] @pytest.mark.db def test_cleanup_only_cleans_matching_client(self) -> None: stale_run = create_model( PipelineRun, status=RunStatus.running, started_at=timezone.now() - timedelta(seconds=9999), ) other_run = create_model( PipelineRun, status=RunStatus.running, started_at=timezone.now() - timedelta(seconds=9999), ) with override_settings(pipeline_run_stale_timeout_s=100): cleaned = cleanup_stale_runs(stale_run.dsp_client_id) assert stale_run.id in cleaned assert other_run.id not in cleaned