"""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