gabrielpamplonapg
Beta gates: rights attestation, demo top-up cap, 3 signup credits, hash-only reference DB
6596834 | """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 βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| 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 | |