maestroai / tests /test_api.py
gabrielpamplonapg
Beta gates: rights attestation, demo top-up cap, 3 signup credits, hash-only reference DB
6596834
Raw
History Blame Contribute Delete
23.6 kB
"""Integration tests for the FastAPI endpoints.
Uses TestClient (synchronous) β€” no live server needed.
All tests use an isolated in-memory SQLite DB via dependency override.
"""
import io
import os
import sys
import json
import uuid
import wave
import struct
import math
import pytest
from fastapi.testclient import TestClient
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from backend.main import app
from tests.conftest import TestingSessionLocal
# conftest.py already sets app.dependency_overrides[get_db]
client = TestClient(app)
# ── Fixtures ───────────────────────────────────────────────────────────────
@pytest.fixture()
def user_token():
"""Register a fresh user (unique email) and return (token, email)."""
email = f"test_{uuid.uuid4().hex[:8]}@example.com"
resp = client.post("/api/auth/register", json={
"email": email,
"password": "testpassword123",
})
assert resp.status_code == 201, resp.text
return resp.json()["access_token"], email
@pytest.fixture()
def auth_headers(user_token):
token, _ = user_token
return {"Authorization": f"Bearer {token}"}
def _make_wav_bytes(freq=440.0, duration=1.0, sr=22050) -> bytes:
"""Generate a minimal valid WAV file in memory."""
n_samples = int(duration * sr)
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sr)
samples = [
int(32767 * 0.5 * math.sin(2 * math.pi * freq * i / sr))
for i in range(n_samples)
]
wf.writeframes(struct.pack(f"<{n_samples}h", *samples))
return buf.getvalue()
# ── Health ─────────────────────────────────────────────────────────────────
def test_health():
resp = client.get("/api/health")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
# ── Auth ───────────────────────────────────────────────────────────────────
def test_register_success():
resp = client.post("/api/auth/register", json={
"email": "new@example.com",
"password": "SecurePass1!",
})
assert resp.status_code == 201
data = resp.json()
assert "access_token" in data
assert data["user"]["email"] == "new@example.com"
assert data["user"]["credits"] == 3
def test_register_duplicate_email():
email = f"dup_{uuid.uuid4().hex[:8]}@example.com"
client.post("/api/auth/register", json={"email": email, "password": "FirstPass1!"})
resp = client.post("/api/auth/register", json={"email": email, "password": "AnotherPass1!"})
assert resp.status_code == 400
assert "already exists" in resp.json()["detail"].lower()
def test_register_short_password():
resp = client.post("/api/auth/register", json={
"email": "short@example.com",
"password": "abc",
})
assert resp.status_code == 422
def test_login_success():
email = f"login_{uuid.uuid4().hex[:8]}@example.com"
client.post("/api/auth/register", json={"email": email, "password": "testpassword123"})
resp = client.post("/api/auth/login", json={"email": email, "password": "testpassword123"})
assert resp.status_code == 200
assert "access_token" in resp.json()
def test_login_wrong_password():
email = f"wp_{uuid.uuid4().hex[:8]}@example.com"
client.post("/api/auth/register", json={"email": email, "password": "testpassword123"})
resp = client.post("/api/auth/login", json={"email": email, "password": "wrongpassword"})
assert resp.status_code == 401
def test_login_unknown_email():
resp = client.post("/api/auth/login", json={
"email": "nobody@example.com",
"password": "doesntmatter",
})
assert resp.status_code == 401
# ── User me ────────────────────────────────────────────────────────────────
def test_get_me(user_token, auth_headers):
_, email = user_token
resp = client.get("/api/user/me", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert data["email"] == email
assert "credits" in data
def test_get_me_unauthenticated():
resp = client.get("/api/user/me")
assert resp.status_code == 401
# ── Analyses list ──────────────────────────────────────────────────────────
def test_list_analyses_empty(auth_headers):
resp = client.get("/api/analyses", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 0
assert data["analyses"] == []
def test_list_analyses_unauthenticated():
resp = client.get("/api/analyses")
assert resp.status_code == 401
# ── Analyze (offline mode β€” no credits consumed) ───────────────────────────
def test_analyze_offline_mode(auth_headers):
wav_bytes = _make_wav_bytes()
resp = client.post(
"/api/analyze?mode=offline&rights_attested=true",
files={"file": ("test.wav", wav_bytes, "audio/wav")},
headers=auth_headers,
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert "result" in data
result = data["result"]
assert "analysis" in result
assert "instruments" in result["analysis"]
assert isinstance(result["analysis"]["instruments"], list)
assert result.get("offline") is True
def test_analyze_offline_does_not_consume_credit(auth_headers):
# Get initial credits
me_before = client.get("/api/user/me", headers=auth_headers).json()
credits_before = me_before["credits"]
wav_bytes = _make_wav_bytes()
client.post(
"/api/analyze?mode=offline&rights_attested=true",
files={"file": ("test.wav", wav_bytes, "audio/wav")},
headers=auth_headers,
)
me_after = client.get("/api/user/me", headers=auth_headers).json()
assert me_after["credits"] == credits_before # no credit deducted
def test_analyze_offline_creates_history_entry(auth_headers):
wav_bytes = _make_wav_bytes()
client.post(
"/api/analyze?mode=offline&rights_attested=true",
files={"file": ("mysong.wav", wav_bytes, "audio/wav")},
headers=auth_headers,
)
resp = client.get("/api/analyses", headers=auth_headers)
assert resp.status_code == 200
items = resp.json()["analyses"]
assert len(items) >= 1
filenames = [i["filename"] for i in items]
assert "mysong.wav" in filenames
def test_analyze_unsupported_format(auth_headers):
resp = client.post(
"/api/analyze?mode=offline&rights_attested=true",
files={"file": ("audio.xyz", b"not audio", "application/octet-stream")},
headers=auth_headers,
)
assert resp.status_code == 400
assert "Unsupported" in resp.json()["detail"]
def test_analyze_unauthenticated():
wav_bytes = _make_wav_bytes()
resp = client.post(
"/api/analyze?mode=offline&rights_attested=true",
files={"file": ("test.wav", wav_bytes, "audio/wav")},
)
assert resp.status_code == 401
# ── AI mode credit check ───────────────────────────────────────────────────
def test_analyze_ai_mode_requires_credit():
"""User with 0 credits and mode=auto gets 402."""
email = f"nc_{uuid.uuid4().hex[:8]}@example.com"
reg = client.post("/api/auth/register", json={"email": email, "password": "testpassword123"})
token = reg.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# Directly zero out credits via the test DB session
db = TestingSessionLocal()
from backend.models import User as UserModel
user = db.query(UserModel).filter(UserModel.email == email).first()
user.credits = 0
db.commit()
db.close()
wav_bytes = _make_wav_bytes()
resp = client.post(
"/api/analyze?mode=auto&rights_attested=true",
files={"file": ("test.wav", wav_bytes, "audio/wav")},
headers=headers,
)
assert resp.status_code == 402
assert "credits" in resp.json()["detail"].lower()
def test_analyze_auto_fallback_to_offline_does_not_consume_credit():
"""mode=auto with Claude unavailable (no API key in the test env) degrades
to the offline classifier β€” the user must NOT be billed for that result."""
email = f"fb_{uuid.uuid4().hex[:8]}@example.com"
reg = client.post("/api/auth/register", json={"email": email, "password": "testpassword123"})
token = reg.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
credits_before = client.get("/api/user/me", headers=headers).json()["credits"]
assert credits_before >= 1 # must clear the 402 gate so the charge path is reachable
wav_bytes = _make_wav_bytes()
resp = client.post(
"/api/analyze?mode=auto&rights_attested=true",
files={"file": ("test.wav", wav_bytes, "audio/wav")},
headers=headers,
)
assert resp.status_code == 200, resp.text
assert resp.json()["result"].get("offline") is True # degraded run
credits_after = client.get("/api/user/me", headers=headers).json()["credits"]
assert credits_after == credits_before
# ── Confirm / correct ──────────────────────────────────────────────────────
def test_confirm_analysis(auth_headers):
wav_bytes = _make_wav_bytes()
analyze_resp = client.post(
"/api/analyze?mode=offline&rights_attested=true",
files={"file": ("test.wav", wav_bytes, "audio/wav")},
headers=auth_headers,
)
analysis_id = analyze_resp.json()["analysis_id"]
confirm_resp = client.post(
f"/api/analyses/{analysis_id}/confirm",
json={"confirmed_instruments": ["Fender Precision Bass"], "record_id": ""},
headers=auth_headers,
)
assert confirm_resp.status_code == 200
assert confirm_resp.json()["status"] == "confirmed"
def test_correct_analysis(auth_headers):
wav_bytes = _make_wav_bytes()
analyze_resp = client.post(
"/api/analyze?mode=offline&rights_attested=true",
files={"file": ("test.wav", wav_bytes, "audio/wav")},
headers=auth_headers,
)
analysis_id = analyze_resp.json()["analysis_id"]
correct_resp = client.post(
f"/api/analyses/{analysis_id}/correct",
json={
"corrections": [{"original_model": "Unknown", "correct_model": "Gibson Les Paul"}],
"record_id": "",
},
headers=auth_headers,
)
assert correct_resp.status_code == 200
assert correct_resp.json()["status"] == "corrected"
def test_confirm_wrong_user(auth_headers):
"""User B cannot confirm user A's analysis."""
wav_bytes = _make_wav_bytes()
analyze_resp = client.post(
"/api/analyze?mode=offline&rights_attested=true",
files={"file": ("test.wav", wav_bytes, "audio/wav")},
headers=auth_headers,
)
analysis_id = analyze_resp.json()["analysis_id"]
# Register user B
reg_b = client.post("/api/auth/register", json={
"email": "userb@example.com",
"password": "passwordb123",
})
token_b = reg_b.json()["access_token"]
headers_b = {"Authorization": f"Bearer {token_b}"}
resp = client.post(
f"/api/analyses/{analysis_id}/confirm",
json={"confirmed_instruments": ["Guitar"], "record_id": ""},
headers=headers_b,
)
assert resp.status_code == 404
# ── Training stats ─────────────────────────────────────────────────────────
def test_training_stats(auth_headers):
resp = client.get("/api/training/stats", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
for key in ("total_records", "gold_records", "silver_records"):
assert key in data
# ── Bulk analysis ───────────────────────────────────────────────────────────
def test_bulk_analyze_two_files(auth_headers):
wav1 = _make_wav_bytes(freq=440.0)
wav2 = _make_wav_bytes(freq=880.0)
resp = client.post(
"/api/analyze/bulk?mode=offline&rights_attested=true",
files=[
("files", ("a.wav", wav1, "audio/wav")),
("files", ("b.wav", wav2, "audio/wav")),
],
headers=auth_headers,
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["total"] == 2
assert data["ok"] == 2
assert data["credits_used"] == 0 # offline mode
assert len(data["results"]) == 2
for r in data["results"]:
assert r["status"] == "ok"
assert "result" in r
def test_bulk_analyze_unauthenticated():
wav = _make_wav_bytes()
resp = client.post(
"/api/analyze/bulk?mode=offline&rights_attested=true",
files=[("files", ("a.wav", wav, "audio/wav"))],
)
assert resp.status_code == 401
def test_bulk_analyze_too_many_files(auth_headers):
files = [("files", (f"f{i}.wav", _make_wav_bytes(), "audio/wav")) for i in range(11)]
resp = client.post("/api/analyze/bulk?mode=offline&rights_attested=true", files=files, headers=auth_headers)
assert resp.status_code == 400
assert "10" in resp.json()["detail"]
def test_bulk_analyze_skips_bad_format(auth_headers):
files = [
("files", ("good.wav", _make_wav_bytes(), "audio/wav")),
("files", ("bad.xyz", b"not audio", "application/octet-stream")),
]
resp = client.post("/api/analyze/bulk?mode=offline&rights_attested=true", files=files, headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
assert data["ok"] == 1
statuses = {r["filename"]: r["status"] for r in data["results"]}
assert statuses["good.wav"] == "ok"
assert statuses["bad.xyz"] == "skipped"
def test_bulk_analyze_creates_history_entries(auth_headers):
wav1 = _make_wav_bytes(freq=300.0)
wav2 = _make_wav_bytes(freq=600.0)
client.post(
"/api/analyze/bulk?mode=offline&rights_attested=true",
files=[
("files", ("bulk1.wav", wav1, "audio/wav")),
("files", ("bulk2.wav", wav2, "audio/wav")),
],
headers=auth_headers,
)
resp = client.get("/api/analyses", headers=auth_headers)
filenames = [i["filename"] for i in resp.json()["analyses"]]
assert "bulk1.wav" in filenames
assert "bulk2.wav" in filenames
# ── Async job processing ────────────────────────────────────────────────────
def test_async_analyze_returns_202(auth_headers):
"""POSTing with async_mode=true immediately returns HTTP 202 with a job_id."""
wav_bytes = _make_wav_bytes()
resp = client.post(
"/api/analyze?mode=offline&async_mode=true&rights_attested=true",
files={"file": ("async_test.wav", wav_bytes, "audio/wav")},
headers=auth_headers,
)
assert resp.status_code == 202, resp.text
data = resp.json()
assert "job_id" in data
assert data["status"] == "pending"
def test_async_job_status_pending(auth_headers):
"""GET /api/analyze/jobs/{job_id} returns a valid job object."""
wav_bytes = _make_wav_bytes()
post_resp = client.post(
"/api/analyze?mode=offline&async_mode=true&rights_attested=true",
files={"file": ("status_test.wav", wav_bytes, "audio/wav")},
headers=auth_headers,
)
assert post_resp.status_code == 202, post_resp.text
job_id = post_resp.json()["job_id"]
# TestClient runs background tasks synchronously so the job may already be done,
# but the job must exist and have a valid status.
status_resp = client.get(f"/api/analyze/jobs/{job_id}", headers=auth_headers)
assert status_resp.status_code == 200, status_resp.text
data = status_resp.json()
assert data["job_id"] == job_id
assert data["status"] in ("pending", "processing", "done", "failed")
assert data["filename"] == "status_test.wav"
def test_async_job_completes(auth_headers):
"""Background task runs and job reaches 'done' status with a full result."""
wav_bytes = _make_wav_bytes()
post_resp = client.post(
"/api/analyze?mode=offline&async_mode=true&rights_attested=true",
files={"file": ("complete_test.wav", wav_bytes, "audio/wav")},
headers=auth_headers,
)
assert post_resp.status_code == 202, post_resp.text
job_id = post_resp.json()["job_id"]
# TestClient executes background tasks before returning, so the job should
# be done immediately. Poll up to 60 s just in case of slow environments.
import time
deadline = time.time() + 60
while time.time() < deadline:
status_resp = client.get(f"/api/analyze/jobs/{job_id}", headers=auth_headers)
assert status_resp.status_code == 200
job_data = status_resp.json()
if job_data["status"] in ("done", "failed"):
break
time.sleep(0.5)
assert job_data["status"] == "done", f"Job ended with status: {job_data['status']}"
assert "result" in job_data
assert "analysis" in job_data["result"]
assert "analysis_id" in job_data
def test_async_job_wrong_user_404(auth_headers):
"""A different user cannot access another user's job."""
wav_bytes = _make_wav_bytes()
post_resp = client.post(
"/api/analyze?mode=offline&async_mode=true&rights_attested=true",
files={"file": ("private_test.wav", wav_bytes, "audio/wav")},
headers=auth_headers,
)
assert post_resp.status_code == 202, post_resp.text
job_id = post_resp.json()["job_id"]
# Register a second user
other_email = f"other_{uuid.uuid4().hex[:8]}@example.com"
reg = client.post("/api/auth/register", json={
"email": other_email,
"password": "otherpassword123",
})
other_token = reg.json()["access_token"]
other_headers = {"Authorization": f"Bearer {other_token}"}
resp = client.get(f"/api/analyze/jobs/{job_id}", headers=other_headers)
assert resp.status_code == 404
# ── Pagination improvements ───────────────────────────────────────────────────
def test_list_analyses_pagination_fields(auth_headers):
"""GET /api/analyses returns total, page, per_page, pages fields."""
resp = client.get("/api/analyses", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert "analyses" in data
assert "total" in data
assert "page" in data
assert "per_page" in data
assert "pages" in data
def test_list_analyses_total_is_integer(auth_headers):
"""total field in /api/analyses should be an integer."""
resp = client.get("/api/analyses", headers=auth_headers)
assert resp.status_code == 200
assert isinstance(resp.json()["total"], int)
def test_list_analyses_pages_calculated_correctly(auth_headers):
"""pages = ceil(total / per_page), minimum 1."""
resp = client.get("/api/analyses?per_page=20", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
total = data["total"]
per_page = data["per_page"]
expected_pages = max(1, -(-total // per_page)) # ceiling division
assert data["pages"] == expected_pages
def test_list_analyses_after_creating_one(auth_headers):
"""total increments after posting an analysis."""
wav_bytes = _make_wav_bytes()
post_resp = client.post(
"/api/analyze?mode=offline&rights_attested=true",
files={"file": ("pagination_test.wav", wav_bytes, "audio/wav")},
headers=auth_headers,
)
assert post_resp.status_code == 200, post_resp.text
resp = client.get("/api/analyses", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert data["total"] >= 1
assert len(data["analyses"]) >= 1
# ── Instrument filter ─────────────────────────────────────────────────────────
def test_instrument_filter_no_match(auth_headers):
"""?instrument=zzznomatch returns empty analyses list."""
resp = client.get("/api/analyses?instrument=zzznomatch", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert data["analyses"] == []
assert data["total"] == 0
def test_instrument_filter_returns_pagination_fields(auth_headers):
"""Instrument filter still returns all pagination fields."""
resp = client.get("/api/analyses?instrument=guitar", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
for field in ("analyses", "total", "page", "per_page", "pages"):
assert field in data, f"Missing field: {field}"
def test_instrument_filter_case_insensitive(auth_headers):
"""Instrument filter should work regardless of case."""
# Both should return the same result (neither crashes)
r1 = client.get("/api/analyses?instrument=Guitar", headers=auth_headers)
r2 = client.get("/api/analyses?instrument=guitar", headers=auth_headers)
assert r1.status_code == 200
assert r2.status_code == 200
# totals should be the same regardless of case
assert r1.json()["total"] == r2.json()["total"]
# ── Admin stats caching ───────────────────────────────────────────────────────
def test_admin_stats_cached_response(user_token):
"""Calling GET /api/admin/stats twice returns consistent results (second from cache)."""
token, email = user_token
# Promote to admin
db = TestingSessionLocal()
from backend.models import User as UserModel
from backend.cache import stats_cache
user = db.query(UserModel).filter(UserModel.email == email).first()
user.is_admin = True
db.commit()
db.close()
# Clear cache before test to avoid pollution from other tests
stats_cache.clear()
headers = {"Authorization": f"Bearer {token}"}
resp1 = client.get("/api/admin/stats", headers=headers)
assert resp1.status_code == 200
resp2 = client.get("/api/admin/stats", headers=headers)
assert resp2.status_code == 200
# Both responses should have the same content (second is from cache)
assert resp1.json()["total_users"] == resp2.json()["total_users"]
assert resp1.json()["total_analyses"] == resp2.json()["total_analyses"]
# Cache should now have the entry
cached = stats_cache.get("admin_stats")
assert cached is not None
assert "total_users" in cached