chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
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
|
||||
Reference in New Issue
Block a user