Files
VoiceStudio/backend/tests/test_audiobook_resume_api.py
T
3f62471bad test: pay down export-router test debt + kill two test-order pollution classes (#1081)
Four pieces of test debt, root-caused and hardened:

1. exports.py test coverage (was: zero dedicated tests): new
   tests/test_exports_api.py (26 tests) covering /export, /export/record,
   /export/history, /export/reveal — happy paths, traversal/containment
   guards (incl. symlink escape), destination validation, error mapping,
   and the mp4 watermark-overlay branch with its plain-copy fallback.
   Two real bugs found and fixed in the router:
   - _safe_destination checked isabs() on realpath()'s output, which is
     always absolute — dead check; a relative destination silently exported
     to a cwd-dependent location instead of the documented 400.
   - _safe_source let "." / ".." through the basename guard (caught only
     later by realpath containment as a confusing 404); now 400 up front.

2. CI-Linux fp16 default-dtype leak (test_prefers_vocals_over_mix,
   test_final_dub_track_and_seg_wav_are_watermarked): not reproducible on
   macOS — instrumenting torch.set_default_dtype across both tests records
   zero non-fp32 sets locally. Both tests now carry an opt-in
   torch_dtype_isolation fixture (save/restore, so the leak can never
   spread), and the conftest guard is demoted to pure insurance. A cheap
   permanent recorder wraps torch.set_default_dtype /
   set_default_tensor_type once torch appears and captures the setter's
   stack only on a non-fp32 set; both fixtures print that stack when they
   fire, so the next CI occurrence names the exact culprit call chain.

3. Test-order pollution (both reported combos): root cause was
   collection-time sys.modules stubbing in backend/tests — seven modules
   installed bare ModuleType stubs for core.config (and test_capture_ws.py
   for services.model_manager/asr_backend/ffmpeg_utils, now all lazily
   imported by the router anyway). pytest imports test modules during
   collection, so the stubs leaked process-wide before any test ran:
   - combo (a): monkeypatch.setattr("core.config.OUTPUTS_DIR", ...) in
     test_longform_e2e died with AttributeError (core never gets a .config
     attribute when the import is satisfied straight from sys.modules).
   - combo (b): test_router_smoke's `from main import app` died with
     ImportError: cannot import name 'find_ffmpeg' (unknown location).
   Fix at source: new backend/tests/conftest.py sets a hermetic
   OMNIVOICE_DATA_DIR (mirroring tests/conftest.py, #878) and the real
   core.config is imported everywhere — zero sys.modules surgery. New
   backend/tests/test_no_module_stubs.py guards the whole class (verified
   fail-before/pass-after against the old stub). Stale rationale comments
   in pyproject.toml and ci.yml updated to match.

4. batched_tts.py TODO(#312): investigated, comment corrected only —
   #312 is closed (the live routes are engine-aware); this module has zero
   call sites and stays an unintegrated experiment. See PR notes.

Full tests/ suite: 2796 passed. backend/tests standalone: 130 passed.
Both pollution combos re-run green in the reported orderings.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 23:10:35 +05:30

92 lines
3.5 KiB
Python

"""API tests for durable longform resume — GET /audiobook/jobs + the resume
404 paths. The resume *happy path* drives real synthesis (no model in CI), so it
is covered by the manifest round-trip (tests/test_longform_resume.py) + the
existing render tests, not here.
Mounts only the audiobook router (no ``main`` import) so it runs locally
without the main+torch segfault; conftest.py provides the hermetic data dir.
"""
from __future__ import annotations
import json
import pytest
# conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a
# throwaway tmpdir before this module imports the REAL core.config (the old
# sys.modules stub leaked at collection time and broke mixed runs).
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from core import job_store # noqa: E402
from core.db import init_db # noqa: E402
from services import longform_resume as lr # noqa: E402
from api.routers import audiobook as ab # noqa: E402
init_db()
@pytest.fixture(scope="module")
def client():
app = FastAPI()
app.include_router(ab.router)
return TestClient(app)
def _seed(job_id, job_type, status, *, manifest=True, chapters_done=0, total=3):
job_store.create(job_id, type=job_type)
if status == "running":
job_store.mark_running(job_id)
elif status == "failed":
job_store.mark_failed(job_id, "boom")
elif status == "done":
job_store.mark_done(job_id)
for i in range(chapters_done):
job_store.append_event(job_id, json.dumps({"type": "chapter", "index": i}))
if manifest:
lr.write_manifest(lr.build_manifest(
job_id=job_id, job_type=job_type, title=f"T-{job_id}",
plan_chapters=[{"title": f"C{i}", "spans": [
{"voice_id": "v", "text": "x", "pause_ms_after": 0, "speed": None}]}
for i in range(total)],
params={"default_voice": "v", "fmt": "m4b"}))
def test_jobs_lists_interrupted_with_progress(client):
_seed("run1", "audiobook", "running", chapters_done=2, total=5)
jobs = client.get("/audiobook/jobs").json()["jobs"]
j = next(x for x in jobs if x["job_id"] == "run1")
assert j["type"] == "audiobook" and j["status"] == "running"
assert j["title"] == "T-run1"
assert j["total_chapters"] == 5 and j["chapters_done"] == 2
def test_jobs_includes_failed_with_manifest(client):
_seed("fail1", "story", "failed", total=2)
ids = [x["job_id"] for x in client.get("/audiobook/jobs").json()["jobs"]]
assert "fail1" in ids
def test_jobs_excludes_done_and_manifestless(client):
_seed("done1", "audiobook", "done", manifest=True) # done → manifest cleared in prod; force-clear
lr.clear_manifest("audiobook", "done1")
_seed("run_nomani", "audiobook", "running", manifest=False)
ids = [x["job_id"] for x in client.get("/audiobook/jobs").json()["jobs"]]
assert "done1" not in ids # completed jobs aren't resumable
assert "run_nomani" not in ids # no manifest → can't resume
def test_jobs_excludes_non_longform_types(client):
_seed("dub1", "dub", "running") # a dub job, even with a stray manifest dir
ids = [x["job_id"] for x in client.get("/audiobook/jobs").json()["jobs"]]
assert "dub1" not in ids
def test_resume_unknown_id_404(client):
assert client.post("/audiobook/resume/does-not-exist").status_code == 404
def test_resume_job_without_manifest_404(client):
_seed("nomani2", "audiobook", "running", manifest=False)
assert client.post("/audiobook/resume/nomani2").status_code == 404