crowncode-backend / tests /test_analyze.py
Rthur2003's picture
feat: aI analysis servisleri routes detectors ve test suites eklendi
06b738c
Raw History Blame Contribute Delete
8.67 kB
"""Tests for /api/analyze endpoint."""
from __future__ import annotations
import io
import struct
import math
from fastapi.testclient import TestClient
def test_analyze_missing_source_type(
client: TestClient,
) -> None:
"""Reject request without sourceType."""
response = client.post("/api/analyze")
assert response.status_code == 422
def test_analyze_invalid_source_type(
client: TestClient,
) -> None:
"""Return error for unknown sourceType."""
response = client.post(
"/api/analyze",
data={"sourceType": "unknown"},
)
assert response.status_code == 200
data = response.json()
assert "invalid_source_type" in data["errors"]
assert data["result"] is None
def test_analyze_unsupported_source(
client: TestClient,
) -> None:
"""Return unsupported_source for spotify/apple."""
for source in ("spotify", "apple"):
response = client.post(
"/api/analyze",
data={"sourceType": source},
)
assert response.status_code == 200
data = response.json()
assert "unsupported_source" in data["errors"]
def test_analyze_youtube_missing_url(
client: TestClient,
) -> None:
"""YouTube without url returns missing_url."""
response = client.post(
"/api/analyze",
data={"sourceType": "youtube"},
)
assert response.status_code == 200
data = response.json()
assert "missing_url" in data["errors"]
def test_analyze_file_missing_file(
client: TestClient,
) -> None:
"""File without upload returns missing_file."""
response = client.post(
"/api/analyze",
data={"sourceType": "file"},
)
assert response.status_code == 200
data = response.json()
assert "missing_file" in data["errors"]
def test_analyze_file_invalid_type(
client: TestClient,
) -> None:
"""Reject non-audio file."""
fake_file = io.BytesIO(b"not audio content " * 100)
response = client.post(
"/api/analyze",
data={"sourceType": "file"},
files={
"file": ("test.txt", fake_file, "text/plain"),
},
)
assert response.status_code == 200
data = response.json()
assert "invalid_file_type" in data["errors"]
def test_analyze_file_too_small(
client: TestClient,
) -> None:
"""Reject files smaller than 1KB."""
tiny_file = io.BytesIO(b"\x00" * 100)
response = client.post(
"/api/analyze",
data={"sourceType": "file"},
files={
"file": ("tiny.wav", tiny_file, "audio/wav"),
},
)
assert response.status_code == 200
data = response.json()
assert "file_too_small" in data["errors"]
def test_analyze_file_bad_audio_is_an_error(
client: TestClient,
) -> None:
"""Undecodable audio returns an error, never an invented result."""
audio_content = b"\x00" * 2048
fake_audio = io.BytesIO(audio_content)
response = client.post(
"/api/analyze",
data={"sourceType": "file"},
files={
"file": ("test.wav", fake_audio, "audio/wav"),
},
)
assert response.status_code == 200
data = response.json()
assert data["result"] is None
assert data["errors"][0] in {"audio_decode_failed", "audio_silent", "audio_too_short"}
def test_analyze_accepts_audio_extension_with_video_mime(
client: TestClient,
) -> None:
"""A .webm labelled video/webm by the browser is audio, not an invalid type."""
response = client.post(
"/api/analyze",
data={"sourceType": "file"},
files={
"file": ("take.webm", io.BytesIO(b"\x1a" * 100), "video/webm"),
},
)
assert response.status_code == 200
data = response.json()
assert data["errors"] == ["file_too_small"]
def test_jobs_rejects_invalid_source_immediately(
client: TestClient,
) -> None:
"""Validation errors come back at once, without starting a job."""
response = client.post(
"/api/analyze/jobs",
data={"sourceType": "unknown"},
)
assert response.status_code == 200
data = response.json()
assert data["jobId"] is None
assert data["status"] == "error"
assert "invalid_source_type" in data["response"]["errors"]
def test_jobs_missing_url(
client: TestClient,
) -> None:
response = client.post(
"/api/analyze/jobs",
data={"sourceType": "youtube"},
)
assert response.status_code == 200
assert "missing_url" in response.json()["response"]["errors"]
def test_jobs_unknown_id_is_404(
client: TestClient,
) -> None:
response = client.get("/api/analyze/jobs/does-not-exist")
assert response.status_code == 404
assert response.json()["detail"]["code"] == "job_not_found"
def test_rate_limit_key_uses_forwarded_client() -> None:
"""Behind the Space proxy each visitor gets their own bucket."""
from starlette.requests import Request
from app.routes.analyze import _client_ip
def make(headers):
return Request({
"type": "http",
"headers": [(k.encode(), v.encode()) for k, v in headers.items()],
"client": ("10.0.0.1", 1234),
})
assert _client_ip(make({"x-forwarded-for": "203.0.113.7, 10.0.0.1"})) == "203.0.113.7"
assert _client_ip(make({})) == "10.0.0.1"
def _make_wav(
duration_sec: float = 3.0,
freq_hz: float = 440.0,
sample_rate: int = 22050,
) -> bytes:
"""
Generate a minimal valid WAV file with a sine tone.
Returns raw bytes of a valid WAV.
"""
n_samples = int(sample_rate * duration_sec)
samples = []
for i in range(n_samples):
t = i / sample_rate
val = math.sin(2 * math.pi * freq_hz * t)
samples.append(int(val * 32767))
# WAV header (16-bit mono PCM)
data_size = n_samples * 2
buf = io.BytesIO()
# RIFF header
buf.write(b"RIFF")
buf.write(struct.pack("<I", 36 + data_size))
buf.write(b"WAVE")
# fmt chunk
buf.write(b"fmt ")
buf.write(struct.pack("<I", 16)) # chunk size
buf.write(struct.pack("<H", 1)) # PCM
buf.write(struct.pack("<H", 1)) # mono
buf.write(struct.pack("<I", sample_rate))
buf.write(struct.pack("<I", sample_rate * 2)) # byte rate
buf.write(struct.pack("<H", 2)) # block align
buf.write(struct.pack("<H", 16)) # bits per sample
# data chunk
buf.write(b"data")
buf.write(struct.pack("<I", data_size))
for s in samples:
buf.write(struct.pack("<h", s))
return buf.getvalue()
def test_analyze_file_real_audio(
client: TestClient,
) -> None:
"""
Real WAV audio triggers the full analysis pipeline.
Expects production mode with feature extraction
and score fusion (not preview fallback).
"""
wav_bytes = _make_wav(duration_sec=3.0, freq_hz=440.0)
audio_file = io.BytesIO(wav_bytes)
response = client.post(
"/api/analyze",
data={"sourceType": "file"},
files={
"file": (
"tone.wav", audio_file, "audio/wav",
),
},
)
assert response.status_code == 200
data = response.json()
assert data["result"] is not None
result = data["result"]
# Should use real analysis, not preview fallback
assert result["analysisMode"] == "production"
assert result["decisionSource"].startswith("auris_")
assert result["modelVersion"] == "auris-v1-fusion"
# Confidence in valid range
assert 0.51 <= result["confidence"] <= 0.97
# Features should have real values
feat = result["features"]
assert 0.0 <= feat["spectralRegularity"] <= 0.99
assert 0.0 <= feat["temporalPatterns"] <= 0.99
assert 0.0 <= feat["harmonicStructure"] <= 0.99
# Audio info should reflect our input
info = result["audioInfo"]
assert info["duration"] > 2.0
assert info["sampleRate"] == 22050
# Indicators should be populated
assert len(feat["artificialIndicators"]) > 0
def test_analyze_file_real_audio_has_processing_time(
client: TestClient,
) -> None:
"""Processing time should be realistic."""
wav_bytes = _make_wav(duration_sec=2.0, freq_hz=220.0)
audio_file = io.BytesIO(wav_bytes)
response = client.post(
"/api/analyze",
data={"sourceType": "file"},
files={
"file": (
"test2.wav", audio_file, "audio/wav",
),
},
)
data = response.json()
assert data["result"] is not None
# Should complete in reasonable time
assert data["result"]["processingTime"] < 30.0
assert data["result"]["processingTime"] > 0.0