security / backend /tests /test_services.py
GitHub Actions
Deploy backend from GitHub 522e1ff559eaf4f3a628b450c12e01b910565458
5d50b8b
"""
Integration tests for HF and Groq service modules (mocked).
Tests retry behavior and error handling.
"""
import pytest
from unittest.mock import patch, AsyncMock, MagicMock
import httpx
from backend.app.services.hf_service import detect_ai_text, get_embeddings, detect_harm
from backend.app.services.groq_service import compute_perplexity
class TestHFService:
@pytest.mark.asyncio
@patch("backend.app.services.hf_service._hf_post", new_callable=AsyncMock)
async def test_detect_ai_text_success(self, mock_post):
mock_post.return_value = [[
{"label": "AI", "score": 0.92},
{"label": "Human", "score": 0.08},
]]
score = await detect_ai_text("Test text for detection")
assert 0.0 <= score <= 1.0
@pytest.mark.asyncio
@patch("backend.app.services.hf_service._hf_post", new_callable=AsyncMock)
async def test_detect_ai_text_fallback(self, mock_post):
"""If primary fails immediately (non-retried error), fallback URL should succeed."""
# Use ConnectError so tenacity does NOT retry (only HTTPStatusError/ConnectError
# with stop_after_attempt=3 would retry, but we want ONE failure then fallback).
# Actually tenacity retries on ConnectError too, so we use a plain Exception
# which is NOT in the retry predicate — it propagates immediately, letting
# detect_ai_text catch it in its try/except and move to the fallback URL.
mock_post.side_effect = [
Exception("Primary failed"), # primary URL -> caught, move on
[[{"label": "FAKE", "score": 0.75}]], # fallback URL -> success
]
with patch("backend.app.services.hf_service.settings") as mock_settings:
mock_settings.HF_DETECTOR_PRIMARY = "https://primary.example.com"
mock_settings.HF_DETECTOR_FALLBACK = "https://fallback.example.com"
score = await detect_ai_text("Test text")
assert 0.0 <= score <= 1.0
@pytest.mark.asyncio
@patch("backend.app.services.hf_service._hf_post", new_callable=AsyncMock)
async def test_get_embeddings_success(self, mock_post):
"""Mock returns a 768-dim vector; assert we get back exactly that vector."""
mock_post.return_value = [0.1] * 768
with patch("backend.app.services.hf_service.settings") as mock_settings:
mock_settings.HF_EMBEDDINGS_PRIMARY = "https://embeddings.example.com"
mock_settings.HF_EMBEDDINGS_FALLBACK = ""
result = await get_embeddings("Test text")
assert len(result) == 768
@pytest.mark.asyncio
@patch("backend.app.services.hf_service._hf_post", new_callable=AsyncMock)
async def test_detect_harm_success(self, mock_post):
mock_post.return_value = [[
{"label": "hate", "score": 0.15},
{"label": "not_hate", "score": 0.85},
]]
score = await detect_harm("Test text")
assert 0.0 <= score <= 1.0
class TestGroqService:
@pytest.mark.asyncio
@patch("backend.app.services.groq_service._groq_chat_completion", new_callable=AsyncMock)
async def test_compute_perplexity_with_logprobs(self, mock_groq):
mock_groq.return_value = {
"choices": [{
"logprobs": {
"content": [
{"logprob": -2.5},
{"logprob": -1.8},
{"logprob": -3.2},
]
}
}]
}
score = await compute_perplexity("Test text for perplexity")
assert score is not None
assert 0.0 <= score <= 1.0
@pytest.mark.asyncio
@patch("backend.app.services.groq_service._groq_chat_completion", new_callable=AsyncMock)
async def test_compute_perplexity_no_logprobs(self, mock_groq):
mock_groq.return_value = {
"choices": [{}],
"usage": {"prompt_tokens": 15},
}
score = await compute_perplexity("Test text without logprobs available")
assert score is None or 0.0 <= score <= 1.0
@pytest.mark.asyncio
@patch("backend.app.services.groq_service._groq_chat_completion", new_callable=AsyncMock)
async def test_compute_perplexity_error(self, mock_groq):
mock_groq.side_effect = Exception("Groq API error")
score = await compute_perplexity("Test text")
assert score is None