8f10353f0c
CI / lint (push) Has been cancelled
CI / js-syntax (push) Successful in 10m24s
CI / deps-audit (push) Has been cancelled
CI / test (push) Failing after 24m55s
CI / sast-bandit (push) Failing after 11m13s
CI / trivy (push) Failing after 9m32s
Docker Publish / build-and-push (push) Failing after 34m3s
223 lines
6.6 KiB
Python
223 lines
6.6 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from app.core.models import Job, JobCancelled
|
|
from app.core.registry import _jobs
|
|
from app.pipeline.runner import _extract_video_track, run_local_pipeline, run_pipeline
|
|
|
|
|
|
def _ffmpeg_available() -> bool:
|
|
import shutil
|
|
|
|
return shutil.which("ffmpeg") is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_transitions_to_error_on_stage_failure(tmp_path: Path):
|
|
job = Job(id="abcdefabcdef")
|
|
|
|
def boom(*args, **kwargs):
|
|
raise RuntimeError("download blew up")
|
|
|
|
with patch("app.pipeline.runner._run_blocking", side_effect=boom):
|
|
await run_pipeline(job, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", tmp_path)
|
|
|
|
assert job.status == "error"
|
|
assert job.error # generic message returned to client; detail is in server logs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_marks_done_on_success(tmp_path: Path):
|
|
job = Job(id="abcdefabcdee")
|
|
|
|
with patch("app.pipeline.runner._run_blocking", return_value=None):
|
|
await run_pipeline(job, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", tmp_path)
|
|
|
|
assert job.status == "done"
|
|
assert job.progress == 1.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_handles_jobcancelled(tmp_path: Path):
|
|
job = Job(id="abcdefabcdec")
|
|
job.cancel_requested = True
|
|
|
|
def cancel(*args, **kwargs):
|
|
raise JobCancelled()
|
|
|
|
with patch("app.pipeline.runner._run_blocking", side_effect=cancel):
|
|
await run_pipeline(job, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", tmp_path)
|
|
|
|
assert job.status == "cancelled"
|
|
# Partial job dir is removed.
|
|
assert not (tmp_path / job.id).exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_handles_wrapped_cancel(tmp_path: Path):
|
|
"""yt-dlp wraps hook exceptions in DownloadError; the runner must still
|
|
treat it as a cancel when the flag is set."""
|
|
job = Job(id="abcdefabcdeb")
|
|
job.cancel_requested = True
|
|
|
|
def wrapped(*args, **kwargs):
|
|
raise RuntimeError("yt-dlp DownloadError wrapping JobCancelled")
|
|
|
|
with patch("app.pipeline.runner._run_blocking", side_effect=wrapped):
|
|
await run_pipeline(job, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", tmp_path)
|
|
|
|
assert job.status == "cancelled"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_recovers_from_mkdir_failure(tmp_path: Path):
|
|
"""If something pre-lock raises, the job must transition to error
|
|
instead of staying stuck on `queued`."""
|
|
job = Job(id="abcdefabcdea")
|
|
bad_jobs_dir = tmp_path / "blocked"
|
|
# Make jobs_dir a regular file so mkdir(parents=True) under it raises.
|
|
bad_jobs_dir.write_bytes(b"not a directory")
|
|
|
|
await run_pipeline(job, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", bad_jobs_dir)
|
|
|
|
assert job.status == "error"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_error_cleans_up_job_dir(tmp_path: Path):
|
|
"""#82: failed pipeline must remove the job directory so no orphan is left."""
|
|
job = Job(id="abcdefabcde9")
|
|
|
|
def boom(*args, **kwargs):
|
|
raise RuntimeError("ffmpeg died")
|
|
|
|
with patch("app.pipeline.runner._run_blocking", side_effect=boom):
|
|
await run_pipeline(job, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", tmp_path)
|
|
|
|
assert job.status == "error"
|
|
assert not (tmp_path / job.id).exists(), "job dir should be removed on error"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_error_calls_persist(tmp_path: Path):
|
|
"""#83: persist is called after an error so the registry stays consistent."""
|
|
job = Job(id="abcdefabcde8")
|
|
_jobs[job.id] = job
|
|
persist_calls = []
|
|
|
|
def boom(*args, **kwargs):
|
|
raise RuntimeError("separated badly")
|
|
|
|
def fake_persist(jobs_dir):
|
|
persist_calls.append(jobs_dir)
|
|
|
|
with (
|
|
patch("app.pipeline.runner._run_blocking", side_effect=boom),
|
|
patch("app.pipeline.runner.persist_registry", side_effect=fake_persist),
|
|
):
|
|
await run_pipeline(job, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", tmp_path)
|
|
|
|
assert job.status == "error"
|
|
assert len(persist_calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_local_pipeline_error_cleans_up_job_dir(tmp_path: Path):
|
|
"""#82: local upload error path also removes the job directory."""
|
|
job = Job(id="abcdefabcde7")
|
|
job_dir = tmp_path / job.id
|
|
job_dir.mkdir(parents=True)
|
|
source = job_dir / "source.mp3"
|
|
source.write_bytes(b"ID3")
|
|
|
|
def boom(*args, **kwargs):
|
|
raise RuntimeError("demucs blew up")
|
|
|
|
with patch("app.pipeline.runner._run_local_blocking", side_effect=boom):
|
|
await run_local_pipeline(job, source, tmp_path)
|
|
|
|
assert job.status == "error"
|
|
assert not (tmp_path / job.id).exists(), "job dir should be removed on local error"
|
|
|
|
|
|
def test_extract_video_track_from_mp4(tmp_path: Path):
|
|
"""#219: an mp4 with a video stream yields video.mp4 and sets has_video."""
|
|
if not _ffmpeg_available():
|
|
pytest.skip("ffmpeg not available")
|
|
import subprocess
|
|
|
|
job = Job(id="vid000000001")
|
|
job_dir = tmp_path / job.id
|
|
job_dir.mkdir(parents=True)
|
|
source = job_dir / "source.mp4"
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg",
|
|
"-nostdin",
|
|
"-loglevel",
|
|
"error",
|
|
"-y",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"color=c=black:s=64x64:d=0.3:r=10",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"anullsrc=r=44100:cl=stereo",
|
|
"-shortest",
|
|
"-c:v",
|
|
"mpeg4",
|
|
"-c:a",
|
|
"aac",
|
|
str(source),
|
|
],
|
|
check=True,
|
|
timeout=30,
|
|
)
|
|
|
|
_extract_video_track(job, source, job_dir)
|
|
|
|
assert job.has_video is True
|
|
assert (job_dir / "video.mp4").is_file()
|
|
assert (job_dir / "video.mp4").stat().st_size > 0
|
|
|
|
|
|
def test_extract_video_track_audio_only_mp4(tmp_path: Path):
|
|
"""An mp4 with no video stream leaves has_video false and no video.mp4."""
|
|
if not _ffmpeg_available():
|
|
pytest.skip("ffmpeg not available")
|
|
import subprocess
|
|
|
|
job = Job(id="vid000000002")
|
|
job_dir = tmp_path / job.id
|
|
job_dir.mkdir(parents=True)
|
|
source = job_dir / "source.mp4"
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg",
|
|
"-nostdin",
|
|
"-loglevel",
|
|
"error",
|
|
"-y",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"anullsrc=r=44100:cl=stereo:d=0.3",
|
|
"-c:a",
|
|
"aac",
|
|
str(source),
|
|
],
|
|
check=True,
|
|
timeout=30,
|
|
)
|
|
|
|
_extract_video_track(job, source, job_dir)
|
|
|
|
assert job.has_video is False
|
|
assert not (job_dir / "video.mp4").exists()
|