crowncode-backend / tests /test_data_processing.py
Rthur2003's picture
test: integration tests için audio processing conversion ve organization uç noktaları eklendi
405f6d3
Raw History Blame Contribute Delete
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
@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