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,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core import settings as _settings
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_network_settings(tmp_path, monkeypatch):
|
||||
"""Isolate the runtime network gate for every test. Without this, a stray
|
||||
settings.json in the repo (written by a local dev server) could flip the
|
||||
gate off and 403 the whole suite, since TestClient's client host is not
|
||||
loopback. Each test starts from the env default (on, outside desktop mode)."""
|
||||
monkeypatch.setattr(_settings, "_SETTINGS_PATH", tmp_path / "settings.json")
|
||||
# Network access defaults OFF in production, which would 403 TestClient
|
||||
# (whose client host isn't loopback). Default the suite ON; gate tests set
|
||||
# it explicitly. Tests checking the real default clear this env var.
|
||||
monkeypatch.setenv("STEMDECK_ALLOW_NETWORK", "1")
|
||||
_settings._state = None # force a fresh load from the isolated path
|
||||
yield
|
||||
_settings._state = None
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_data_dir_moves_default_runtime_dirs(monkeypatch, tmp_path: Path):
|
||||
import app.core.config as config
|
||||
|
||||
original = config
|
||||
data_dir = tmp_path / "portable-data"
|
||||
monkeypatch.setenv("STEMDECK_DATA_DIR", str(data_dir))
|
||||
monkeypatch.delenv("STEMDECK_JOBS_DIR", raising=False)
|
||||
monkeypatch.delenv("STEMDECK_CACHE_DIR", raising=False)
|
||||
monkeypatch.delenv("STEMDECK_DOWNLOADS_DIR", raising=False)
|
||||
monkeypatch.delenv("STEMDECK_MODELS_DIR", raising=False)
|
||||
monkeypatch.delenv("STEMDECK_LOGS_DIR", raising=False)
|
||||
try:
|
||||
reloaded = importlib.reload(config)
|
||||
assert data_dir.resolve() == reloaded.DATA_DIR
|
||||
assert data_dir.resolve() / "jobs" == reloaded.JOBS_DIR
|
||||
assert data_dir.resolve() / "cache" == reloaded.CACHE_DIR
|
||||
assert data_dir.resolve() / "downloads" == reloaded.DOWNLOADS_DIR
|
||||
assert data_dir.resolve() / "models" == reloaded.MODELS_DIR
|
||||
assert data_dir.resolve() / "logs" == reloaded.LOGS_DIR
|
||||
finally:
|
||||
monkeypatch.delenv("STEMDECK_DATA_DIR", raising=False)
|
||||
importlib.reload(original)
|
||||
|
||||
|
||||
def test_jobs_dir_override_wins_over_data_dir(monkeypatch, tmp_path: Path):
|
||||
import app.core.config as config
|
||||
|
||||
original = config
|
||||
data_dir = tmp_path / "portable-data"
|
||||
jobs_dir = tmp_path / "custom-jobs"
|
||||
monkeypatch.setenv("STEMDECK_DATA_DIR", str(data_dir))
|
||||
monkeypatch.setenv("STEMDECK_JOBS_DIR", str(jobs_dir))
|
||||
try:
|
||||
reloaded = importlib.reload(config)
|
||||
assert data_dir.resolve() == reloaded.DATA_DIR
|
||||
assert jobs_dir.resolve() == reloaded.JOBS_DIR
|
||||
finally:
|
||||
monkeypatch.delenv("STEMDECK_DATA_DIR", raising=False)
|
||||
monkeypatch.delenv("STEMDECK_JOBS_DIR", raising=False)
|
||||
importlib.reload(original)
|
||||
|
||||
|
||||
def test_ffmpeg_executable_prefers_portable_binary(monkeypatch, tmp_path: Path):
|
||||
import app.core.config as config
|
||||
|
||||
original = config
|
||||
ffmpeg = tmp_path / "ffmpeg" / "ffmpeg"
|
||||
ffmpeg.parent.mkdir()
|
||||
ffmpeg.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
monkeypatch.setenv("STEMDECK_DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("STEMDECK_FFMPEG", str(ffmpeg))
|
||||
try:
|
||||
reloaded = importlib.reload(config)
|
||||
assert reloaded.ffmpeg_executable() == str(ffmpeg.resolve())
|
||||
finally:
|
||||
monkeypatch.delenv("STEMDECK_DATA_DIR", raising=False)
|
||||
monkeypatch.delenv("STEMDECK_FFMPEG", raising=False)
|
||||
importlib.reload(original)
|
||||
|
||||
|
||||
def test_configure_portable_environment_leaves_dev_cache_env_alone(monkeypatch):
|
||||
import app.core.config as config
|
||||
|
||||
original = config
|
||||
monkeypatch.delenv("STEMDECK_DATA_DIR", raising=False)
|
||||
monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
|
||||
monkeypatch.delenv("TORCH_HOME", raising=False)
|
||||
try:
|
||||
reloaded = importlib.reload(config)
|
||||
reloaded.configure_portable_environment()
|
||||
assert "XDG_CACHE_HOME" not in os.environ
|
||||
assert "TORCH_HOME" not in os.environ
|
||||
finally:
|
||||
monkeypatch.delenv("STEMDECK_DATA_DIR", raising=False)
|
||||
monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
|
||||
monkeypatch.delenv("TORCH_HOME", raising=False)
|
||||
importlib.reload(original)
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def _csp_directive(name: str) -> str:
|
||||
"""Return the named directive from the served Content-Security-Policy header."""
|
||||
with TestClient(app) as c:
|
||||
resp = c.get("/")
|
||||
csp = resp.headers["content-security-policy"]
|
||||
return next(d.strip() for d in csp.split(";") if d.strip().startswith(name))
|
||||
|
||||
|
||||
def test_connect_src_permits_data_and_blob():
|
||||
# Regression for #186: multitrack.js fetches a data: URI while initializing
|
||||
# each track's audio. Without data:/blob: in connect-src the browser blocks
|
||||
# it, Multitrack.create throws, and no audio/waveform/playback loads.
|
||||
connect = _csp_directive("connect-src")
|
||||
assert "data:" in connect
|
||||
assert "blob:" in connect
|
||||
|
||||
|
||||
def test_script_src_stays_locked():
|
||||
# Lock #171's intent: loosening connect-src must not weaken the XSS defense.
|
||||
script = _csp_directive("script-src")
|
||||
assert "'self'" in script
|
||||
assert "unsafe-inline" not in script
|
||||
assert "unsafe-eval" not in script
|
||||
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_health_endpoints_report_ok():
|
||||
from app.main import app
|
||||
|
||||
with TestClient(app) as client:
|
||||
for path in ("/health", "/api/health"):
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["name"] == "StemDeck"
|
||||
assert body["status"] == "ok"
|
||||
assert body["version"]
|
||||
assert "ffmpeg_configured" in body
|
||||
assert "jobs_dir" not in body
|
||||
assert "data_dir" not in body
|
||||
@@ -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
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import _is_mobile_ua, app
|
||||
|
||||
IPHONE_UA = (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
|
||||
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
|
||||
)
|
||||
ANDROID_UA = (
|
||||
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
|
||||
)
|
||||
DESKTOP_UA = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
# Modern iPadOS Safari reports a Mac desktop UA — tablets fall through to the DAW.
|
||||
IPAD_UA = (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
|
||||
"(KHTML, like Gecko) Version/17.0 Safari/605.1.15"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ua", [IPHONE_UA, ANDROID_UA])
|
||||
def test_is_mobile_ua_true_for_phones(ua: str):
|
||||
assert _is_mobile_ua(ua) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ua", [DESKTOP_UA, IPAD_UA, "", "curl/8.0"])
|
||||
def test_is_mobile_ua_false_for_non_phones(ua: str):
|
||||
assert _is_mobile_ua(ua) is False
|
||||
|
||||
|
||||
def test_root_serves_mobile_shell_to_phones():
|
||||
with TestClient(app) as c:
|
||||
resp = c.get("/", headers={"user-agent": IPHONE_UA})
|
||||
assert resp.status_code == 200
|
||||
assert "/mobile/app.js" in resp.text
|
||||
|
||||
|
||||
def test_root_serves_daw_to_desktop():
|
||||
with TestClient(app) as c:
|
||||
resp = c.get("/", headers={"user-agent": DESKTOP_UA})
|
||||
assert resp.status_code == 200
|
||||
assert "/css/daw.css" in resp.text
|
||||
|
||||
|
||||
def test_ui_query_override_forces_mobile_on_desktop():
|
||||
with TestClient(app) as c:
|
||||
resp = c.get("/", params={"ui": "mobile"}, headers={"user-agent": DESKTOP_UA})
|
||||
assert "/mobile/app.js" in resp.text
|
||||
|
||||
|
||||
def test_ui_query_override_forces_desktop_on_phone():
|
||||
with TestClient(app) as c:
|
||||
resp = c.get("/", params={"ui": "desktop"}, headers={"user-agent": IPHONE_UA})
|
||||
assert "/css/daw.css" in resp.text
|
||||
|
||||
|
||||
def test_mobile_assets_are_served():
|
||||
with TestClient(app) as c:
|
||||
assert c.get("/mobile/app.js").status_code == 200
|
||||
assert c.get("/mobile/styles.css").status_code == 200
|
||||
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core import settings as settings_mod
|
||||
from app.main import _is_host_request, _is_loopback, app
|
||||
|
||||
MOBILE_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile/15E148"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"host,expected",
|
||||
[
|
||||
("127.0.0.1", True),
|
||||
("::1", True),
|
||||
("localhost", True),
|
||||
("::ffff:127.0.0.1", True),
|
||||
("127.0.1.1", True),
|
||||
("192.168.1.14", False),
|
||||
("10.0.0.5", False),
|
||||
("", False),
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
def test_is_loopback(host, expected):
|
||||
assert _is_loopback(host) is expected
|
||||
|
||||
|
||||
def test_host_request_recognizes_own_lan_ip(monkeypatch):
|
||||
# The host reaching itself via its LAN address must count as local, so
|
||||
# turning network access off never cuts the host off from its own server.
|
||||
monkeypatch.setattr("app.main._local_ips", lambda: frozenset({"192.168.1.14"}))
|
||||
assert _is_host_request("192.168.1.14") is True # the host's own IP
|
||||
assert _is_host_request("127.0.0.1") is True # loopback
|
||||
assert _is_host_request("192.168.1.99") is False # a different device
|
||||
|
||||
|
||||
def test_default_is_off_in_desktop_mode(monkeypatch):
|
||||
# Desktop keeps network off; the user opts in via the UI toggle.
|
||||
monkeypatch.delenv("STEMDECK_ALLOW_NETWORK", raising=False)
|
||||
monkeypatch.setenv("STEMDECK_DESKTOP", "1")
|
||||
assert settings_mod._default_allow_network() is False
|
||||
|
||||
|
||||
def test_default_is_on_in_server_mode(monkeypatch):
|
||||
# Server/Docker deployments open the gate by default.
|
||||
monkeypatch.delenv("STEMDECK_ALLOW_NETWORK", raising=False)
|
||||
monkeypatch.delenv("STEMDECK_DESKTOP", raising=False)
|
||||
assert settings_mod._default_allow_network() is True
|
||||
|
||||
|
||||
def test_env_var_pre_enables(monkeypatch):
|
||||
monkeypatch.setenv("STEMDECK_ALLOW_NETWORK", "1")
|
||||
assert settings_mod._default_allow_network() is True
|
||||
|
||||
|
||||
def test_runtime_settings_round_trip_and_clamp():
|
||||
with TestClient(app) as c:
|
||||
r = c.post("/api/settings", json={"max_duration_sec": 600, "video_max_height": 1080})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["max_duration_sec"] == 600
|
||||
assert body["video_max_height"] == 1080
|
||||
# GET reflects the new values.
|
||||
assert c.get("/api/settings").json()["max_duration_sec"] == 600
|
||||
|
||||
# Out-of-range values are clamped, not rejected.
|
||||
assert settings_mod.set_max_duration_sec(5) == 60 # floor
|
||||
assert settings_mod.set_max_duration_sec(99999) == 1200 # ceiling = 20 min
|
||||
assert settings_mod.set_video_max_height(99999) == 2160 # ceil
|
||||
|
||||
|
||||
def test_port_default_and_clamp():
|
||||
assert settings_mod.get_port() == 8000 # default
|
||||
with TestClient(app) as c:
|
||||
assert c.post("/api/settings", json={"port": 9000}).json()["port"] == 9000
|
||||
assert settings_mod.set_port(80) == 1024 # floor (privileged ports rejected)
|
||||
assert settings_mod.set_port(70000) == 65535 # ceil
|
||||
|
||||
|
||||
def test_settings_reject_non_integer():
|
||||
with TestClient(app) as c:
|
||||
assert c.post("/api/settings", json={"max_duration_sec": "abc"}).status_code == 422
|
||||
|
||||
|
||||
def test_gate_blocks_non_loopback_when_off():
|
||||
settings_mod.set_allow_network(False)
|
||||
# TestClient's client host ("testclient") is treated as non-loopback.
|
||||
with TestClient(app) as c:
|
||||
r = c.get("/", headers={"user-agent": MOBILE_UA})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_gate_allows_everyone_when_on():
|
||||
settings_mod.set_allow_network(True)
|
||||
with TestClient(app) as c:
|
||||
assert c.get("/api/health").status_code == 200
|
||||
|
||||
|
||||
def test_loopback_always_allowed_even_when_off(monkeypatch):
|
||||
settings_mod.set_allow_network(False)
|
||||
monkeypatch.setattr("app.main._is_loopback", lambda _host: True)
|
||||
with TestClient(app) as c:
|
||||
assert c.get("/api/health").status_code == 200
|
||||
|
||||
|
||||
def test_post_toggles_off_then_blocks():
|
||||
settings_mod.set_allow_network(True) # so the non-loopback client can reach POST
|
||||
with TestClient(app) as c:
|
||||
r = c.post("/api/settings", json={"allow_network": False})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["allow_network"] is False
|
||||
# Now off → a non-loopback client is blocked from everything.
|
||||
with TestClient(app) as c:
|
||||
assert c.get("/api/settings").status_code == 403
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import struct
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app.pipeline.collect import _PEAK_POINTS, compute_stem_peaks
|
||||
|
||||
|
||||
def _write_wav(path: Path, samples: list[float], sample_rate: int = 44100) -> None:
|
||||
"""Write a mono 16-bit PCM WAV file."""
|
||||
with wave.open(str(path), "w") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sample_rate)
|
||||
data = struct.pack(f"<{len(samples)}h", *[int(s * 32767) for s in samples])
|
||||
wf.writeframes(data)
|
||||
|
||||
|
||||
def test_produces_peaks_json(tmp_path):
|
||||
stems_dir = tmp_path / "stems"
|
||||
stems_dir.mkdir()
|
||||
|
||||
# 1-second sine wave at 440 Hz
|
||||
sr = 44100
|
||||
t = np.linspace(0, 1, sr, endpoint=False)
|
||||
samples = (np.sin(2 * np.pi * 440 * t) * 0.5).tolist()
|
||||
_write_wav(stems_dir / "vocals.wav", samples, sr)
|
||||
|
||||
compute_stem_peaks(stems_dir, ["vocals"])
|
||||
|
||||
peaks_path = stems_dir / "peaks.json"
|
||||
assert peaks_path.is_file()
|
||||
data = json.loads(peaks_path.read_text())
|
||||
assert "vocals" in data
|
||||
pts = data["vocals"]
|
||||
assert len(pts) <= _PEAK_POINTS
|
||||
assert len(pts) > 0
|
||||
# each point is [min, max] with min <= 0 <= max (sine wave)
|
||||
for mn, mx in pts:
|
||||
assert mn <= mx
|
||||
assert -1.0 <= mn <= 1.0
|
||||
assert -1.0 <= mx <= 1.0
|
||||
|
||||
|
||||
def test_multiple_stems(tmp_path):
|
||||
stems_dir = tmp_path / "stems"
|
||||
stems_dir.mkdir()
|
||||
for name in ("vocals", "drums", "bass"):
|
||||
_write_wav(stems_dir / f"{name}.wav", [0.1, -0.1, 0.2, -0.2])
|
||||
|
||||
compute_stem_peaks(stems_dir, ["vocals", "drums", "bass"])
|
||||
|
||||
data = json.loads((stems_dir / "peaks.json").read_text())
|
||||
assert set(data.keys()) == {"vocals", "drums", "bass"}
|
||||
|
||||
|
||||
def test_skips_missing_wav(tmp_path):
|
||||
stems_dir = tmp_path / "stems"
|
||||
stems_dir.mkdir()
|
||||
_write_wav(stems_dir / "drums.wav", [0.1, -0.1])
|
||||
# "vocals.wav" intentionally absent
|
||||
|
||||
compute_stem_peaks(stems_dir, ["vocals", "drums"])
|
||||
|
||||
data = json.loads((stems_dir / "peaks.json").read_text())
|
||||
assert "drums" in data
|
||||
assert "vocals" not in data
|
||||
|
||||
|
||||
def test_no_output_when_all_stems_missing(tmp_path):
|
||||
stems_dir = tmp_path / "stems"
|
||||
stems_dir.mkdir()
|
||||
|
||||
compute_stem_peaks(stems_dir, ["vocals", "drums"])
|
||||
|
||||
assert not (stems_dir / "peaks.json").exists()
|
||||
|
||||
|
||||
def test_writes_atomically(tmp_path):
|
||||
"""No partial peaks.json.tmp should survive a successful run."""
|
||||
stems_dir = tmp_path / "stems"
|
||||
stems_dir.mkdir()
|
||||
_write_wav(stems_dir / "vocals.wav", [0.1, -0.1, 0.3])
|
||||
|
||||
compute_stem_peaks(stems_dir, ["vocals"])
|
||||
|
||||
assert (stems_dir / "peaks.json").is_file()
|
||||
assert not (stems_dir / "peaks.json.tmp").exists()
|
||||
|
||||
|
||||
def test_non_fatal_on_corrupt_wav(tmp_path):
|
||||
stems_dir = tmp_path / "stems"
|
||||
stems_dir.mkdir()
|
||||
(stems_dir / "vocals.wav").write_bytes(b"not a wav file at all")
|
||||
_write_wav(stems_dir / "drums.wav", [0.1, -0.1])
|
||||
|
||||
# Should not raise; drums should still be computed
|
||||
compute_stem_peaks(stems_dir, ["vocals", "drums"])
|
||||
|
||||
data = json.loads((stems_dir / "peaks.json").read_text())
|
||||
assert "drums" in data
|
||||
assert "vocals" not in data
|
||||
@@ -0,0 +1,222 @@
|
||||
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()
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.models import Job
|
||||
from app.core.registry import _jobs
|
||||
from app.core.registry import persist as persist_registry
|
||||
from app.core.registry import restore as restore_registry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_registry():
|
||||
_jobs.clear()
|
||||
yield
|
||||
_jobs.clear()
|
||||
|
||||
|
||||
def test_persist_and_restore_terminal_job(tmp_path: Path):
|
||||
job = Job(
|
||||
id="abcdefabcdef",
|
||||
status="done",
|
||||
progress=1.0,
|
||||
stage_message="Done",
|
||||
title="Saved song",
|
||||
stems=[{"name": "vocals", "url": "/api/jobs/abcdefabcdef/stems/vocals.wav"}],
|
||||
selected_stems=["vocals"],
|
||||
)
|
||||
_jobs[job.id] = job
|
||||
|
||||
persist_registry(tmp_path)
|
||||
_jobs.clear()
|
||||
restore_registry(tmp_path)
|
||||
|
||||
restored = _jobs[job.id]
|
||||
assert restored.status == "done"
|
||||
assert restored.title == "Saved song"
|
||||
assert restored.stems == job.stems
|
||||
assert restored.cancel_requested is False
|
||||
|
||||
|
||||
def test_restore_recovers_orphan_done_job_from_stems(tmp_path: Path):
|
||||
job_dir = tmp_path / "abcdefabcdee"
|
||||
stems_dir = job_dir / "stems"
|
||||
stems_dir.mkdir(parents=True)
|
||||
(stems_dir / "vocals.wav").write_bytes(b"RIFF")
|
||||
(stems_dir / "drums.wav").write_bytes(b"RIFF")
|
||||
(job_dir / "metadata.json").write_text(json.dumps({"title": "Test Song"}), encoding="utf-8")
|
||||
|
||||
restore_registry(tmp_path)
|
||||
|
||||
restored = _jobs["abcdefabcdee"]
|
||||
assert restored.status == "done"
|
||||
assert restored.progress == 1.0
|
||||
assert restored.title == "Test Song"
|
||||
assert {stem["name"] for stem in restored.stems} == {"vocals", "drums"}
|
||||
|
||||
|
||||
def test_restore_skips_orphan_without_metadata(tmp_path: Path):
|
||||
stems_dir = tmp_path / "abcdefabcde0" / "stems"
|
||||
stems_dir.mkdir(parents=True)
|
||||
(stems_dir / "vocals.wav").write_bytes(b"RIFF")
|
||||
|
||||
restore_registry(tmp_path)
|
||||
|
||||
assert "abcdefabcde0" not in _jobs
|
||||
|
||||
|
||||
def test_restored_job_serves_stems(tmp_path: Path, monkeypatch):
|
||||
stems_dir = tmp_path / "abcdefabcded" / "stems"
|
||||
stems_dir.mkdir(parents=True)
|
||||
(stems_dir / "vocals.wav").write_bytes(b"RIFF1234")
|
||||
data = {
|
||||
"version": 1,
|
||||
"jobs": [
|
||||
Job(
|
||||
id="abcdefabcded",
|
||||
status="done",
|
||||
title="Test Song",
|
||||
stems=[{"name": "vocals", "url": "/api/jobs/abcdefabcded/stems/vocals.wav"}],
|
||||
selected_stems=["vocals"],
|
||||
).to_record()
|
||||
],
|
||||
}
|
||||
(tmp_path / "registry.json").write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr("app.api.stems.JOBS_DIR", tmp_path)
|
||||
restore_registry(tmp_path)
|
||||
|
||||
from app.main import app
|
||||
|
||||
with TestClient(app) as client:
|
||||
state = client.get("/api/jobs/abcdefabcded")
|
||||
assert state.status_code == 200
|
||||
assert state.json()["status"] == "done"
|
||||
stem = client.get("/api/jobs/abcdefabcded/stems/vocals.wav")
|
||||
assert stem.status_code == 200
|
||||
assert stem.content == b"RIFF1234"
|
||||
|
||||
|
||||
def test_delete_updates_persisted_registry(tmp_path: Path, monkeypatch):
|
||||
job = Job(id="abcdefabcdec", status="done")
|
||||
_jobs[job.id] = job
|
||||
job_dir = tmp_path / job.id
|
||||
job_dir.mkdir(parents=True)
|
||||
persist_registry(tmp_path)
|
||||
|
||||
monkeypatch.setattr("app.api.jobs.JOBS_DIR", tmp_path)
|
||||
|
||||
from app.main import app
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.delete(f"/api/jobs/{job.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert not job_dir.exists()
|
||||
data = json.loads((tmp_path / "registry.json").read_text(encoding="utf-8"))
|
||||
assert data["jobs"] == []
|
||||
@@ -0,0 +1,457 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.models import Job
|
||||
from app.core.registry import _jobs
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_registry():
|
||||
_jobs.clear()
|
||||
yield
|
||||
_jobs.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
import app.api.stems as stems_mod
|
||||
|
||||
monkeypatch.setattr(stems_mod, "JOBS_DIR", tmp_path)
|
||||
from app.main import app
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _make_stem_file(tmp_path, job_id: str, name: str, contents: bytes = b"RIFF"):
|
||||
stems_dir = tmp_path / job_id / "stems"
|
||||
stems_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = stems_dir / f"{name}.wav"
|
||||
path.write_bytes(contents)
|
||||
return path
|
||||
|
||||
|
||||
def test_rejects_malformed_job_id(client):
|
||||
for bad_id in ("../etc", "ABC", "abcdefabcdef0", "abcdefabcde", "abcd-efabcdef"):
|
||||
r = client.get(f"/api/jobs/{bad_id}/stems/vocals.wav")
|
||||
assert r.status_code == 404, f"id {bad_id!r} should 404"
|
||||
|
||||
|
||||
def test_rejects_unknown_stem_name(client):
|
||||
job = Job(id="abcdefabcdef")
|
||||
job.status = "done"
|
||||
_jobs[job.id] = job
|
||||
r = client.get(f"/api/jobs/{job.id}/stems/banjo.wav")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_requires_done_status(client, tmp_path):
|
||||
job = Job(id="abcdefabcdef")
|
||||
job.status = "separating"
|
||||
_jobs[job.id] = job
|
||||
_make_stem_file(tmp_path, job.id, "vocals")
|
||||
r = client.get(f"/api/jobs/{job.id}/stems/vocals.wav")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_serves_done_job_stem(client, tmp_path):
|
||||
job = Job(id="abcdefabcdee")
|
||||
job.status = "done"
|
||||
_jobs[job.id] = job
|
||||
_make_stem_file(tmp_path, job.id, "vocals", b"RIFF1234")
|
||||
r = client.get(f"/api/jobs/{job.id}/stems/vocals.wav")
|
||||
assert r.status_code == 200
|
||||
assert r.content == b"RIFF1234"
|
||||
assert r.headers["content-type"] == "audio/wav"
|
||||
|
||||
|
||||
# --- peaks endpoint ---
|
||||
|
||||
|
||||
def _make_peaks_file(tmp_path, job_id: str, data: dict) -> None:
|
||||
stems_dir = tmp_path / job_id / "stems"
|
||||
stems_dir.mkdir(parents=True, exist_ok=True)
|
||||
(stems_dir / "peaks.json").write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
|
||||
def test_peaks_returns_json_for_done_job(client, tmp_path):
|
||||
job = Job(id="abcdefabcdea")
|
||||
job.status = "done"
|
||||
_jobs[job.id] = job
|
||||
payload = {"vocals": [[-0.1, 0.2], [-0.3, 0.4]], "drums": [[-0.5, 0.6]]}
|
||||
_make_peaks_file(tmp_path, job.id, payload)
|
||||
|
||||
r = client.get(f"/api/jobs/{job.id}/stems/peaks.json")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "application/json"
|
||||
assert "immutable" in r.headers.get("cache-control", "")
|
||||
assert r.json() == payload
|
||||
|
||||
|
||||
def test_peaks_404_when_file_missing(client, tmp_path):
|
||||
job = Job(id="abcdefabcdeb")
|
||||
job.status = "done"
|
||||
_jobs[job.id] = job
|
||||
# stems dir exists but no peaks.json
|
||||
(tmp_path / job.id / "stems").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
r = client.get(f"/api/jobs/{job.id}/stems/peaks.json")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_peaks_404_for_non_done_job(client):
|
||||
job = Job(id="abcdefabcdec")
|
||||
job.status = "separating"
|
||||
_jobs[job.id] = job
|
||||
|
||||
r = client.get(f"/api/jobs/{job.id}/stems/peaks.json")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_peaks_rejects_malformed_job_id(client):
|
||||
for bad_id in ("../etc", "ABC", "abcdefabcdef0", "abcdefabcde"):
|
||||
r = client.get(f"/api/jobs/{bad_id}/stems/peaks.json")
|
||||
assert r.status_code == 404, f"id {bad_id!r} should 404"
|
||||
|
||||
|
||||
# ── Export All Stems (.zip) ──
|
||||
|
||||
|
||||
def test_all_stems_zip_all_when_no_subset(client, tmp_path):
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
job = Job(id="abcdefabcdab")
|
||||
job.status = "done"
|
||||
job.title = "My Song! (Live)"
|
||||
_jobs[job.id] = job
|
||||
_make_stem_file(tmp_path, job.id, "vocals", b"RIFFvocals")
|
||||
_make_stem_file(tmp_path, job.id, "drums", b"RIFFdrums")
|
||||
_make_stem_file(tmp_path, job.id, "bass", b"RIFFbass")
|
||||
|
||||
r = client.get(f"/api/jobs/{job.id}/stems/all.zip")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "application/zip"
|
||||
assert "My_Song_Live_stems.zip" in r.headers["content-disposition"]
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(r.content))
|
||||
assert sorted(zf.namelist()) == ["bass.wav", "drums.wav", "vocals.wav"]
|
||||
assert zf.read("vocals.wav") == b"RIFFvocals"
|
||||
|
||||
|
||||
def test_all_stems_zip_only_active_subset(client, tmp_path):
|
||||
"""Only the requested (active) stems are bundled — not every stem on disk."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
job = Job(id="abcdefabcdba")
|
||||
job.status = "done"
|
||||
_jobs[job.id] = job
|
||||
for name in ("vocals", "drums", "bass", "guitar", "piano", "other"):
|
||||
_make_stem_file(tmp_path, job.id, name, f"RIFF{name}".encode())
|
||||
|
||||
r = client.get(f"/api/jobs/{job.id}/stems/all.zip?stems=vocals,bass")
|
||||
assert r.status_code == 200
|
||||
zf = zipfile.ZipFile(io.BytesIO(r.content))
|
||||
assert sorted(zf.namelist()) == ["bass.wav", "vocals.wav"]
|
||||
|
||||
|
||||
def test_all_stems_zip_rejects_unknown_stem(client, tmp_path):
|
||||
job = Job(id="abcdefabcdbb")
|
||||
job.status = "done"
|
||||
_jobs[job.id] = job
|
||||
_make_stem_file(tmp_path, job.id, "vocals")
|
||||
r = client.get(f"/api/jobs/{job.id}/stems/all.zip?stems=vocals,banjo")
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_all_stems_zip_rejects_bad_format(client, tmp_path):
|
||||
job = Job(id="abcdefabcdac")
|
||||
job.status = "done"
|
||||
_jobs[job.id] = job
|
||||
_make_stem_file(tmp_path, job.id, "vocals")
|
||||
r = client.get(f"/api/jobs/{job.id}/stems/all.zip?format=ogg")
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_all_stems_zip_404_for_unknown_job(client):
|
||||
r = client.get("/api/jobs/abcdefabcdad/stems/all.zip")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_all_stems_zip_rejects_malformed_job_id(client):
|
||||
for bad_id in ("../etc", "ABC", "abcdefabcdef0", "abcdefabcde"):
|
||||
r = client.get(f"/api/jobs/{bad_id}/stems/all.zip")
|
||||
assert r.status_code == 404, f"id {bad_id!r} should 404"
|
||||
|
||||
|
||||
def test_all_stems_zip_404_when_no_stem_files(client, tmp_path):
|
||||
job = Job(id="abcdefabcdae")
|
||||
job.status = "done"
|
||||
_jobs[job.id] = job
|
||||
(tmp_path / job.id / "stems").mkdir(parents=True, exist_ok=True)
|
||||
r = client.get(f"/api/jobs/{job.id}/stems/all.zip")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_all_stems_zip_mp3(client, tmp_path):
|
||||
"""MP3 zip transcodes via ffmpeg; skip if ffmpeg isn't available."""
|
||||
import io
|
||||
import shutil
|
||||
import zipfile
|
||||
|
||||
if shutil.which("ffmpeg") is None:
|
||||
import pytest
|
||||
|
||||
pytest.skip("ffmpeg not available")
|
||||
|
||||
# A real (tiny) WAV so ffmpeg can transcode it.
|
||||
import struct
|
||||
|
||||
sr = 8000
|
||||
nframes = sr // 10
|
||||
data = b"\x00\x00" * nframes
|
||||
hdr = b"RIFF" + struct.pack("<I", 36 + len(data)) + b"WAVE"
|
||||
hdr += b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, sr, sr * 2, 2, 16)
|
||||
hdr += b"data" + struct.pack("<I", len(data))
|
||||
wav = hdr + data
|
||||
|
||||
job = Job(id="abcdefabcdaf")
|
||||
job.status = "done"
|
||||
job.title = "Track"
|
||||
_jobs[job.id] = job
|
||||
_make_stem_file(tmp_path, job.id, "vocals", wav)
|
||||
|
||||
r = client.get(f"/api/jobs/{job.id}/stems/all.zip?format=mp3")
|
||||
assert r.status_code == 200
|
||||
zf = zipfile.ZipFile(io.BytesIO(r.content))
|
||||
assert zf.namelist() == ["vocals.mp3"]
|
||||
assert len(zf.read("vocals.mp3")) > 0
|
||||
|
||||
|
||||
# --- dynamic mixdown endpoint (#183) ---
|
||||
|
||||
|
||||
def _tiny_wav(seconds: float = 0.2, sr: int = 8000) -> bytes:
|
||||
"""A minimal silent PCM16 mono WAV so ffmpeg can decode/mix it."""
|
||||
import struct
|
||||
|
||||
nframes = int(sr * seconds)
|
||||
data = b"\x00\x00" * nframes
|
||||
hdr = b"RIFF" + struct.pack("<I", 36 + len(data)) + b"WAVE"
|
||||
hdr += b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, sr, sr * 2, 2, 16)
|
||||
hdr += b"data" + struct.pack("<I", len(data))
|
||||
return hdr + data
|
||||
|
||||
|
||||
def _done_job_with_stems(tmp_path, job_id: str, names) -> Job:
|
||||
job = Job(id=job_id)
|
||||
job.status = "done"
|
||||
job.title = "Track"
|
||||
_jobs[job.id] = job
|
||||
for name in names:
|
||||
_make_stem_file(tmp_path, job_id, name, _tiny_wav())
|
||||
return job
|
||||
|
||||
|
||||
def test_mixdown_rejects_bad_ext(client):
|
||||
r = client.get("/api/jobs/abcdef000001/mixdown.ogg?stems=vocals&gains=1")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_mixdown_rejects_length_mismatch(client):
|
||||
r = client.get("/api/jobs/abcdef000001/mixdown.wav?stems=vocals,drums&gains=1")
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_mixdown_rejects_empty(client):
|
||||
r = client.get("/api/jobs/abcdef000001/mixdown.wav?stems=&gains=")
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_mixdown_rejects_bad_gain(client):
|
||||
for gains in ("abc", "-1", "99"):
|
||||
r = client.get(f"/api/jobs/abcdef000001/mixdown.wav?stems=vocals&gains={gains}")
|
||||
assert r.status_code == 422, f"gains={gains!r} should 422"
|
||||
|
||||
|
||||
def test_mixdown_rejects_unknown_stem(client):
|
||||
# "mix" is intentionally excluded (it is the static pre-render we replace).
|
||||
for stem in ("banjo", "mix"):
|
||||
r = client.get(f"/api/jobs/abcdef000001/mixdown.wav?stems={stem}&gains=1")
|
||||
assert r.status_code == 422, f"stem={stem!r} should 422"
|
||||
|
||||
|
||||
def test_mixdown_rejects_bad_region(client):
|
||||
r = client.get("/api/jobs/abcdef000001/mixdown.wav?stems=vocals&gains=1&start=5&end=2")
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_mixdown_rejects_malformed_job_id(client):
|
||||
r = client.get("/api/jobs/ZZZ/mixdown.wav?stems=vocals&gains=1")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_mixdown_requires_done(client, tmp_path):
|
||||
job = Job(id="abcdef000002")
|
||||
job.status = "separating"
|
||||
_jobs[job.id] = job
|
||||
_make_stem_file(tmp_path, job.id, "vocals", _tiny_wav())
|
||||
r = client.get(f"/api/jobs/{job.id}/mixdown.wav?stems=vocals&gains=1")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_mixdown_404_for_missing_stem_file(client, tmp_path):
|
||||
job = Job(id="abcdef000003")
|
||||
job.status = "done"
|
||||
_jobs[job.id] = job # no stem files on disk
|
||||
r = client.get(f"/api/jobs/{job.id}/mixdown.wav?stems=vocals&gains=1")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def _skip_without_ffmpeg():
|
||||
import shutil
|
||||
|
||||
if shutil.which("ffmpeg") is None:
|
||||
import pytest
|
||||
|
||||
pytest.skip("ffmpeg not available")
|
||||
|
||||
|
||||
def test_mixdown_wav_happy(client, tmp_path):
|
||||
_skip_without_ffmpeg()
|
||||
job = _done_job_with_stems(tmp_path, "abcdef000010", ["vocals", "drums"])
|
||||
r = client.get(f"/api/jobs/{job.id}/mixdown.wav?stems=vocals,drums&gains=1.000,0.500")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "audio/wav"
|
||||
assert r.content[:4] == b"RIFF"
|
||||
|
||||
|
||||
def test_mixdown_single_lane_skips_amix(client, tmp_path):
|
||||
_skip_without_ffmpeg()
|
||||
job = _done_job_with_stems(tmp_path, "abcdef000011", ["bass"])
|
||||
r = client.get(f"/api/jobs/{job.id}/mixdown.wav?stems=bass&gains=1.500")
|
||||
assert r.status_code == 200
|
||||
assert r.content[:4] == b"RIFF"
|
||||
|
||||
|
||||
def test_mixdown_mp3_happy(client, tmp_path):
|
||||
_skip_without_ffmpeg()
|
||||
job = _done_job_with_stems(tmp_path, "abcdef000012", ["vocals", "bass"])
|
||||
r = client.get(f"/api/jobs/{job.id}/mixdown.mp3?stems=vocals,bass&gains=1,1")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "audio/mpeg"
|
||||
assert len(r.content) > 0
|
||||
|
||||
|
||||
def test_mixdown_region_trim(client, tmp_path):
|
||||
_skip_without_ffmpeg()
|
||||
job = _done_job_with_stems(tmp_path, "abcdef000013", ["vocals", "drums"])
|
||||
r = client.get(
|
||||
f"/api/jobs/{job.id}/mixdown.wav?stems=vocals,drums&gains=1,1&start=0.05&end=0.15"
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.content[:4] == b"RIFF"
|
||||
|
||||
|
||||
def test_mixdown_flac_happy(client, tmp_path):
|
||||
_skip_without_ffmpeg()
|
||||
job = _done_job_with_stems(tmp_path, "abcdef000014", ["vocals", "bass"])
|
||||
r = client.get(f"/api/jobs/{job.id}/mixdown.flac?stems=vocals,bass&gains=1,1")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "audio/flac"
|
||||
assert r.content[:4] == b"fLaC" # FLAC stream marker
|
||||
|
||||
|
||||
def test_mixdown_rejects_unknown_ext_still(client):
|
||||
# ogg remains unsupported even after adding flac.
|
||||
r = client.get("/api/jobs/abcdef000001/mixdown.ogg?stems=vocals&gains=1")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_all_stems_zip_flac(client, tmp_path):
|
||||
_skip_without_ffmpeg()
|
||||
job = _done_job_with_stems(tmp_path, "abcdef000015", ["vocals"])
|
||||
job.title = "Track"
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
r = client.get(f"/api/jobs/{job.id}/stems/all.zip?format=flac")
|
||||
assert r.status_code == 200
|
||||
zf = zipfile.ZipFile(io.BytesIO(r.content))
|
||||
assert zf.namelist() == ["vocals.flac"]
|
||||
assert zf.read("vocals.flac")[:4] == b"fLaC"
|
||||
|
||||
|
||||
# --- MP4 video mux endpoint (#219) ---
|
||||
|
||||
|
||||
def _make_video_file(tmp_path, job_id: str) -> None:
|
||||
"""Generate a tiny real MP4 with a video stream at <job>/video.mp4 so the
|
||||
mux endpoint has something to stream-copy. Requires ffmpeg."""
|
||||
import subprocess
|
||||
|
||||
job_dir = tmp_path / job_id
|
||||
job_dir.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-nostdin",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=black:s=64x64:d=0.3:r=10",
|
||||
"-c:v",
|
||||
"mpeg4",
|
||||
"-an",
|
||||
str(job_dir / "video.mp4"),
|
||||
],
|
||||
check=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def test_video_404_when_no_video_track(client, tmp_path):
|
||||
job = _done_job_with_stems(tmp_path, "abcdef000020", ["vocals"])
|
||||
r = client.get(f"/api/jobs/{job.id}/video.mp4?stems=vocals&gains=1")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_video_requires_done(client, tmp_path):
|
||||
job = Job(id="abcdef000021")
|
||||
job.status = "separating"
|
||||
_jobs[job.id] = job
|
||||
r = client.get(f"/api/jobs/{job.id}/video.mp4?stems=vocals&gains=1")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_video_rejects_malformed_job_id(client):
|
||||
r = client.get("/api/jobs/ZZZ/video.mp4?stems=vocals&gains=1")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_video_rejects_bad_params(client, tmp_path):
|
||||
_skip_without_ffmpeg()
|
||||
job = _done_job_with_stems(tmp_path, "abcdef000022", ["vocals", "drums"])
|
||||
_make_video_file(tmp_path, job.id)
|
||||
# length mismatch, bad gain, unknown stem all 422 once the video track exists.
|
||||
assert client.get(f"/api/jobs/{job.id}/video.mp4?stems=vocals,drums&gains=1").status_code == 422
|
||||
assert client.get(f"/api/jobs/{job.id}/video.mp4?stems=vocals&gains=99").status_code == 422
|
||||
assert client.get(f"/api/jobs/{job.id}/video.mp4?stems=mix&gains=1").status_code == 422
|
||||
|
||||
|
||||
def test_video_mux_happy(client, tmp_path):
|
||||
_skip_without_ffmpeg()
|
||||
job = _done_job_with_stems(tmp_path, "abcdef000023", ["vocals", "drums"])
|
||||
_make_video_file(tmp_path, job.id)
|
||||
r = client.get(f"/api/jobs/{job.id}/video.mp4?stems=vocals,drums&gains=1.0,0.5")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "video/mp4"
|
||||
# ISO-BMFF: bytes 4-8 of the first box are the "ftyp" type.
|
||||
assert r.content[4:8] == b"ftyp"
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.models import Job
|
||||
from app.core.registry import _jobs
|
||||
from app.pipeline.collect import sweep_old_jobs
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_registry():
|
||||
_jobs.clear()
|
||||
yield
|
||||
_jobs.clear()
|
||||
|
||||
|
||||
def _mkdir(jobs_dir: Path, name: str) -> Path:
|
||||
d = jobs_dir / name
|
||||
d.mkdir(parents=True)
|
||||
(d / "marker").write_bytes(b"x")
|
||||
return d
|
||||
|
||||
|
||||
def test_skip_active_job_even_if_old(tmp_path: Path):
|
||||
"""An active (non-terminal) job's directory must never be swept,
|
||||
even if its created_at predates the TTL cutoff."""
|
||||
d = _mkdir(tmp_path, "abcdefabcdef")
|
||||
job = Job(id="abcdefabcdef")
|
||||
job.status = "separating"
|
||||
job.created_at = time.time() - 999_999 # ancient
|
||||
_jobs[job.id] = job
|
||||
|
||||
with patch("app.pipeline.collect.JOB_TTL_SECONDS", 60):
|
||||
sweep_old_jobs(tmp_path)
|
||||
|
||||
assert d.is_dir()
|
||||
assert job.id in _jobs
|
||||
|
||||
|
||||
def test_sweeps_terminal_old_job(tmp_path: Path):
|
||||
d = _mkdir(tmp_path, "abcdefabcdee")
|
||||
job = Job(id="abcdefabcdee")
|
||||
job.status = "done"
|
||||
job.created_at = time.time() - 999_999
|
||||
_jobs[job.id] = job
|
||||
|
||||
with patch("app.pipeline.collect.JOB_TTL_SECONDS", 60):
|
||||
sweep_old_jobs(tmp_path)
|
||||
|
||||
assert not d.exists()
|
||||
assert job.id not in _jobs
|
||||
|
||||
|
||||
def test_sweep_disabled_under_desktop(monkeypatch):
|
||||
"""The desktop shell (STEMDECK_DESKTOP=1) opts out of the TTL sweep so a
|
||||
user's curated library isn't purged; the server/Docker default keeps it."""
|
||||
from app.main import _sweep_disabled
|
||||
|
||||
monkeypatch.delenv("STEMDECK_PERSIST_LIBRARY", raising=False)
|
||||
monkeypatch.setenv("STEMDECK_DESKTOP", "1")
|
||||
assert _sweep_disabled() is True
|
||||
monkeypatch.delenv("STEMDECK_DESKTOP", raising=False)
|
||||
assert _sweep_disabled() is False
|
||||
|
||||
|
||||
def test_sweep_disabled_under_persistent_library(monkeypatch):
|
||||
"""A self-hosted server (run.sh) opts into a persistent library
|
||||
(STEMDECK_PERSIST_LIBRARY=1) so its processed tracks aren't purged."""
|
||||
from app.main import _sweep_disabled
|
||||
|
||||
monkeypatch.delenv("STEMDECK_DESKTOP", raising=False)
|
||||
monkeypatch.setenv("STEMDECK_PERSIST_LIBRARY", "1")
|
||||
assert _sweep_disabled() is True
|
||||
monkeypatch.delenv("STEMDECK_PERSIST_LIBRARY", raising=False)
|
||||
assert _sweep_disabled() is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sweep_loop_returns_immediately_under_desktop(monkeypatch):
|
||||
"""In desktop mode the loop returns at once instead of entering the hourly
|
||||
cycle (wait_for would time out if it looped)."""
|
||||
import asyncio
|
||||
|
||||
from app.main import _sweep_loop
|
||||
|
||||
monkeypatch.setenv("STEMDECK_DESKTOP", "1")
|
||||
await asyncio.wait_for(_sweep_loop(), timeout=2)
|
||||
|
||||
|
||||
def test_keeps_recent_terminal_job(tmp_path: Path):
|
||||
d = _mkdir(tmp_path, "abcdefabcded")
|
||||
job = Job(id="abcdefabcded")
|
||||
job.status = "done"
|
||||
job.created_at = time.time() # fresh
|
||||
_jobs[job.id] = job
|
||||
|
||||
with patch("app.pipeline.collect.JOB_TTL_SECONDS", 60):
|
||||
sweep_old_jobs(tmp_path)
|
||||
|
||||
assert d.is_dir()
|
||||
assert job.id in _jobs
|
||||
|
||||
|
||||
def test_orphan_dir_falls_back_to_mtime(tmp_path: Path):
|
||||
"""Directories with no registry entry (e.g. left over from a prior
|
||||
server run) still get swept by mtime."""
|
||||
d = _mkdir(tmp_path, "abcdefabcdec")
|
||||
# Backdate the directory.
|
||||
old = time.time() - 999_999
|
||||
import os
|
||||
|
||||
os.utime(d, (old, old))
|
||||
|
||||
with patch("app.pipeline.collect.JOB_TTL_SECONDS", 60):
|
||||
sweep_old_jobs(tmp_path)
|
||||
|
||||
assert not d.exists()
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.pipeline.download import InvalidYouTubeURL, validate_youtube_url
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url,expected",
|
||||
[
|
||||
(
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
),
|
||||
(
|
||||
"https://youtu.be/dQw4w9WgXcQ",
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
),
|
||||
(
|
||||
"https://m.youtube.com/watch?v=dQw4w9WgXcQ&list=PLfoo",
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
),
|
||||
(
|
||||
"https://music.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
),
|
||||
(
|
||||
" https://www.youtube.com/watch?v=dQw4w9WgXcQ ",
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
),
|
||||
(
|
||||
"https://www.youtube.com/shorts/dQw4w9WgXcQ",
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
),
|
||||
(
|
||||
"https://m.youtube.com/shorts/dQw4w9WgXcQ",
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_accepts_youtube_urls(url: str, expected: str) -> None:
|
||||
assert validate_youtube_url(url) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url,reason_substring",
|
||||
[
|
||||
("", "required"),
|
||||
(" ", "required"),
|
||||
("not a url", "http"),
|
||||
("ftp://youtube.com/watch?v=dQw4w9WgXcQ", "http"),
|
||||
("https://example.com/foo", "unsupported host"),
|
||||
("https://www.youtube.com/playlist?list=PLfoo", "video ID"),
|
||||
("https://evil.com/watch?v=dQw4w9WgXcQ", "unsupported host"),
|
||||
# The on.soundcloud.com share shortener is rejected — it redirects to
|
||||
# arbitrary targets, an SSRF vector once handed to yt-dlp (#173).
|
||||
("https://on.soundcloud.com/abc123", "unsupported host"),
|
||||
],
|
||||
)
|
||||
def test_rejects_bad_urls(url: str, reason_substring: str) -> None:
|
||||
with pytest.raises(InvalidYouTubeURL) as exc:
|
||||
validate_youtube_url(url)
|
||||
assert reason_substring in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://soundcloud.com/artist/track",
|
||||
"https://www.soundcloud.com/artist/track",
|
||||
" https://soundcloud.com/artist/track ",
|
||||
],
|
||||
)
|
||||
def test_accepts_soundcloud_urls(url: str) -> None:
|
||||
result = validate_youtube_url(url)
|
||||
assert result == url.strip()
|
||||
Reference in New Issue
Block a user