Spaces:
Sleeping
Sleeping
Download tests/test_data_processing.py from Rthur2003/crowncode-backend: direct link, hf CLI and curl.
- Browser
- Download file 12.3 kB
-
https://huggingface.co/spaces/Rthur2003/crowncode-backend/resolve/main/tests/test_data_processing.py
- Command line
-
hf download hf://spaces/Rthur2003/crowncode-backend/tests/test_data_processing.py
-
curl -L -o test_data_processing.py https://huggingface.co/spaces/Rthur2003/crowncode-backend/resolve/main/tests/test_data_processing.py
12.3 kB
| """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 | |
| 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 | |