from __future__ import annotations from pathlib import Path import pytest from fastapi.testclient import TestClient from app import main @pytest.fixture def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): data_dir = tmp_path / "data" monkeypatch.setattr(main, "DATA_DIR", data_dir) monkeypatch.setattr(main, "REFERENCES_DIR", data_dir / "references") monkeypatch.setattr(main, "GENERATIONS_DIR", data_dir / "generations") monkeypatch.setattr(main, "DB_PATH", data_dir / "studio.sqlite3") queue = main.JobQueue() async def start_without_worker(): main.init_db() monkeypatch.setattr(queue, "start", start_without_worker) monkeypatch.setattr(main, "job_queue", queue) with TestClient(main.app) as test_client: yield test_client def wav_bytes() -> bytes: # Minimal RIFF/WAVE-shaped fixture; endpoint validates the container header. return b"RIFF\x24\x00\x00\x00WAVEfmt " + b"\x10\x00\x00\x00\x01\x00\x01\x00\x44\xac\x00\x00\x88\x58\x01\x00\x02\x00\x10\x00data\x00\x00\x00\x00" def create_reference(client: TestClient) -> dict: response = client.post( "/api/references", files={"file": ("voice.wav", wav_bytes(), "audio/wav")}, data={"transcript": "Hello, this is a sample.", "name": "Studio voice"}, ) assert response.status_code == 201, response.text return response.json() def test_reference_upload_and_transcript_required(client: TestClient): response = client.post( "/api/references", files={"file": ("voice.wav", wav_bytes(), "audio/wav")}, data={"transcript": " ", "name": "Empty"}, ) assert response.status_code == 422 reference = create_reference(client) assert reference["transcript"] == "Hello, this is a sample." assert client.get(reference["audio_url"]).content == wav_bytes() assert client.get("/api/references").json()[0]["id"] == reference["id"] def test_rejects_non_wav_upload(client: TestClient): response = client.post( "/api/references", files={"file": ("voice.mp3", b"not wav", "audio/mpeg")}, data={"transcript": "sample"}, ) assert response.status_code == 415 def test_generations_are_queued_and_can_be_cancelled(client: TestClient): reference = create_reference(client) created = client.post("/api/generations", json={"text": "Read this aloud", "reference_id": reference["id"]}) assert created.status_code == 202 job = created.json() assert job["status"] in {"queued", "running"} # Cancellation is valid regardless of whether the single worker has picked it up. cancelled = client.post(f"/api/generations/{job['id']}/cancel") assert cancelled.status_code == 200 assert cancelled.json()["status"] == "cancelled" assert client.post(f"/api/generations/{job['id']}/cancel").json()["status"] == "cancelled" def test_running_jobs_cancel_and_completed_jobs_cannot(client: TestClient): reference = create_reference(client) running = client.post("/api/generations", json={"text": "Active request", "reference_id": reference["id"]}).json() completed = client.post("/api/generations", json={"text": "Finished request", "reference_id": reference["id"]}).json() with main.connect_db() as db: db.execute("UPDATE generations SET status='running' WHERE id=?", (running["id"],)) db.execute("UPDATE generations SET status='completed', filename='done.mp3' WHERE id=?", (completed["id"],)) cancelled = client.post(f"/api/generations/{running['id']}/cancel") assert cancelled.status_code == 200 assert cancelled.json()["status"] == "cancelled" cannot_cancel = client.post(f"/api/generations/{completed['id']}/cancel") assert cannot_cancel.status_code == 409 def test_generation_history_paginates_at_fifty(client: TestClient): reference = create_reference(client) with main.connect_db() as db: for index in range(52): stamp = f"2026-10-27T12:{index:02}:00+00:00" db.execute( "INSERT INTO generations(id,text,reference_id,status,created_at,updated_at) VALUES(?,?,?,'failed',?,?)", (f"job-{index:02}", f"Take {index}", reference["id"], stamp, stamp), ) page_one = client.get("/api/generations?page=1&page_size=50").json() page_two = client.get("/api/generations?page=2&page_size=50").json() assert page_one["total"] == 52 assert len(page_one["items"]) == 50 assert len(page_two["items"]) == 2 assert page_one["items"][0]["text"] == "Take 51" assert client.get("/api/generations?page=1&page_size=20").status_code == 422 def test_fish_speech_mock_request_persists_mp3_and_respects_cancel(client: TestClient): import asyncio import httpx import msgpack reference = create_reference(client) job = client.post("/api/generations", json={"text": "A test take", "reference_id": reference["id"]}).json() outgoing: list[dict] = [] async def handler(request: httpx.Request) -> httpx.Response: outgoing.append(msgpack.unpackb(request.content, raw=False)) return httpx.Response(200, content=b"fake-mp3") queue = main.JobQueue() queue.http = httpx.AsyncClient(transport=httpx.MockTransport(handler)) asyncio.run(queue.process(job["id"])) asyncio.run(queue.http.aclose()) assert outgoing[0]["text"] == "A test take" assert outgoing[0]["format"] == "mp3" assert outgoing[0]["references"][0]["text"] == "Hello, this is a sample." result = client.get(f"/api/generations/{job['id']}").json() assert result["status"] == "completed" assert client.get(result["audio_url"]).content == b"fake-mp3" def test_cancelled_queued_job_never_calls_fish_speech(client: TestClient): import asyncio import httpx reference = create_reference(client) job = client.post("/api/generations", json={"text": "A cancelled take", "reference_id": reference["id"]}).json() client.post(f"/api/generations/{job['id']}/cancel") calls = 0 async def handler(_: httpx.Request) -> httpx.Response: nonlocal calls calls += 1 return httpx.Response(200, content=b"unexpected") queue = main.JobQueue() queue.http = httpx.AsyncClient(transport=httpx.MockTransport(handler)) asyncio.run(queue.process(job["id"])) asyncio.run(queue.http.aclose()) assert calls == 0 assert client.get(f"/api/generations/{job['id']}").json()["status"] == "cancelled" def test_running_cancellation_discards_upstream_result(client: TestClient): import asyncio import httpx reference = create_reference(client) job = client.post("/api/generations", json={"text": "Cancel while running", "reference_id": reference["id"]}).json() started = asyncio.Event() release = asyncio.Event() async def scenario(): async def handler(_: httpx.Request) -> httpx.Response: started.set() await release.wait() return httpx.Response(200, content=b"discard this mp3") queue = main.JobQueue() queue.http = httpx.AsyncClient(transport=httpx.MockTransport(handler)) work = asyncio.create_task(queue.process(job["id"])) await started.wait() response = client.post(f"/api/generations/{job['id']}/cancel") assert response.json()["status"] == "cancelled" release.set() await work await queue.http.aclose() asyncio.run(scenario()) result = client.get(f"/api/generations/{job['id']}").json() assert result["status"] == "cancelled" assert result["audio_url"] is None assert not (main.GENERATIONS_DIR / f"{job['id']}.mp3").exists() def test_fish_speech_errors_are_saved_on_the_job(client: TestClient): import asyncio import httpx reference = create_reference(client) job = client.post("/api/generations", json={"text": "An upstream error", "reference_id": reference["id"]}).json() async def handler(_: httpx.Request) -> httpx.Response: return httpx.Response(503, text="model unavailable") async def scenario(): queue = main.JobQueue() queue.http = httpx.AsyncClient(transport=httpx.MockTransport(handler)) await queue.process(job["id"]) await queue.http.aclose() asyncio.run(scenario()) result = client.get(f"/api/generations/{job['id']}").json() assert result["status"] == "failed" assert "503" in result["error"] assert "model unavailable" in result["error"] def test_fish_speech_connection_failure_is_saved_on_the_job(client: TestClient): import asyncio import httpx reference = create_reference(client) job = client.post("/api/generations", json={"text": "A disconnected request", "reference_id": reference["id"]}).json() async def handler(_: httpx.Request) -> httpx.Response: raise httpx.ConnectError("server is offline") async def scenario(): queue = main.JobQueue() queue.http = httpx.AsyncClient(transport=httpx.MockTransport(handler)) await queue.process(job["id"]) await queue.http.aclose() asyncio.run(scenario()) result = client.get(f"/api/generations/{job['id']}").json() assert result["status"] == "failed" assert "Could not reach Fish Speech" in result["error"] def test_tts_url_normalization_and_validation(): assert main.normalize_tts_url(" http://192.168.1.42:8080/ ") == "http://192.168.1.42:8080/v1/tts" assert main.normalize_tts_url("http://192.168.1.42:8080/v1/tts") == "http://192.168.1.42:8080/v1/tts" with pytest.raises(main.HTTPException): main.normalize_tts_url("file:///etc/passwd") def test_tts_url_probe_uses_fish_health_endpoint(monkeypatch: pytest.MonkeyPatch): import asyncio requests = [] async def handler(request): requests.append(request) return main.httpx.Response(200, json={"status": "ok"}) client_class = main.httpx.AsyncClient monkeypatch.setattr( main.httpx, "AsyncClient", lambda **kwargs: client_class(transport=main.httpx.MockTransport(handler), **kwargs), ) url, message = asyncio.run(main.probe_tts_url("http://192.168.1.42:8080")) assert url == "http://192.168.1.42:8080/v1/tts" assert "healthy" in message assert requests[0].url.path == "/v1/health" def test_tts_urls_must_pass_probe_before_saving_and_can_be_activated(client: TestClient, monkeypatch: pytest.MonkeyPatch): async def passing_probe(url: str) -> tuple[str, str]: return main.normalize_tts_url(url), "Probe passed" monkeypatch.setattr(main, "probe_tts_url", passing_probe) test_result = client.post("/api/tts-endpoints/test", json={"url": "http://192.168.1.42:8080"}) assert test_result.status_code == 200 assert test_result.json()["url"] == "http://192.168.1.42:8080/v1/tts" saved = client.post("/api/tts-endpoints", json={"url": "http://192.168.1.42:8080"}) assert saved.status_code == 201 endpoint = saved.json() assert endpoint["active"] is True assert main.active_tts_url() == endpoint["url"] reference = create_reference(client) generation = client.post("/api/generations", json={"text": "Use the selected host", "reference_id": reference["id"]}).json() assert main.get_generation(generation["id"])["tts_url"] == endpoint["url"] second = client.post("/api/tts-endpoints", json={"url": "http://192.168.1.43:8080/v1/tts"}).json() activated = client.post(f"/api/tts-endpoints/{endpoint['id']}/activate") assert activated.status_code == 200 assert activated.json()["url"] == endpoint["url"] assert main.active_tts_url() == endpoint["url"] assert client.get("/api/tts-endpoints").json()["items"] assert second["url"] != endpoint["url"] def test_startup_marks_interrupted_jobs_failed(client: TestClient): reference = create_reference(client) with main.connect_db() as db: db.execute( "INSERT INTO generations(id,text,reference_id,status,created_at,updated_at) VALUES('interrupted','hello',?,'running','2026-10-27T12:00:00+00:00','2026-10-27T12:00:00+00:00')", (reference["id"],), ) main.init_db() row = main.get_generation("interrupted") assert row["status"] == "failed" assert "restarted" in row["error"].lower()