- conftest.py: isolated async test fixtures with temp DB and directories - test_waveform.py: waveform generation, downsampling, caching, SVG output - test_api.py: full API endpoint tests (health, models, clips CRUD, waveform endpoint, admin cache clearing) - test_analysis.py: BPM detection, key detection, clip analysis - test_models.py: request validation, model registry, prompt building - Fixed off-by-one in waveform.py downsampling (linspace upper bound) - All 40 tests passing
139 lines
4.2 KiB
Python
139 lines
4.2 KiB
Python
"""Shared test fixtures for Lyria Studio backend."""
|
|
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
# ── Path setup: ensure src/ is importable ──────────────────────
|
|
BACKEND_SRC = Path(__file__).resolve().parent.parent / "src"
|
|
sys.path.insert(0, str(BACKEND_SRC))
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def event_loop():
|
|
"""Create a single event loop for the entire test session."""
|
|
loop = asyncio.new_event_loop()
|
|
yield loop
|
|
loop.close()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def app_client(tmp_path):
|
|
"""Provide an httpx AsyncClient wired to the FastAPI app
|
|
with a fresh temp database and data directories.
|
|
|
|
Each test gets its own isolated DB and file directories.
|
|
"""
|
|
|
|
# Create temp data directories
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
clips_dir = data_dir / "clips"
|
|
clips_dir.mkdir()
|
|
waveforms_dir = data_dir / "waveforms"
|
|
waveforms_dir.mkdir()
|
|
artwork_dir = data_dir / "artwork"
|
|
artwork_dir.mkdir()
|
|
test_db = data_dir / "lyria.db"
|
|
|
|
# Set env vars BEFORE import so module-level Path() calls use test dirs
|
|
# (image_gen.py reads ARTWORK_DIR from env, main.py uses __file__-relative paths)
|
|
os.environ["ARTWORK_DIR"] = str(artwork_dir)
|
|
|
|
# Import modules AFTER setting env vars
|
|
import image_gen as ig_mod
|
|
import database as db_mod
|
|
import waveform as wf_mod
|
|
import main
|
|
|
|
# Save originals for restoration
|
|
orig_db_path = db_mod.DB_PATH
|
|
orig_clips_dir = main.CLIPS_DIR
|
|
orig_artwork_dir = main.ARTWORK_DIR
|
|
orig_wf_clips = wf_mod.CLIPS_DIR
|
|
orig_wf_wave = wf_mod.WAVEFORM_DIR
|
|
orig_ig_artwork = ig_mod.ARTWORK_DIR
|
|
|
|
# Patch module-level path constants to test directories
|
|
db_mod.DB_PATH = test_db
|
|
main.CLIPS_DIR = clips_dir
|
|
main.ARTWORK_DIR = artwork_dir
|
|
wf_mod.CLIPS_DIR = clips_dir
|
|
wf_mod.WAVEFORM_DIR = waveforms_dir
|
|
ig_mod.ARTWORK_DIR = artwork_dir
|
|
|
|
# Initialize fresh database
|
|
await db_mod.init_db()
|
|
|
|
transport = ASGITransport(app=main.app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield {
|
|
"client": c,
|
|
"db": test_db,
|
|
"clips_dir": clips_dir,
|
|
"waveforms_dir": waveforms_dir,
|
|
"artwork_dir": artwork_dir,
|
|
"data_dir": data_dir,
|
|
}
|
|
|
|
# Restore originals
|
|
db_mod.DB_PATH = orig_db_path
|
|
main.CLIPS_DIR = orig_clips_dir
|
|
main.ARTWORK_DIR = orig_artwork_dir
|
|
wf_mod.CLIPS_DIR = orig_wf_clips
|
|
wf_mod.WAVEFORM_DIR = orig_wf_wave
|
|
ig_mod.ARTWORK_DIR = orig_ig_artwork
|
|
|
|
# Clean up env var
|
|
del os.environ["ARTWORK_DIR"]
|
|
|
|
|
|
# ── Helper: create a clip record + dummy MP3 ───────────────────
|
|
|
|
async def seed_test_clip(app_client, prompt="test prompt", model="lyria-3-clip-preview", duration=30):
|
|
"""Create a clip record in the test DB with a dummy MP3 file."""
|
|
import aiosqlite
|
|
from database import create_clip
|
|
|
|
clip_id = await create_clip(prompt, model, duration)
|
|
|
|
filename = f"{clip_id}.mp3"
|
|
mp3_path = app_client["clips_dir"] / filename
|
|
mp3_path.write_bytes(b"\xff\xfb\x90\x00" + b"\x00" * 4096)
|
|
|
|
async with aiosqlite.connect(app_client["db"]) as db:
|
|
await db.execute(
|
|
"UPDATE clips SET status = ?, filename = ? WHERE id = ?",
|
|
("ready", filename, clip_id),
|
|
)
|
|
await db.commit()
|
|
|
|
return clip_id
|
|
|
|
|
|
# ── Helper: create a real (ffmpeg-generated) MP3 ────────────────
|
|
|
|
def create_real_mp3(output_path, duration=2):
|
|
"""Generate a real MP3 file using ffmpeg.
|
|
|
|
Returns True if successful, False if ffmpeg is unavailable.
|
|
"""
|
|
import subprocess
|
|
try:
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg", "-y", "-f", "lavfi",
|
|
"-i", f"anullsrc=r=22050:cl=mono",
|
|
"-t", str(duration), "-q:a", "9",
|
|
str(output_path)
|
|
],
|
|
capture_output=True, check=True,
|
|
)
|
|
return True
|
|
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
return False |