File size: 12,328 Bytes
8bcdcab
 
 
 
 
2ccad2a
8bcdcab
2eb5dd7
8bcdcab
 
2eb5dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
8bcdcab
2ccad2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8bcdcab
2ccad2a
8bcdcab
 
2ccad2a
 
8bcdcab
2ccad2a
 
 
8bcdcab
 
 
2ccad2a
8bcdcab
 
2ccad2a
 
8bcdcab
2ccad2a
 
 
8bcdcab
 
 
 
 
 
2ccad2a
 
 
 
 
 
 
 
 
 
 
8bcdcab
 
2ccad2a
 
 
 
 
 
 
 
 
 
 
 
8ea6326
 
 
 
 
 
 
 
 
 
2eb5dd7
 
405f6d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2eb5dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
"""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