ground-zero / tests /test_api.py
jefffffff9
Initial commit: Sahel-Agri Voice AI
76db545
"""
Integration tests for the FastAPI endpoints.
Uses httpx.AsyncClient with a mocked transcriber so GPU hardware is not required.
"""
from __future__ import annotations
import io
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
def _make_mock_app():
"""Create a test FastAPI app with mocked model state."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from src.api.routes import health, iot, transcribe
from src.engine.transcriber import TranscriptionResult
app = FastAPI()
app.include_router(health.router, prefix="/api/v1")
app.include_router(transcribe.router, prefix="/api/v1")
app.include_router(iot.router, prefix="/api/v1")
# Mock adapter manager
mock_adapter_manager = MagicMock()
mock_adapter_manager.get_active.return_value = "bam"
mock_adapter_manager.list_available.return_value = ["bam", "ful"]
mock_adapter_manager.list_loaded.return_value = ["bam"]
# Mock transcriber
mock_transcriber = MagicMock()
mock_transcriber.transcribe_file.return_value = TranscriptionResult(
text="bunding nɔgɔ foro",
language="bam",
duration_s=3.0,
processing_time_ms=250,
)
# Mock sensor bridge
mock_sensor_bridge = MagicMock()
from src.iot.sensor_bridge import SensorData
from datetime import datetime
mock_sensor_bridge.fetch = AsyncMock(
return_value=SensorData(
sensor_type="soil",
values={"moisture_pct": 42.0, "ph": 6.5},
timestamp=datetime.utcnow().isoformat(),
)
)
app.state.adapter_manager = mock_adapter_manager
app.state.transcriber = mock_transcriber
app.state.sensor_bridge = mock_sensor_bridge
return app, TestClient(app)
class TestHealthEndpoint:
def setup_method(self):
self.app, self.client = _make_mock_app()
def test_health_returns_200(self):
response = self.client.get("/api/v1/health")
assert response.status_code == 200
def test_health_response_structure(self):
data = self.client.get("/api/v1/health").json()
assert "status" in data
assert "model_loaded" in data
assert "adapters_available" in data
def test_health_model_loaded_true(self):
data = self.client.get("/api/v1/health").json()
assert data["model_loaded"] is True
def test_health_active_adapter(self):
data = self.client.get("/api/v1/health").json()
assert data["active_adapter"] == "bam"
class TestTranscribeEndpoint:
def setup_method(self):
self.app, self.client = _make_mock_app()
def _wav_bytes(self) -> bytes:
"""Minimal valid WAV file bytes for testing."""
import wave
import struct
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(16000)
wf.writeframes(struct.pack("<" + "h" * 160, *([0] * 160)))
return buf.getvalue()
def test_transcribe_returns_200(self):
response = self.client.post(
"/api/v1/transcribe",
data={"language": "bam"},
files={"audio_file": ("test.wav", self._wav_bytes(), "audio/wav")},
)
assert response.status_code == 200
def test_transcribe_response_has_text(self):
data = self.client.post(
"/api/v1/transcribe",
data={"language": "bam"},
files={"audio_file": ("test.wav", self._wav_bytes(), "audio/wav")},
).json()
assert "text" in data
assert isinstance(data["text"], str)
def test_invalid_language_returns_422(self):
response = self.client.post(
"/api/v1/transcribe",
data={"language": "xyz"},
files={"audio_file": ("test.wav", self._wav_bytes(), "audio/wav")},
)
assert response.status_code == 422
def test_unsupported_file_type_returns_422(self):
response = self.client.post(
"/api/v1/transcribe",
data={"language": "bam"},
files={"audio_file": ("test.txt", b"not audio", "text/plain")},
)
assert response.status_code == 422
class TestIoTQueryEndpoint:
def setup_method(self):
self.app, self.client = _make_mock_app()
def _wav_bytes(self) -> bytes:
import io
import struct
import wave
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(16000)
wf.writeframes(struct.pack("<" + "h" * 160, *([0] * 160)))
return buf.getvalue()
def test_query_returns_200(self):
response = self.client.post(
"/api/v1/query",
data={"language": "bam", "field_id": "field_001"},
files={"audio_file": ("test.wav", self._wav_bytes(), "audio/wav")},
)
assert response.status_code == 200
def test_query_response_structure(self):
data = self.client.post(
"/api/v1/query",
data={"language": "bam"},
files={"audio_file": ("test.wav", self._wav_bytes(), "audio/wav")},
).json()
assert "transcription" in data
assert "intent" in data
assert "sensor_data" in data
assert "voice_response" in data
def test_query_voice_response_is_french(self):
data = self.client.post(
"/api/v1/query",
data={"language": "bam"},
files={"audio_file": ("test.wav", self._wav_bytes(), "audio/wav")},
).json()
# French response should contain at least one French word
response_text = data["voice_response"]
french_indicators = ["du", "de", "le", "la", "les", "et", "Humidité", "sol", "pH"]
assert any(word in response_text for word in french_indicators)