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>
92 lines
3.5 KiB
Python
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
|