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
272 lines
8.5 KiB
Python
272 lines
8.5 KiB
Python
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.core.config import MAX_PENDING_JOBS
|
|
from app.core.models import Job
|
|
from app.core.registry import _jobs
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_registry():
|
|
"""Each test gets a fresh in-memory registry."""
|
|
_jobs.clear()
|
|
yield
|
|
_jobs.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
async def _noop_pipeline(job, url, jobs_dir):
|
|
return None
|
|
|
|
with patch("app.api.jobs.run_pipeline", _noop_pipeline):
|
|
from app.main import app
|
|
|
|
with TestClient(app) as c:
|
|
yield c
|
|
|
|
|
|
@pytest.fixture
|
|
def upload_client(tmp_path, monkeypatch):
|
|
import app.core.config as cfg
|
|
|
|
monkeypatch.setattr(cfg, "JOBS_DIR", tmp_path)
|
|
|
|
async def _noop_local(job, source_path, jobs_dir):
|
|
return None
|
|
|
|
async def _noop_youtube(job, url, jobs_dir):
|
|
return None
|
|
|
|
with (
|
|
patch("app.api.jobs.run_local_pipeline", _noop_local),
|
|
patch("app.api.jobs.run_pipeline", _noop_youtube),
|
|
patch("app.api.jobs._probe_duration", return_value=60.0),
|
|
):
|
|
from app.main import app
|
|
|
|
with TestClient(app) as c:
|
|
yield c
|
|
|
|
|
|
def test_post_rejects_invalid_url(client):
|
|
r = client.post("/api/jobs", json={"url": "https://example.com/foo"})
|
|
assert r.status_code == 422
|
|
assert "unsupported host" in r.json()["detail"]
|
|
|
|
|
|
def test_post_rejects_empty_url(client):
|
|
r = client.post("/api/jobs", json={"url": ""})
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_post_accepts_youtube_url(client):
|
|
r = client.post("/api/jobs", json={"url": "https://youtu.be/dQw4w9WgXcQ"})
|
|
assert r.status_code == 200
|
|
assert "job_id" in r.json()
|
|
assert len(r.json()["job_id"]) == 12
|
|
|
|
|
|
def test_get_unknown_job_returns_404(client):
|
|
r = client.get("/api/jobs/000000000000")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_cancel_unknown_job_returns_404(client):
|
|
r = client.post("/api/jobs/000000000000/cancel")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_delete_running_job_rejected(client):
|
|
r = client.post("/api/jobs", json={"url": "https://youtu.be/dQw4w9WgXcQ"})
|
|
job_id = r.json()["job_id"]
|
|
r = client.delete(f"/api/jobs/{job_id}")
|
|
assert r.status_code == 409
|
|
|
|
|
|
def test_cancel_sets_flag_and_returns_state(client):
|
|
r = client.post("/api/jobs", json={"url": "https://youtu.be/dQw4w9WgXcQ"})
|
|
job_id = r.json()["job_id"]
|
|
r = client.post(f"/api/jobs/{job_id}/cancel")
|
|
assert r.status_code == 200
|
|
assert _jobs[job_id].cancel_requested is True
|
|
|
|
|
|
def test_cancel_after_done_is_idempotent(client):
|
|
r = client.post("/api/jobs", json={"url": "https://youtu.be/dQw4w9WgXcQ"})
|
|
job_id = r.json()["job_id"]
|
|
_jobs[job_id].status = "done"
|
|
r = client.post(f"/api/jobs/{job_id}/cancel")
|
|
assert r.status_code == 200
|
|
assert _jobs[job_id].cancel_requested is False
|
|
|
|
|
|
# ─── Capacity (503) ───────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_youtube_503_when_queue_full(client):
|
|
for _ in range(MAX_PENDING_JOBS):
|
|
r = client.post("/api/jobs", json={"url": "https://youtu.be/dQw4w9WgXcQ"})
|
|
assert r.status_code == 200
|
|
r = client.post("/api/jobs", json={"url": "https://youtu.be/dQw4w9WgXcQ"})
|
|
assert r.status_code == 503
|
|
|
|
|
|
def test_upload_503_when_queue_full(upload_client):
|
|
for _ in range(MAX_PENDING_JOBS):
|
|
r = upload_client.post("/api/jobs", json={"url": "https://youtu.be/dQw4w9WgXcQ"})
|
|
assert r.status_code == 200
|
|
data = io.BytesIO(b"ID3" + b"\x00" * 128)
|
|
r = upload_client.post(
|
|
"/api/jobs",
|
|
files={"file": ("track.mp3", data, "audio/mpeg")},
|
|
)
|
|
assert r.status_code == 503
|
|
|
|
|
|
# ─── File upload ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_upload_rejects_unsupported_extension(upload_client):
|
|
data = io.BytesIO(b"OGG data")
|
|
r = upload_client.post(
|
|
"/api/jobs",
|
|
files={"file": ("track.ogg", data, "audio/ogg")},
|
|
)
|
|
assert r.status_code == 422
|
|
assert "Unsupported file type" in r.json()["detail"]
|
|
|
|
|
|
def test_upload_rejects_empty_file(upload_client):
|
|
r = upload_client.post(
|
|
"/api/jobs",
|
|
files={"file": ("track.wav", io.BytesIO(b""), "audio/wav")},
|
|
)
|
|
assert r.status_code == 422
|
|
assert "empty" in r.json()["detail"].lower()
|
|
|
|
|
|
def test_upload_mp3_returns_job_id(upload_client):
|
|
data = io.BytesIO(b"ID3" + b"\x00" * 128)
|
|
r = upload_client.post(
|
|
"/api/jobs",
|
|
files={"file": ("my_track.mp3", data, "audio/mpeg")},
|
|
)
|
|
assert r.status_code == 200
|
|
assert "job_id" in r.json()
|
|
assert len(r.json()["job_id"]) == 12
|
|
|
|
|
|
def test_upload_wav_returns_job_id(upload_client):
|
|
data = io.BytesIO(b"RIFF" + b"\x00" * 128)
|
|
r = upload_client.post(
|
|
"/api/jobs",
|
|
files={"file": ("my_track.wav", data, "audio/wav")},
|
|
)
|
|
assert r.status_code == 200
|
|
assert "job_id" in r.json()
|
|
|
|
|
|
def test_upload_flac_returns_job_id(upload_client):
|
|
data = io.BytesIO(b"fLaC" + b"\x00" * 128)
|
|
r = upload_client.post(
|
|
"/api/jobs",
|
|
files={"file": ("my_track.flac", data, "audio/flac")},
|
|
)
|
|
assert r.status_code == 200
|
|
assert "job_id" in r.json()
|
|
|
|
|
|
# ─── Sections endpoint ────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def done_job(client, tmp_path, monkeypatch):
|
|
import app.api.jobs as jobs_mod
|
|
|
|
monkeypatch.setattr(jobs_mod, "JOBS_DIR", tmp_path)
|
|
job = Job(id="abcdefabcdef")
|
|
job.status = "done"
|
|
_jobs[job.id] = job
|
|
job_dir = tmp_path / job.id
|
|
job_dir.mkdir(parents=True, exist_ok=True)
|
|
return job
|
|
|
|
|
|
def test_sections_happy_path(client, done_job, tmp_path):
|
|
payload = {
|
|
"sections": [{"id": "sec1", "name": "Verse", "start": 0.0, "end": 30.0, "color": "#ff0000"}]
|
|
}
|
|
r = client.patch(f"/api/jobs/{done_job.id}/sections", json=payload)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["job_id"] == done_job.id
|
|
assert len(body["sections"]) == 1
|
|
assert body["sections"][0]["name"] == "Verse"
|
|
# Verify written to disk
|
|
meta_path = tmp_path / done_job.id / "metadata.json"
|
|
assert meta_path.is_file()
|
|
meta = json.loads(meta_path.read_text())
|
|
assert meta["sections"][0]["id"] == "sec1"
|
|
|
|
|
|
def test_sections_unknown_job_returns_404(client):
|
|
payload = {"sections": []}
|
|
r = client.patch("/api/jobs/000000000000/sections", json=payload)
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_sections_malformed_job_id_returns_404(client):
|
|
# Job IDs must be 12 lowercase hex chars; anything else is rejected.
|
|
r = client.patch("/api/jobs/BADID/sections", json={"sections": []})
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_sections_invalid_color_returns_422(client, done_job):
|
|
payload = {
|
|
"sections": [
|
|
{"id": "sec1", "name": "Intro", "start": 0.0, "end": 10.0, "color": "not-a-color"}
|
|
]
|
|
}
|
|
r = client.patch(f"/api/jobs/{done_job.id}/sections", json=payload)
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_sections_invalid_id_returns_422(client, done_job):
|
|
payload = {
|
|
"sections": [{"id": "has space", "name": "x", "start": 0.0, "end": 5.0, "color": "#fff"}]
|
|
}
|
|
r = client.patch(f"/api/jobs/{done_job.id}/sections", json=payload)
|
|
assert r.status_code == 422
|
|
|
|
|
|
# ─── SSE job_id validation ────────────────────────────────────────────────────
|
|
|
|
|
|
def test_sse_rejects_malformed_job_id(client):
|
|
for bad_id in ("../etc", "ABC", "abcdefabcdef0"):
|
|
r = client.get(f"/api/jobs/{bad_id}/events")
|
|
assert r.status_code == 404, f"SSE should 404 for id {bad_id!r}"
|
|
|
|
|
|
def test_sse_503_when_connection_cap_reached(client):
|
|
"""#86/#88: SSE endpoint rejects with 503 when _MAX_SSE_CONNECTIONS is reached."""
|
|
import app.api.events as events_mod
|
|
|
|
original = events_mod._sse_active
|
|
try:
|
|
events_mod._sse_active = events_mod._MAX_SSE_CONNECTIONS
|
|
job = Job(id="abcdefabcdef")
|
|
job.status = "done"
|
|
_jobs[job.id] = job
|
|
r = client.get(f"/api/jobs/{job.id}/events")
|
|
assert r.status_code == 503
|
|
finally:
|
|
events_mod._sse_active = original
|