"""Tests for /api/process/audio endpoint.""" from __future__ import annotations import io import json import pytest from fastapi.testclient import TestClient from app.routes.data_processing import _process_rate_store @pytest.fixture(autouse=True) def _reset_rate_limiter(): """The rate limiter's store is module-level and shared across every test in this file (10 requests/60s per IP, and TestClient always uses the same fake client IP) — without resetting it, tests that pass in isolation start failing with 429 once enough tests run before them in the same process.""" _process_rate_store.clear() yield _process_rate_store.clear() def _valid_options(**overrides: object) -> str: """Return a valid JSON options string with optional overrides.""" defaults = { "pitchShift": False, "speedChange": False, "bassBoost": False, "trimSilence": False, "mixAudio": False, "addNoise": False, } defaults.update(overrides) return json.dumps(defaults) def _fake_audio(content: bytes = b"\x00" * 1024) -> io.BytesIO: return io.BytesIO(content) def test_audio_valid_request_accepted(client: TestClient) -> None: """Valid audio file + valid options should not return 422.""" response = client.post( "/api/process/audio", data={"options": _valid_options()}, files={"file": ("test.wav", _fake_audio(), "audio/wav")}, ) # Should be processed (200) or a processing error (400/500) — never 422 assert response.status_code != 422 def test_audio_rejects_non_audio(client: TestClient) -> None: """Should reject non-audio content type with 400.""" response = client.post( "/api/process/audio", data={"options": _valid_options()}, files={"file": ("test.txt", _fake_audio(), "text/plain")}, ) assert response.status_code == 400 detail = response.json()["detail"] assert detail["code"] == "invalid_file_type" def test_audio_rejects_missing_content_type(client: TestClient) -> None: """Should reject file with empty content type as invalid.""" response = client.post( "/api/process/audio", data={"options": _valid_options()}, files={"file": ("test.bin", _fake_audio(), "")}, ) assert response.status_code == 400 detail = response.json()["detail"] assert detail["code"] == "invalid_file_type" def test_audio_rejects_missing_file(client: TestClient) -> None: """Should reject request without file.""" response = client.post( "/api/process/audio", data={"options": _valid_options()}, ) assert response.status_code == 422 def test_audio_rejects_invalid_options_json(client: TestClient) -> None: """Should reject malformed JSON in options with 422 + invalid_options code.""" response = client.post( "/api/process/audio", data={"options": "not-valid-json"}, files={"file": ("test.wav", _fake_audio(), "audio/wav")}, ) assert response.status_code == 422 detail = response.json()["detail"] assert detail["code"] == "invalid_options" def test_audio_accepts_camel_case_options(client: TestClient) -> None: """Frontend sends camelCase keys — should be accepted.""" response = client.post( "/api/process/audio", data={"options": _valid_options(pitchShift=True, addNoise=True)}, files={"file": ("test.wav", _fake_audio(), "audio/wav")}, ) assert response.status_code != 422 def test_audio_defaults_when_options_missing(client: TestClient) -> None: """Missing options field should default to all-off, not 422.""" response = client.post( "/api/process/audio", files={"file": ("test.wav", _fake_audio(), "audio/wav")}, ) # Should proceed to processing (200) or processing error — never 422 assert response.status_code != 422 def _real_tone_wav(freq: float = 440.0, duration: float = 1.0, sr: int = 22050) -> io.BytesIO: """A real sine wave WAV — librosa/soundfile reject an all-zero buffer as silence, so mix-audio tests need genuine audio content.""" import numpy as np import soundfile as sf t = np.linspace(0, duration, int(sr * duration)) y = (0.3 * np.sin(2 * np.pi * freq * t)).astype(np.float32) buf = io.BytesIO() sf.write(buf, y, sr, format="WAV") buf.seek(0) return buf def test_audio_mix_requires_second_file_when_enabled(client: TestClient) -> None: """mixAudio: true without a mix_file must 400, not silently ignore it.""" response = client.post( "/api/process/audio", data={"options": _valid_options(mixAudio=True)}, files={"file": ("a.wav", _real_tone_wav(440), "audio/wav")}, ) assert response.status_code == 400 detail = response.json()["detail"] assert detail["code"] == "missing_mix_file" def test_audio_mix_rejects_non_audio_second_file(client: TestClient) -> None: response = client.post( "/api/process/audio", data={"options": _valid_options(mixAudio=True)}, files={ "file": ("a.wav", _real_tone_wav(440), "audio/wav"), "mix_file": ("b.txt", io.BytesIO(b"not audio"), "text/plain"), }, ) assert response.status_code == 400 detail = response.json()["detail"] assert detail["code"] == "invalid_file_type" def test_audio_mix_succeeds_and_blends_both_tracks(client: TestClient) -> None: """End-to-end: two distinct real tones, mixAudio on, verify the output actually contains spectral energy from BOTH sources.""" import numpy as np import librosa response = client.post( "/api/process/audio", data={"options": _valid_options(mixAudio=True)}, files={ "file": ("a.wav", _real_tone_wav(440), "audio/wav"), "mix_file": ("b.wav", _real_tone_wav(880), "audio/wav"), }, ) assert response.status_code == 200 y, sr = librosa.load(io.BytesIO(response.content), sr=22050) fft = np.abs(np.fft.rfft(y)) freqs = np.fft.rfftfreq(len(y), 1 / sr) energy_440 = fft[(freqs > 430) & (freqs < 450)].max() energy_880 = fft[(freqs > 870) & (freqs < 890)].max() assert energy_440 > 5 assert energy_880 > 5 def test_audio_rejects_oversized_file_with_413_not_500(client: TestClient) -> None: """Regression test: the 413 raised inside the read loop's try block was being caught by the bare `except Exception` below it (HTTPException IS an Exception) and replaced with a misleading 500. A file over the 30MB cap must surface as 413 file_too_large, not 500 internal_error.""" oversized = _fake_audio(b"\x00" * (31 * 1024 * 1024)) response = client.post( "/api/process/audio", data={"options": _valid_options()}, files={"file": ("big.wav", oversized, "audio/wav")}, ) assert response.status_code == 413 detail = response.json()["detail"] assert detail["code"] == "file_too_large" # ─────────────────────────── /api/process/audio/convert ─────────────────────────── def _valid_convert_options(**overrides: object) -> str: defaults = {"targetFormat": "wav", "bitrateKbps": 192} defaults.update(overrides) return json.dumps(defaults) def test_convert_valid_request_accepted(client: TestClient) -> None: """Valid audio file + valid convert options should not return 422.""" response = client.post( "/api/process/audio/convert", data={"options": _valid_convert_options()}, files={"file": ("test.wav", _fake_audio(), "audio/wav")}, ) assert response.status_code != 422 def test_convert_rejects_non_audio(client: TestClient) -> None: response = client.post( "/api/process/audio/convert", data={"options": _valid_convert_options()}, files={"file": ("test.txt", _fake_audio(), "text/plain")}, ) assert response.status_code == 400 detail = response.json()["detail"] assert detail["code"] == "invalid_file_type" def test_convert_rejects_invalid_target_format(client: TestClient) -> None: """targetFormat outside the wav/mp3/flac/ogg enum should 422.""" response = client.post( "/api/process/audio/convert", data={"options": _valid_convert_options(targetFormat="exe")}, files={"file": ("test.wav", _fake_audio(), "audio/wav")}, ) assert response.status_code == 422 detail = response.json()["detail"] assert detail["code"] == "invalid_options" def test_convert_rejects_bitrate_out_of_range(client: TestClient) -> None: response = client.post( "/api/process/audio/convert", data={"options": _valid_convert_options(bitrateKbps=999)}, files={"file": ("test.wav", _fake_audio(), "audio/wav")}, ) assert response.status_code == 422 detail = response.json()["detail"] assert detail["code"] == "invalid_options" def test_convert_defaults_when_options_missing(client: TestClient) -> None: """Missing options should default to wav @ 192kbps, not 422.""" response = client.post( "/api/process/audio/convert", files={"file": ("test.wav", _fake_audio(), "audio/wav")}, ) assert response.status_code != 422 def test_convert_rejects_oversized_file_with_413_not_500(client: TestClient) -> None: """Same regression as the /audio endpoint: 413 must not become 500.""" oversized = _fake_audio(b"\x00" * (31 * 1024 * 1024)) response = client.post( "/api/process/audio/convert", data={"options": _valid_convert_options()}, files={"file": ("big.wav", oversized, "audio/wav")}, ) assert response.status_code == 413 detail = response.json()["detail"] assert detail["code"] == "file_too_large" # ─────────────────────────── /api/process/audio/organize ─────────────────────────── def test_organize_valid_request_accepted(client: TestClient) -> None: response = client.post( "/api/process/audio/organize", files={"file": ("test.wav", _fake_audio(), "audio/wav")}, ) # A silent all-zero WAV is a legitimate 400 (analyzer rejects silence) — # never 422 (that's the "malformed request" status, not "bad content"). assert response.status_code != 422 def test_organize_rejects_non_audio(client: TestClient) -> None: response = client.post( "/api/process/audio/organize", files={"file": ("test.txt", _fake_audio(), "text/plain")}, ) assert response.status_code == 400 detail = response.json()["detail"] assert detail["code"] == "invalid_file_type" def test_organize_rejects_missing_file(client: TestClient) -> None: response = client.post("/api/process/audio/organize") assert response.status_code == 422 def test_organize_rejects_oversized_file_with_413(client: TestClient) -> None: oversized = _fake_audio(b"\x00" * (31 * 1024 * 1024)) response = client.post( "/api/process/audio/organize", files={"file": ("big.wav", oversized, "audio/wav")}, ) assert response.status_code == 413 detail = response.json()["detail"] assert detail["code"] == "file_too_large" def test_organize_returns_real_metadata_for_real_audio(client: TestClient) -> None: """End-to-end with an actual sine wave — not a silent/zero fixture — to prove the analyzer runs and returns genuine acoustic metadata.""" import io as _io import numpy as _np import soundfile as _sf sr = 22050 t = _np.linspace(0, 2, sr * 2) y = (0.4 * _np.sin(2 * _np.pi * 440 * t)).astype(_np.float32) buf = _io.BytesIO() _sf.write(buf, y, sr, format="WAV") buf.seek(0) response = client.post( "/api/process/audio/organize", files={"file": ("tone.wav", buf, "audio/wav")}, ) assert response.status_code == 200 body = response.json() assert body["durationSec"] == pytest.approx(2.0, abs=0.1) assert isinstance(body["tempoBpm"], (int, float)) assert "major" in body["key"] or "minor" in body["key"] assert isinstance(body["tags"], list) and len(body["tags"]) > 0