Add comprehensive test suite for image processing and related services
CI / Lint & Format (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Docker Build (push) Has been cancelled

- Implement tests for database generator to ensure proper session handling.
- Create tests for EXIF extraction and conversion functions.
- Add tests for image-related endpoints, ensuring proper data retrieval and isolation between clients.
- Develop tests for OCR functionality, including language detection and text extraction.
- Introduce tests for the image processing pipeline, covering success and failure scenarios.
- Validate rate limiting functionality and ensure independent counters for different clients.
- Implement scraper tests to verify HTML content fetching and error handling.
- Add unit tests for various services, including storage and filename generation.
- Establish worker entry point for ARQ to handle background image processing tasks.
This commit is contained in:
2026-02-24 11:22:10 -05:00
commit cc99fea20a
80 changed files with 9582 additions and 0 deletions
View File
+179
View File
@@ -0,0 +1,179 @@
"""
Fixtures pytest — base de données de test + clients API.
"""
import secrets
import asyncio
from typing import AsyncGenerator
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from app.database import Base, get_db
from app.dependencies.auth import hash_api_key
from app.models.client import APIClient, ClientPlan
from app.main import app
# ─────────────────────────────────────────────────────────────
# Engine de test (SQLite in-memory)
# ─────────────────────────────────────────────────────────────
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
test_engine = create_async_engine(TEST_DATABASE_URL, echo=False)
TestSessionLocal = async_sessionmaker(
bind=test_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
autocommit=False,
)
# ─────────────────────────────────────────────────────────────
# Override de la dépendance get_db
# ─────────────────────────────────────────────────────────────
async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
async with TestSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
app.dependency_overrides[get_db] = override_get_db
# ─────────────────────────────────────────────────────────────
# Mock ARQ pool et Redis pour les tests (pas de Redis requis)
# ─────────────────────────────────────────────────────────────
from unittest.mock import AsyncMock
_mock_arq_pool = AsyncMock()
_mock_arq_pool.enqueue_job = AsyncMock(return_value=None)
_mock_arq_pool.close = AsyncMock()
_mock_redis = AsyncMock()
_mock_redis.ping = AsyncMock(return_value=True)
_mock_redis.publish = AsyncMock(return_value=0)
app.state.arq_pool = _mock_arq_pool
app.state.redis = _mock_redis
# ─────────────────────────────────────────────────────────────
# Fixtures
# ─────────────────────────────────────────────────────────────
@pytest_asyncio.fixture(autouse=True)
async def setup_database():
"""Crée et détruit toutes les tables avant/après chaque test."""
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture
async def db_session() -> AsyncGenerator[AsyncSession, None]:
"""Session DB de test."""
async with TestSessionLocal() as session:
yield session
@pytest_asyncio.fixture
async def client_a_key() -> str:
"""Clé API en clair pour le client A."""
return "test-key-client-a-secret-123456"
@pytest_asyncio.fixture
async def client_b_key() -> str:
"""Clé API en clair pour le client B."""
return "test-key-client-b-secret-789012"
@pytest_asyncio.fixture
async def admin_key() -> str:
"""Clé API en clair pour le client admin."""
return "test-key-admin-secret-000000"
@pytest_asyncio.fixture
async def client_a(db_session: AsyncSession, client_a_key: str) -> APIClient:
"""Client A — droits standard (images + AI)."""
client = APIClient(
name="Test Client A",
api_key_hash=hash_api_key(client_a_key),
scopes=["images:read", "images:write", "images:delete", "ai:use"],
plan=ClientPlan.STANDARD,
)
db_session.add(client)
await db_session.commit()
await db_session.refresh(client)
return client
@pytest_asyncio.fixture
async def client_b(db_session: AsyncSession, client_b_key: str) -> APIClient:
"""Client B — droits standard (images + AI)."""
client = APIClient(
name="Test Client B",
api_key_hash=hash_api_key(client_b_key),
scopes=["images:read", "images:write", "images:delete", "ai:use"],
plan=ClientPlan.FREE,
)
db_session.add(client)
await db_session.commit()
await db_session.refresh(client)
return client
@pytest_asyncio.fixture
async def admin_client(db_session: AsyncSession, admin_key: str) -> APIClient:
"""Client admin — tous les droits."""
client = APIClient(
name="Admin Client",
api_key_hash=hash_api_key(admin_key),
scopes=["images:read", "images:write", "images:delete", "ai:use", "admin"],
plan=ClientPlan.PREMIUM,
)
db_session.add(client)
await db_session.commit()
await db_session.refresh(client)
return client
@pytest_asyncio.fixture
async def auth_headers_a(client_a_key: str) -> dict[str, str]:
"""Headers d'authentification pour le client A."""
return {"Authorization": f"Bearer {client_a_key}"}
@pytest_asyncio.fixture
async def auth_headers_b(client_b_key: str) -> dict[str, str]:
"""Headers d'authentification pour le client B."""
return {"Authorization": f"Bearer {client_b_key}"}
@pytest_asyncio.fixture
async def admin_headers(admin_key: str) -> dict[str, str]:
"""Headers d'authentification pour le client admin."""
return {"Authorization": f"Bearer {admin_key}"}
@pytest_asyncio.fixture
async def async_client() -> AsyncGenerator[AsyncClient, None]:
"""Client HTTP async pour tester l'API FastAPI."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
+57
View File
@@ -0,0 +1,57 @@
import pytest
from httpx import AsyncClient
from unittest.mock import patch, MagicMock
@pytest.mark.asyncio
async def test_ai_summarize(async_client: AsyncClient, client_a, auth_headers_a):
mock_page = {
"title": "Example Title",
"description": "Example Description",
"text": "Example Text content for the page.",
}
mock_ai_result = {
"summary": "This is a summary.",
"tags": ["tag1", "tag2"],
"model": "gemini-1.5-flash"
}
with patch("app.routers.ai.fetch_page_content", return_value=mock_page), \
patch("app.routers.ai.summarize_url", return_value=mock_ai_result), \
patch("app.routers.ai.settings.AI_ENABLED", True):
payload = {"url": "https://example.com", "language": "français"}
response = await async_client.post("/ai/summarize", json=payload, headers=auth_headers_a)
assert response.status_code == 200
data = response.json()
assert data["summary"] == "This is a summary."
assert "tag1" in data["tags"]
assert data["title"] == "Example Title"
@pytest.mark.asyncio
async def test_ai_draft_task(async_client: AsyncClient, client_a, auth_headers_a):
mock_task = {
"title": "Fix Bug",
"description": "Fix the bug in the code",
"steps": ["Step 1", "Step 2"],
"priority": "high",
"estimated_time": "1h"
}
with patch("app.routers.ai.draft_task", return_value=mock_task), \
patch("app.routers.ai.settings.AI_ENABLED", True):
payload = {"description": "Fix bug", "context": "Backend project"}
response = await async_client.post("/ai/draft-task", json=payload, headers=auth_headers_a)
assert response.status_code == 200
data = response.json()
assert data["title"] == "Fix Bug"
assert data["priority"] == "high"
@pytest.mark.asyncio
async def test_ai_disabled(async_client: AsyncClient, client_a, auth_headers_a):
with patch("app.routers.ai.settings.AI_ENABLED", False):
response = await async_client.post("/ai/draft-task", json={"description": "test"}, headers=auth_headers_a)
assert response.status_code == 503
assert response.json()["detail"] == "AI désactivée"
+62
View File
@@ -0,0 +1,62 @@
import pytest
from unittest.mock import patch, MagicMock
from app.services.ai_vision import analyze_image, summarize_url, draft_task, _extract_json
def test_extract_json():
text = '```json {"key": "value"} ```'
assert _extract_json(text) == {"key": "value"}
text = 'Here is the result: {"a": 1}'
assert _extract_json(text) == {"a": 1}
assert _extract_json("Invalid") is None
@pytest.mark.asyncio
@patch("app.services.ai_vision._generate")
@patch("app.services.ai_vision._read_image")
@patch("app.services.ai_vision.settings")
async def test_analyze_image_success(mock_settings, mock_read_image, mock_generate):
mock_settings.AI_ENABLED = True
mock_settings.AI_PROVIDER = "gemini"
mock_settings.AI_TAGS_MIN = 5
mock_settings.AI_TAGS_MAX = 10
mock_read_image.return_value = (b"fake_bytes", "image/jpeg")
mock_generate.return_value = {
"text": '{"description": "A test image", "tags": ["tag1", "tag2"], "confidence": 0.9}',
"usage": (10, 20)
}
result = await analyze_image("fake/path.jpg")
assert result["description"] == "A test image"
assert "tag1" in result["tags"]
assert result["prompt_tokens"] == 10
@pytest.mark.asyncio
@patch("app.services.ai_vision._generate")
@patch("app.services.ai_vision.settings")
async def test_summarize_url_success(mock_settings, mock_generate):
mock_settings.AI_ENABLED = True
mock_generate.return_value = {
"text": '{"summary": "A summary", "tags": ["s1", "s2"]}',
"usage": (5, 5)
}
result = await summarize_url("http://url", "content")
assert result["summary"] == "A summary"
assert "s1" in result["tags"]
@pytest.mark.asyncio
@patch("app.services.ai_vision._generate")
@patch("app.services.ai_vision.settings")
async def test_draft_task_success(mock_settings, mock_generate):
mock_settings.AI_ENABLED = True
mock_generate.return_value = {
"text": '{"title": "Task Title", "description": "Task Desc", "steps": ["S1"]}',
"usage": (5, 5)
}
result = await draft_task("Fix bug", "Context")
assert result["title"] == "Task Title"
assert "S1" in result["steps"]
+94
View File
@@ -0,0 +1,94 @@
import pytest
import os
import base64
from pathlib import Path
from unittest.mock import patch, MagicMock
from app.services.ai_vision import (
_read_image, _extract_json, _usage_tokens_gemini,
_generate_gemini, _generate_openrouter, _generate,
extract_text_with_ai, analyze_image
)
from app.config import settings
def test_read_image_variants(tmp_path):
img_file = tmp_path / "test.png"
img_file.write_bytes(b"fake_png_data")
data, mime = _read_image(str(img_file))
assert data == b"fake_png_data"
assert mime == "image/png"
def test_extract_json_bad_cases():
assert _extract_json(None) is None
assert _extract_json("No JSON here") is None
assert _extract_json("Broken { json : 1") is None
def test_usage_tokens_gemini_none():
assert _usage_tokens_gemini(None) == (None, None)
mock_resp = MagicMock()
mock_resp.usage_metadata = None
assert _usage_tokens_gemini(mock_resp) == (None, None)
@pytest.mark.asyncio
@patch("app.services.ai_vision._get_client")
@patch("app.services.ai_vision.settings")
async def test_generate_gemini_no_key(mock_settings, mock_get_client):
mock_settings.GEMINI_API_KEY = None
res = await _generate_gemini("test prompt")
assert res["text"] is None
@pytest.mark.asyncio
@patch("httpx.AsyncClient.post")
@patch("app.services.ai_vision.settings")
async def test_generate_openrouter_success(mock_settings, mock_post):
mock_settings.OPENROUTER_API_KEY = "test_key"
mock_settings.OPENROUTER_MODEL = "test_model"
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"choices": [{"message": {"content": "openrouter test response"}}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5}
}
mock_post.return_value = mock_response
res = await _generate_openrouter("test prompt")
assert res["text"] == "openrouter test response"
assert res["usage"] == (10, 5)
@pytest.mark.asyncio
@patch("app.services.ai_vision._generate_openrouter")
@patch("app.services.ai_vision.settings")
async def test_generate_dispatcher_openrouter(mock_settings, mock_gen_or):
mock_settings.AI_PROVIDER = "openrouter"
await _generate("test")
mock_gen_or.assert_called_once()
@pytest.mark.asyncio
@patch("app.services.ai_vision._generate")
@patch("app.services.ai_vision._read_image")
@patch("app.services.ai_vision.settings")
async def test_extract_text_with_ai(mock_settings, mock_read_image, mock_generate):
mock_settings.AI_ENABLED = True
mock_settings.AI_PROVIDER = "gemini"
mock_read_image.return_value = (b"fake", "image/jpeg")
mock_generate.return_value = {
"text": '{"text": "Extracted text", "language": "en", "confidence": 0.9}'
}
res = await extract_text_with_ai("fake/path.jpg")
assert res["text"] == "Extracted text"
assert res["language"] == "en"
@pytest.mark.asyncio
@patch("app.services.ai_vision._generate")
@patch("app.services.ai_vision._read_image")
@patch("app.services.ai_vision.settings")
async def test_analyze_image_failure(mock_settings, mock_read_image, mock_generate):
mock_settings.AI_ENABLED = True
mock_read_image.return_value = (b"fake", "image/jpeg")
mock_generate.return_value = {"text": "Invalid JSON", "usage": (None, None)}
res = await analyze_image("fake/path.jpg")
assert res["description"] is None
+254
View File
@@ -0,0 +1,254 @@
"""
Tests d'authentification — API Keys + scopes.
"""
import pytest
import pytest_asyncio
from httpx import AsyncClient
from app.dependencies.auth import hash_api_key
from app.models.client import APIClient
pytestmark = pytest.mark.asyncio
# ─────────────────────────────────────────────────────────────
# 401 — Pas de clé / clé invalide
# ─────────────────────────────────────────────────────────────
async def test_no_auth_returns_401(async_client: AsyncClient):
"""Requête sans header Authorization → HTTP 401."""
response = await async_client.get("/images")
assert response.status_code == 422 or response.status_code == 401
async def test_invalid_key_returns_401(async_client: AsyncClient):
"""Requête avec une clé invalide → HTTP 401."""
response = await async_client.get(
"/images",
headers={"Authorization": "Bearer invalid-key-that-does-not-exist"},
)
assert response.status_code == 401
assert "Authentification requise" in response.json()["detail"]
async def test_no_bearer_prefix_returns_401(async_client: AsyncClient):
"""Requête avec un header Authorization sans 'Bearer ' → HTTP 401."""
response = await async_client.get(
"/images",
headers={"Authorization": "Basic some-key"},
)
assert response.status_code == 401
async def test_empty_bearer_returns_401(async_client: AsyncClient):
"""Requête avec 'Bearer ' mais sans clé → HTTP 401."""
response = await async_client.get(
"/images",
headers={"Authorization": "Bearer "},
)
assert response.status_code == 401
# ─────────────────────────────────────────────────────────────
# 200 — Clé valide
# ─────────────────────────────────────────────────────────────
async def test_valid_key_returns_200(
async_client: AsyncClient,
client_a: APIClient,
auth_headers_a: dict,
):
"""Requête avec une clé valide → HTTP 200."""
response = await async_client.get("/images", headers=auth_headers_a)
assert response.status_code == 200
# ─────────────────────────────────────────────────────────────
# 401 — Client inactif
# ─────────────────────────────────────────────────────────────
async def test_inactive_client_returns_401(
async_client: AsyncClient,
client_a: APIClient,
auth_headers_a: dict,
db_session,
):
"""Client désactivé → HTTP 401 même avec une clé valide."""
# Désactiver le client
client_a.is_active = False
db_session.add(client_a)
await db_session.commit()
response = await async_client.get("/images", headers=auth_headers_a)
assert response.status_code == 401
# ─────────────────────────────────────────────────────────────
# 403 — Scope manquant
# ─────────────────────────────────────────────────────────────
async def test_missing_scope_returns_403(
async_client: AsyncClient,
db_session,
):
"""Client sans le bon scope → HTTP 403."""
# Créer un client avec seulement images:read (pas images:write)
key = "test-readonly-key-123"
client = APIClient(
name="Read Only Client",
api_key_hash=hash_api_key(key),
scopes=["images:read"], # pas images:write ni admin
plan="free",
)
db_session.add(client)
await db_session.commit()
# Tenter d'accéder aux endpoints admin
response = await async_client.get(
"/auth/clients",
headers={"Authorization": f"Bearer {key}"},
)
assert response.status_code == 403
assert "Permission insuffisante" in response.json()["detail"]
async def test_scope_images_read_allowed(
async_client: AsyncClient,
client_a: APIClient,
auth_headers_a: dict,
):
"""Client avec scope images:read peut lister les images."""
response = await async_client.get("/images", headers=auth_headers_a)
assert response.status_code == 200
# ─────────────────────────────────────────────────────────────
# Rotation de clé
# ─────────────────────────────────────────────────────────────
async def test_key_rotation(
async_client: AsyncClient,
admin_client: APIClient,
admin_headers: dict,
client_a: APIClient,
auth_headers_a: dict,
):
"""Après rotation, l'ancienne clé est invalide et la nouvelle fonctionne."""
# Vérifier que la clé actuelle fonctionne
response = await async_client.get("/images", headers=auth_headers_a)
assert response.status_code == 200
# Rotation de la clé
response = await async_client.post(
f"/auth/clients/{client_a.id}/rotate-key",
headers=admin_headers,
)
assert response.status_code == 200
new_key = response.json()["api_key"]
assert new_key # La nouvelle clé est retournée
# L'ancienne clé ne fonctionne plus
response = await async_client.get("/images", headers=auth_headers_a)
assert response.status_code == 401
# La nouvelle clé fonctionne
response = await async_client.get(
"/images",
headers={"Authorization": f"Bearer {new_key}"},
)
assert response.status_code == 200
# ─────────────────────────────────────────────────────────────
# Création de client
# ─────────────────────────────────────────────────────────────
async def test_create_client_returns_key_once(
async_client: AsyncClient,
admin_client: APIClient,
admin_headers: dict,
):
"""La clé API est retournée une seule fois à la création."""
response = await async_client.post(
"/auth/clients",
json={
"name": "New Test Client",
"scopes": ["images:read"],
"plan": "free",
},
headers=admin_headers,
)
assert response.status_code == 201
data = response.json()
assert "api_key" in data
assert len(data["api_key"]) > 20 # clé suffisamment longue
assert data["name"] == "New Test Client"
assert data["scopes"] == ["images:read"]
# La clé n'apparaît pas dans les réponses GET
client_id = data["id"]
response = await async_client.get(
f"/auth/clients/{client_id}",
headers=admin_headers,
)
assert response.status_code == 200
assert "api_key" not in response.json()
# ─────────────────────────────────────────────────────────────
# CRUD clients — admin only
# ─────────────────────────────────────────────────────────────
async def test_list_clients_admin_only(
async_client: AsyncClient,
client_a: APIClient,
auth_headers_a: dict,
admin_client: APIClient,
admin_headers: dict,
):
"""Seul un admin peut lister les clients."""
# Non-admin → 403
response = await async_client.get("/auth/clients", headers=auth_headers_a)
assert response.status_code == 403
# Admin → 200
response = await async_client.get("/auth/clients", headers=admin_headers)
assert response.status_code == 200
async def test_update_client(
async_client: AsyncClient,
admin_client: APIClient,
admin_headers: dict,
client_a: APIClient,
):
"""L'admin peut modifier les scopes et le plan d'un client."""
response = await async_client.patch(
f"/auth/clients/{client_a.id}",
json={"plan": "premium", "scopes": ["images:read"]},
headers=admin_headers,
)
assert response.status_code == 200
assert response.json()["plan"] == "premium"
assert response.json()["scopes"] == ["images:read"]
async def test_soft_delete_client(
async_client: AsyncClient,
admin_client: APIClient,
admin_headers: dict,
client_a: APIClient,
auth_headers_a: dict,
):
"""Soft delete désactive le client — les requêtes suivantes retournent 401."""
response = await async_client.delete(
f"/auth/clients/{client_a.id}",
headers=admin_headers,
)
assert response.status_code == 200
assert response.json()["is_active"] is False
# Le client désactivé ne peut plus s'authentifier
response = await async_client.get("/images", headers=auth_headers_a)
assert response.status_code == 401
+45
View File
@@ -0,0 +1,45 @@
import pytest
import hashlib
from sqlalchemy import select
from app.database import init_db
from app.models.client import APIClient
from unittest.mock import patch, MagicMock
@pytest.mark.asyncio
async def test_init_db_creates_default_client():
# Mock engine and AsyncSessionLocal
with patch("app.database.engine") as mock_engine, \
patch("app.database.AsyncSessionLocal") as mock_session_factory:
# Mock engine.begin() context manager
mock_conn = AsyncMock()
mock_engine.begin.return_value.__aenter__.return_value = mock_conn
# Mock session behavior
mock_session = AsyncMockSession()
mock_session_factory.return_value.__aenter__.return_value = mock_session
# Simulate empty table
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = None
mock_session.execute.return_value = mock_result
await init_db()
# Check if add was called for bootstrap client
assert any(isinstance(call.args[0], APIClient) for call in mock_session.add.call_args_list)
class AsyncMockSession:
def __init__(self):
self.add = MagicMock()
self.commit = AsyncMock()
self.execute = AsyncMock()
self.close = AsyncMock()
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
class AsyncMock(MagicMock):
async def __call__(self, *args, **kwargs):
return super(AsyncMock, self).__call__(*args, **kwargs)
def __await__(self):
return self().__await__()
+54
View File
@@ -0,0 +1,54 @@
import pytest
from app.database import get_db
from unittest.mock import patch, MagicMock
@pytest.mark.asyncio
async def test_get_db_generator():
mock_session = MagicMock()
mock_session.commit = AsyncMock()
mock_session.rollback = AsyncMock()
mock_session.close = AsyncMock()
# We need to mock AsyncSessionLocal as context manager
mock_session_factory = MagicMock()
mock_session_factory.return_value.__aenter__.return_value = mock_session
with patch("app.database.AsyncSessionLocal", mock_session_factory):
generator = get_db()
# Initial yield
session = await generator.__anext__()
assert session == mock_session
# After yield, it tries to commit
try:
await generator.__anext__()
except StopAsyncIteration:
pass
mock_session.commit.assert_called_once()
mock_session.close.assert_called_once()
@pytest.mark.asyncio
async def test_get_db_generator_exception():
mock_session = MagicMock()
mock_session.commit = AsyncMock()
mock_session.rollback = AsyncMock()
mock_session.close = AsyncMock()
mock_session_factory = MagicMock()
mock_session_factory.return_value.__aenter__.return_value = mock_session
with patch("app.database.AsyncSessionLocal", mock_session_factory):
generator = get_db()
session = await generator.__anext__()
# Simulate exception during use
with pytest.raises(ValueError):
await generator.athrow(ValueError("test error"))
mock_session.rollback.assert_called_once()
mock_session.close.assert_called_once()
class AsyncMock(MagicMock):
async def __call__(self, *args, **kwargs):
return super(AsyncMock, self).__call__(*args, **kwargs)
+57
View File
@@ -0,0 +1,57 @@
import pytest
from unittest.mock import patch, MagicMock
from app.services.exif_service import extract_exif, _dms_to_decimal
def test_dms_to_decimal():
# 48° 51' 23.82" N -> 48.8566167
dms = ((48, 1), (51, 1), (2382, 100))
assert _dms_to_decimal(dms, "N") == 48.8566167
assert _dms_to_decimal(dms, "S") == -48.8566167
@patch("app.services.exif_service.piexif")
@patch("app.services.exif_service.PILImage")
@patch("app.services.exif_service.Path.exists", return_value=True)
def test_extract_exif_success(mock_path_exists, mock_pil, mock_piexif):
# Mock piexif data
import piexif
mock_data = {
"0th": {
piexif.ImageIFD.Make: b"Canon",
piexif.ImageIFD.Model: b"EOS R5",
piexif.ImageIFD.Software: b"1.1.0"
},
"Exif": {
piexif.ExifIFD.DateTimeOriginal: b"2024:06:15 14:30:00",
piexif.ExifIFD.ISOSpeedRatings: 400,
piexif.ExifIFD.FNumber: (28, 10),
piexif.ExifIFD.ExposureTime: (1, 250),
piexif.ExifIFD.FocalLength: (500, 10),
piexif.ExifIFD.Flash: 0
},
"GPS": {
piexif.GPSIFD.GPSLatitude: ((48, 1), (51, 1), (2382, 100)),
piexif.GPSIFD.GPSLatitudeRef: b"N",
piexif.GPSIFD.GPSLongitude: ((2, 1), (21, 1), (792, 100)),
piexif.GPSIFD.GPSLongitudeRef: b"E",
piexif.GPSIFD.GPSAltitude: (350, 10)
}
}
mock_piexif.load.return_value = mock_data
mock_piexif.ImageIFD = piexif.ImageIFD
mock_piexif.ExifIFD = piexif.ExifIFD
mock_piexif.GPSIFD = piexif.GPSIFD
# Mock PIL
mock_img = mock_pil.open.return_value.__enter__.return_value
mock_img._getexif.return_value = {271: "Canon"} # 271 is Make
result = extract_exif("fake/path.jpg")
assert result["make"] == "Canon"
assert result["model"] == "EOS R5"
assert result["iso"] == 400
assert result["aperture"] == "f/2.8"
assert result["shutter"] == "1/250"
assert result["focal"] == "50mm"
assert result["gps_lat"] == 48.8566167
assert result["altitude"] == 35.0
+130
View File
@@ -0,0 +1,130 @@
import pytest
import uuid
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.image import Image, ProcessingStatus
from unittest.mock import patch
def create_test_image(client_id, **kwargs):
u = str(uuid.uuid4())
defaults = {
"uuid": u,
"client_id": client_id,
"original_name": "test.jpg",
"filename": f"{u}.jpg",
"file_path": f"fake/{u}.jpg",
}
defaults.update(kwargs)
return Image(**defaults)
@pytest.mark.asyncio
async def test_get_image_exif(async_client: AsyncClient, client_a, auth_headers_a, db_session: AsyncSession):
# Seed an image
image = create_test_image(
client_id=client_a.id,
exif_make="Apple",
exif_model="iPhone 13",
exif_gps_lat=48.8566,
exif_gps_lon=2.3522
)
db_session.add(image)
await db_session.commit()
await db_session.refresh(image)
response = await async_client.get(f"/images/{image.id}/exif", headers=auth_headers_a)
assert response.status_code == 200
data = response.json()
assert data["camera"]["make"] == "Apple"
assert data["gps"]["latitude"] == 48.8566
assert "maps_url" in data["gps"]
@pytest.mark.asyncio
async def test_get_image_ocr(async_client: AsyncClient, client_a, auth_headers_a, db_session: AsyncSession):
# Seed an image
image = create_test_image(
client_id=client_a.id,
ocr_has_text=True,
ocr_text="Hello World",
ocr_language="en",
ocr_confidence=0.95
)
db_session.add(image)
await db_session.commit()
await db_session.refresh(image)
response = await async_client.get(f"/images/{image.id}/ocr", headers=auth_headers_a)
assert response.status_code == 200
data = response.json()
assert data["has_text"] is True
assert data["text"] == "Hello World"
@pytest.mark.asyncio
async def test_get_image_ai(async_client: AsyncClient, client_a, auth_headers_a, db_session: AsyncSession):
# Seed an image
image = create_test_image(
client_id=client_a.id,
ai_description="A beautiful landscape",
ai_tags=["nature", "landscape"],
ai_confidence=0.99,
ai_model_used="gemini-1.5-pro"
)
db_session.add(image)
await db_session.commit()
await db_session.refresh(image)
response = await async_client.get(f"/images/{image.id}/ai", headers=auth_headers_a)
assert response.status_code == 200
data = response.json()
assert data["description"] == "A beautiful landscape"
assert "nature" in data["tags"]
@pytest.mark.asyncio
async def test_get_all_tags(async_client: AsyncClient, client_a, auth_headers_a, db_session: AsyncSession):
# Seed images
db_session.add(create_test_image(client_id=client_a.id, original_name="1.jpg", ai_tags=["tag1", "tag2"]))
db_session.add(create_test_image(client_id=client_a.id, original_name="2.jpg", ai_tags=["tag2", "tag3"]))
await db_session.commit()
response = await async_client.get("/images/tags/all", headers=auth_headers_a)
assert response.status_code == 200
data = response.json()
assert set(data["tags"]) == {"tag1", "tag2", "tag3"}
assert data["total"] == 3
@pytest.mark.asyncio
async def test_reprocess_image(async_client: AsyncClient, client_a, auth_headers_a, db_session: AsyncSession):
image = create_test_image(
client_id=client_a.id,
processing_status=ProcessingStatus.DONE
)
db_session.add(image)
await db_session.commit()
await db_session.refresh(image)
# Le ARQ pool est mocké globalement dans conftest.py
response = await async_client.post(f"/images/{image.id}/reprocess", headers=auth_headers_a)
assert response.status_code == 200
await db_session.refresh(image)
assert image.processing_status == ProcessingStatus.PENDING
@pytest.mark.asyncio
async def test_delete_image(async_client: AsyncClient, client_a, auth_headers_a, db_session: AsyncSession):
image = create_test_image(
client_id=client_a.id,
file_path="fake/path.jpg",
thumbnail_path="fake/thumb.jpg"
)
db_session.add(image)
await db_session.commit()
await db_session.refresh(image)
with patch("app.routers.images.storage.delete_files") as mock_delete:
response = await async_client.delete(f"/images/{image.id}", headers=auth_headers_a)
assert response.status_code == 200
mock_delete.assert_called_once_with("fake/path.jpg", "fake/thumb.jpg")
# Check if deleted from DB
from sqlalchemy import select
res = await db_session.execute(select(Image).where(Image.id == image.id))
assert res.scalar_one_or_none() is None
+209
View File
@@ -0,0 +1,209 @@
"""
Tests d'isolation multi-tenants — un client ne peut jamais voir les données d'un autre.
"""
import io
import pytest
import pytest_asyncio
from unittest.mock import patch, AsyncMock
from httpx import AsyncClient
from app.models.client import APIClient
from app.models.image import Image
pytestmark = pytest.mark.asyncio
# ─────────────────────────────────────────────────────────────
# Helper : upload d'une image de test
# ─────────────────────────────────────────────────────────────
async def _upload_test_image(
async_client: AsyncClient,
headers: dict,
filename: str = "test.jpg",
) -> dict:
"""Upload une image de test minimale (1x1 pixel JPEG) et retourne la réponse JSON."""
# Image JPEG minimale valide (1x1 pixel)
jpeg_bytes = (
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
b"\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t"
b"\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a"
b"\x1f\x1e\x1d\x1a\x1c\x1c $.\' \",#\x1c\x1c(7),01444\x1f\'9=82<.342"
b"\xff\xc0\x00\x0b\x08\x00\x01\x00\x01\x01\x01\x11\x00"
b"\xff\xc4\x00\x1f\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b"
b"\xff\xc4\x00\xb5\x10\x00\x02\x01\x03\x03\x02\x04\x03\x05\x05\x04"
b"\x04\x00\x00\x01}\x01\x02\x03\x00\x04\x11\x05\x12!1A\x06\x13Qa\x07"
b"\x22q\x142\x81\x91\xa1\x08#B\xb1\xc1\x15R\xd1\xf0$3br\x82\t\n\x16"
b"\x17\x18\x19\x1a%&\'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz\x83"
b"\x84\x85\x86\x87\x88\x89\x8a\x92\x93\x94\x95\x96\x97\x98\x99\x9a"
b"\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xb2\xb3\xb4\xb5\xb6\xb7\xb8"
b"\xb9\xba\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xd2\xd3\xd4\xd5\xd6"
b"\xd7\xd8\xd9\xda\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xf1\xf2"
b"\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa"
b"\xff\xda\x00\x08\x01\x01\x00\x00?\x00T\xdb\xae\x8a(\x03\xff\xd9"
)
# Le pipeline ARQ est mocké globalement dans conftest.py
response = await async_client.post(
"/images/upload",
files={"file": (filename, io.BytesIO(jpeg_bytes), "image/jpeg")},
headers=headers,
)
return response
# ─────────────────────────────────────────────────────────────
# Client A upload une image → invisible pour Client B
# ─────────────────────────────────────────────────────────────
async def test_client_a_image_invisible_to_client_b(
async_client: AsyncClient,
client_a: APIClient,
client_b: APIClient,
auth_headers_a: dict,
auth_headers_b: dict,
):
"""L'image uploadée par A n'apparaît pas dans la liste de B."""
# A uploade une image
upload_resp = await _upload_test_image(async_client, auth_headers_a, "photo_a.jpg")
assert upload_resp.status_code == 201
# Liste pour A → contient l'image
response = await async_client.get("/images", headers=auth_headers_a)
assert response.status_code == 200
data_a = response.json()
assert data_a["total"] == 1
assert data_a["items"][0]["original_name"] == "photo_a.jpg"
# Liste pour B → vide
response = await async_client.get("/images", headers=auth_headers_b)
assert response.status_code == 200
data_b = response.json()
assert data_b["total"] == 0
assert len(data_b["items"]) == 0
# ─────────────────────────────────────────────────────────────
# Client B ne peut pas lire l'image de Client A
# ─────────────────────────────────────────────────────────────
async def test_client_b_cannot_read_client_a_image(
async_client: AsyncClient,
client_a: APIClient,
client_b: APIClient,
auth_headers_a: dict,
auth_headers_b: dict,
):
"""Client B reçoit 404 en essayant de lire l'image de A."""
# A uploade
upload_resp = await _upload_test_image(async_client, auth_headers_a)
assert upload_resp.status_code == 201
image_id = upload_resp.json()["id"]
# B essaie de lire l'image de A → 404
response = await async_client.get(f"/images/{image_id}", headers=auth_headers_b)
assert response.status_code == 404
# ─────────────────────────────────────────────────────────────
# Client B ne peut pas supprimer l'image de Client A
# ─────────────────────────────────────────────────────────────
async def test_client_b_cannot_delete_client_a_image(
async_client: AsyncClient,
client_a: APIClient,
client_b: APIClient,
auth_headers_a: dict,
auth_headers_b: dict,
):
"""Client B reçoit 404 en essayant de supprimer l'image de A."""
# A uploade
upload_resp = await _upload_test_image(async_client, auth_headers_a)
assert upload_resp.status_code == 201
image_id = upload_resp.json()["id"]
# B essaie de supprimer → 404
response = await async_client.delete(f"/images/{image_id}", headers=auth_headers_b)
assert response.status_code == 404
# L'image existe toujours pour A
response = await async_client.get(f"/images/{image_id}", headers=auth_headers_a)
assert response.status_code == 200
# ─────────────────────────────────────────────────────────────
# Chaque client ne voit que ses propres images
# ─────────────────────────────────────────────────────────────
async def test_listing_returns_only_own_images(
async_client: AsyncClient,
client_a: APIClient,
client_b: APIClient,
auth_headers_a: dict,
auth_headers_b: dict,
):
"""Chaque client ne voit que ses propres images dans la liste."""
# A uploade 2 images
await _upload_test_image(async_client, auth_headers_a, "a1.jpg")
await _upload_test_image(async_client, auth_headers_a, "a2.jpg")
# B uploade 1 image
await _upload_test_image(async_client, auth_headers_b, "b1.jpg")
# A voit 2 images
resp_a = await async_client.get("/images", headers=auth_headers_a)
assert resp_a.json()["total"] == 2
# B voit 1 image
resp_b = await async_client.get("/images", headers=auth_headers_b)
assert resp_b.json()["total"] == 1
assert resp_b.json()["items"][0]["original_name"] == "b1.jpg"
# ─────────────────────────────────────────────────────────────
# Reprocess d'une image d'un autre client → 404
# ─────────────────────────────────────────────────────────────
async def test_reprocess_other_client_image_returns_404(
async_client: AsyncClient,
client_a: APIClient,
client_b: APIClient,
auth_headers_a: dict,
auth_headers_b: dict,
):
"""Reprocess l'image d'un autre client → HTTP 404."""
# A uploade
upload_resp = await _upload_test_image(async_client, auth_headers_a)
assert upload_resp.status_code == 201
image_id = upload_resp.json()["id"]
# B essaie de reprocess → 404
response = await async_client.post(
f"/images/{image_id}/reprocess",
headers=auth_headers_b,
)
assert response.status_code == 404
# ─────────────────────────────────────────────────────────────
# Statut / EXIF / OCR / AI d'un autre client → 404
# ─────────────────────────────────────────────────────────────
async def test_sub_endpoints_other_client_returns_404(
async_client: AsyncClient,
client_a: APIClient,
client_b: APIClient,
auth_headers_a: dict,
auth_headers_b: dict,
):
"""Les sous-endpoints (status, exif, ocr, ai) retournent 404 pour l'autre client."""
upload_resp = await _upload_test_image(async_client, auth_headers_a)
assert upload_resp.status_code == 201
image_id = upload_resp.json()["id"]
for endpoint in [f"/images/{image_id}/status", f"/images/{image_id}/exif",
f"/images/{image_id}/ocr", f"/images/{image_id}/ai"]:
response = await async_client.get(endpoint, headers=auth_headers_b)
assert response.status_code == 404, f"Endpoint {endpoint} should return 404 for other client"
+25
View File
@@ -0,0 +1,25 @@
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
from app.config import settings
@pytest.mark.asyncio
async def test_root_endpoint(async_client: AsyncClient):
response = await async_client.get("/")
assert response.status_code == 200
assert response.json()["app"] == settings.APP_NAME
@pytest.mark.asyncio
async def test_health_endpoint(async_client: AsyncClient):
response = await async_client.get("/health")
assert response.status_code == 200
assert "status" in response.json()
assert response.json()["status"] == "healthy"
@pytest.mark.asyncio
async def test_lifespan(async_client: AsyncClient):
# Testing that the lifespan context manager can be entered/exited
# ASGITransport already handles this if used correctly
async with ASGITransport(app=app) as transport:
# Just entering the context triggers lifespan
pass
+34
View File
@@ -0,0 +1,34 @@
import pytest
from unittest.mock import MagicMock
from app.middleware import (
_get_client_id_from_request, get_upload_rate_limit,
get_ai_rate_limit, upload_rate_limit_key, ai_rate_limit_key
)
def test_get_client_id_from_request_with_id():
request = MagicMock()
request.state.client_id = "test-client"
assert _get_client_id_from_request(request) == "test-client"
def test_get_client_id_from_request_fallback():
request = MagicMock()
del request.state.client_id # Ensure it's not there
request.client.host = "1.2.3.4"
request.headers = {}
# slowapi's get_remote_address uses request.client.host or headers
with patch("app.middleware.get_remote_address", return_value="1.2.3.4"):
assert _get_client_id_from_request(request) == "1.2.3.4"
from unittest.mock import patch
def test_rate_limit_helpers():
assert "hour" in get_upload_rate_limit("free")
assert "hour" in get_ai_rate_limit("premium")
assert get_upload_rate_limit("invalid") == get_upload_rate_limit("free")
def test_rate_limit_keys():
request = MagicMock()
request.state.client_id = "abc"
assert upload_rate_limit_key(request) == "abc"
assert ai_rate_limit_key(request) == "abc"
+41
View File
@@ -0,0 +1,41 @@
import pytest
from unittest.mock import patch, MagicMock
from app.services.ocr_service import extract_text, _detect_language
def test_detect_language():
assert _detect_language("C'est une phrase en français.") == "fr"
assert _detect_language("This is a sentence in English.") == "en"
assert _detect_language("") == "unknown"
assert _detect_language("123456789") == "unknown"
@patch("app.services.ocr_service.pytesseract")
@patch("app.services.ocr_service.PILImage")
@patch("app.services.ocr_service.Path.exists", return_value=True)
@patch("app.services.ocr_service.settings")
def test_extract_text_success(mock_settings, mock_path_exists, mock_pil, mock_tesseract):
mock_settings.OCR_ENABLED = True
mock_settings.OCR_LANGUAGES = "fra+eng"
mock_settings.TESSERACT_CMD = None
# Mock image_to_data
mock_tesseract.image_to_data.return_value = {
"conf": ["90", "80", "-1", "70"]
}
mock_tesseract.Output.DICT = "dict"
# Mock image_to_string
mock_tesseract.image_to_string.return_value = "This is a test OCR output."
result = extract_text("fake/path.jpg")
assert result["has_text"] is True
assert result["text"] == "This is a test OCR output."
assert result["confidence"] == 0.8 # (90+80+70)/3 / 100 = 0.8
assert result["language"] == "en"
@patch("app.services.ocr_service.settings")
def test_extract_text_disabled(mock_settings):
mock_settings.OCR_ENABLED = False
result = extract_text("any/path.jpg")
assert result["has_text"] is False
assert result["text"] is None
+106
View File
@@ -0,0 +1,106 @@
import pytest
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.image import Image, ProcessingStatus
from app.services.pipeline import process_image_pipeline
from unittest.mock import patch, AsyncMock
def create_test_image(client_id, **kwargs):
u = str(uuid.uuid4())
defaults = {
"uuid": u,
"client_id": client_id,
"original_name": "test.jpg",
"filename": f"{u}.jpg",
"file_path": f"fake/{u}.jpg",
}
defaults.update(kwargs)
return Image(**defaults)
@pytest.mark.asyncio
async def test_pipeline_full_success(db_session: AsyncSession, client_a):
image = create_test_image(client_id=client_a.id)
db_session.add(image)
await db_session.commit()
await db_session.refresh(image)
mock_exif = {"make": "Canon", "model": "EOS R"}
mock_ocr = {"text": "Hello", "has_text": True, "confidence": 0.9}
mock_ai = {"description": "A photo", "tags": ["tag1"], "confidence": 0.8, "model": "gemini"}
with patch("app.services.pipeline.extract_exif", return_value=mock_exif), \
patch("app.services.pipeline.extract_text", return_value=mock_ocr), \
patch("app.services.pipeline.analyze_image", return_value=mock_ai):
await process_image_pipeline(image.id, db_session)
await db_session.refresh(image)
assert image.processing_status == ProcessingStatus.DONE
assert image.exif_make == "Canon"
assert image.ocr_text == "Hello"
assert image.ai_description == "A photo"
@pytest.mark.asyncio
async def test_pipeline_partial_failure(db_session: AsyncSession, client_a):
image = create_test_image(client_id=client_a.id)
db_session.add(image)
await db_session.commit()
await db_session.refresh(image)
mock_ocr = {"text": "Hello", "has_text": True}
mock_ai = {"description": "A photo", "tags": ["tag1"]}
# Step 1 fails, Step 2 & 3 succeed
with patch("app.services.pipeline.extract_exif", side_effect=Exception("EXIF error")), \
patch("app.services.pipeline.extract_text", return_value=mock_ocr), \
patch("app.services.pipeline.analyze_image", return_value=mock_ai):
await process_image_pipeline(image.id, db_session)
await db_session.refresh(image)
assert image.processing_status == ProcessingStatus.DONE # Still DONE because AI succeeded
assert "EXIF error" in image.processing_error
assert image.ocr_text == "Hello"
@pytest.mark.asyncio
async def test_pipeline_total_failure(db_session: AsyncSession, client_a):
image = create_test_image(client_id=client_a.id)
db_session.add(image)
await db_session.commit()
await db_session.refresh(image)
# All steps fail
with patch("app.services.pipeline.extract_exif", side_effect=Exception("EXIF error")), \
patch("app.services.pipeline.extract_text", side_effect=Exception("OCR error")), \
patch("app.services.pipeline.analyze_image", side_effect=Exception("AI error")):
await process_image_pipeline(image.id, db_session)
await db_session.refresh(image)
assert image.processing_status == ProcessingStatus.ERROR
assert "EXIF error" in image.processing_error
assert "OCR error" in image.processing_error
assert "AI error" in image.processing_error
@pytest.mark.asyncio
async def test_pipeline_ocr_fallback(db_session: AsyncSession, client_a):
image = create_test_image(client_id=client_a.id)
db_session.add(image)
await db_session.commit()
await db_session.refresh(image)
mock_exif = {}
mock_ocr_empty = {"has_text": False}
mock_ai_ocr = {"text": "AI extracted text", "has_text": True}
mock_ai_vision = {"description": "A photo"}
with patch("app.services.pipeline.extract_exif", return_value=mock_exif), \
patch("app.services.pipeline.extract_text", return_value=mock_ocr_empty), \
patch("app.services.pipeline.extract_text_with_ai", return_value=mock_ai_ocr), \
patch("app.services.pipeline.analyze_image", return_value=mock_ai_vision):
await process_image_pipeline(image.id, db_session)
await db_session.refresh(image)
assert image.ocr_text == "AI extracted text"
assert image.ocr_has_text is True
+107
View File
@@ -0,0 +1,107 @@
"""
Tests de rate limiting — vérification des limites par client.
"""
import io
import pytest
from unittest.mock import patch, AsyncMock
from httpx import AsyncClient
from app.models.client import APIClient, ClientPlan
from app.dependencies.auth import hash_api_key
pytestmark = pytest.mark.asyncio
# ─────────────────────────────────────────────────────────────
# Helper : upload rapide
# ─────────────────────────────────────────────────────────────
async def _quick_upload(async_client: AsyncClient, headers: dict) -> int:
"""Upload rapide et retourne le status code."""
# Image JPEG minimale
jpeg_bytes = (
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
b"\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t"
b"\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a"
b"\x1f\x1e\x1d\x1a\x1c\x1c $.\' \",#\x1c\x1c(7),01444\x1f\'9=82<.342"
b"\xff\xc0\x00\x0b\x08\x00\x01\x00\x01\x01\x01\x11\x00"
b"\xff\xc4\x00\x1f\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b"
b"\xff\xc4\x00\xb5\x10\x00\x02\x01\x03\x03\x02\x04\x03\x05\x05\x04"
b"\x04\x00\x00\x01}\x01\x02\x03\x00\x04\x11\x05\x12!1A\x06\x13Qa\x07"
b"\x22q\x142\x81\x91\xa1\x08#B\xb1\xc1\x15R\xd1\xf0$3br\x82\t\n\x16"
b"\x17\x18\x19\x1a%&\'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz\x83"
b"\x84\x85\x86\x87\x88\x89\x8a\x92\x93\x94\x95\x96\x97\x98\x99\x9a"
b"\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xb2\xb3\xb4\xb5\xb6\xb7\xb8"
b"\xb9\xba\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xd2\xd3\xd4\xd5\xd6"
b"\xd7\xd8\xd9\xda\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xf1\xf2"
b"\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa"
b"\xff\xda\x00\x08\x01\x01\x00\x00?\x00T\xdb\xae\x8a(\x03\xff\xd9"
)
# Le pipeline ARQ est mocké globalement dans conftest.py
response = await async_client.post(
"/images/upload",
files={"file": ("test.jpg", io.BytesIO(jpeg_bytes), "image/jpeg")},
headers=headers,
)
return response.status_code
# ─────────────────────────────────────────────────────────────
# Rate limit headers sont présents dans les réponses
# ─────────────────────────────────────────────────────────────
async def test_rate_limit_headers_present(
async_client: AsyncClient,
client_a: APIClient,
auth_headers_a: dict,
):
# Le pipeline ARQ est mocké globalement dans conftest.py
jpeg_bytes = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
response = await async_client.post(
"/images/upload",
files={"file": ("test.jpg", io.BytesIO(jpeg_bytes), "image/jpeg")},
headers=auth_headers_a,
)
# slowapi met des headers rate limit dans la réponse
# Vérifier que la réponse est soit 201 (succès) soit contient des headers rate limit
assert response.status_code in (201, 429)
# ─────────────────────────────────────────────────────────────
# Compteurs distincts par client
# ─────────────────────────────────────────────────────────────
async def test_rate_limit_per_client_independent(
async_client: AsyncClient,
client_a: APIClient,
client_b: APIClient,
auth_headers_a: dict,
auth_headers_b: dict,
):
"""Les compteurs de rate limit sont indépendants par client."""
# Uploads pour A
status_a = await _quick_upload(async_client, auth_headers_a)
assert status_a == 201
# Uploads pour B (ne doit pas être affecté par les requêtes de A)
status_b = await _quick_upload(async_client, auth_headers_b)
assert status_b == 201
# ─────────────────────────────────────────────────────────────
# Vérification fonctionnelle du rate limiter
# ─────────────────────────────────────────────────────────────
async def test_rate_limiter_is_configured(
async_client: AsyncClient,
client_a: APIClient,
auth_headers_a: dict,
):
"""Le rate limiter est bien actif sur le endpoint upload."""
from app.middleware import limiter
# Le limiter doit être configuré et actif
assert limiter is not None
assert limiter.enabled
+45
View File
@@ -0,0 +1,45 @@
import pytest
import respx
from httpx import Response
from app.services.scraper import fetch_page_content
@pytest.mark.asyncio
@respx.mock
async def test_fetch_page_content_success():
url = "https://example.com"
html_content = """
<html>
<head><title>Test Title</title><meta name="description" content="Test Description"></head>
<body>
<article>
<p>This is a long enough paragraph to be captured by the scraper logic which requires > 30 chars.</p>
<p>Another long paragraph that should be joined with the previous one for the final text.</p>
</article>
</body>
</html>
"""
respx.get(url).mock(return_value=Response(200, text=html_content))
result = await fetch_page_content(url)
assert result["title"] == "Test Title"
assert result["description"] == "Test Description"
assert "This is a long enough paragraph" in result["text"]
assert result["error"] is None
@pytest.mark.asyncio
@respx.mock
async def test_fetch_page_content_http_error():
url = "https://example.com/404"
respx.get(url).mock(return_value=Response(404))
result = await fetch_page_content(url)
assert result["error"] == "HTTP 404"
@pytest.mark.asyncio
@respx.mock
async def test_fetch_page_content_request_error():
url = "https://broken.url"
respx.get(url).mock(side_effect=Exception("Connection reset"))
result = await fetch_page_content(url)
assert "Connection reset" in result["error"]
+121
View File
@@ -0,0 +1,121 @@
"""
Tests unitaires — services et endpoints
"""
import pytest
import asyncio
from unittest.mock import patch, MagicMock
from pathlib import Path
# ── Tests EXIF ────────────────────────────────────────────────
def test_exif_missing_file():
from app.services.exif_service import extract_exif
result = extract_exif("/tmp/non_existant.jpg")
assert result["make"] is None
assert result["gps_lat"] is None
assert result["raw"] == {}
def test_exif_dms_to_decimal():
from app.services.exif_service import _dms_to_decimal
# Paris : 48°51'24.12"N → ~48.856700
dms = ((48, 1), (51, 1), (2412, 100))
result = _dms_to_decimal(dms, "N")
assert result is not None
assert 48.85 < result < 48.86
# Direction Sud = négatif
result_s = _dms_to_decimal(dms, "S")
assert result_s < 0
# ── Tests OCR ─────────────────────────────────────────────────
def test_ocr_disabled(monkeypatch):
monkeypatch.setattr("app.services.ocr_service.settings.OCR_ENABLED", False)
from app.services.ocr_service import extract_text
result = extract_text("/tmp/test.jpg")
assert result["has_text"] is False
assert result["text"] is None
def test_ocr_language_detection():
from app.services.ocr_service import _detect_language
fr_text = "le chat est sur le tapis et la maison est grande"
en_text = "the cat is on the mat and the house is great"
assert _detect_language(fr_text) == "fr"
assert _detect_language(en_text) == "en"
assert _detect_language("") == "unknown"
# ── Tests Storage ─────────────────────────────────────────────
def test_generate_filename():
from app.services.storage import _generate_filename
filename, uuid = _generate_filename("photo.jpg")
assert filename.endswith(".jpg")
assert len(uuid) == 36 # UUID format
def test_generate_filename_no_extension():
from app.services.storage import _generate_filename
filename, uuid = _generate_filename("image")
assert filename.endswith(".jpg") # extension par défaut
# ── Tests Schémas ─────────────────────────────────────────────
def test_image_detail_schema():
from app.schemas import ImageDetail
from app.models.image import ProcessingStatus
# Simule un objet Image ORM
mock_img = MagicMock()
mock_img.id = 1
mock_img.uuid = "abc-123"
mock_img.original_name = "test.jpg"
mock_img.mime_type = "image/jpeg"
mock_img.file_size = 1024
mock_img.width = 800
mock_img.height = 600
mock_img.uploaded_at = None
mock_img.processing_status = ProcessingStatus.DONE
mock_img.thumbnail_path = None
mock_img.exif_make = "Canon"
mock_img.exif_model = "EOS R5"
mock_img.exif_lens = None
mock_img.exif_iso = 400
mock_img.exif_aperture = "f/2.8"
mock_img.exif_shutter = "1/250"
mock_img.exif_focal = "50mm"
mock_img.exif_flash = False
mock_img.exif_orientation = 1
mock_img.exif_software = None
mock_img.exif_taken_at = None
mock_img.exif_gps_lat = 48.857
mock_img.exif_gps_lon = 2.295
mock_img.exif_altitude = 35.0
mock_img.exif_raw = {}
mock_img.has_gps = True
mock_img.ocr_text = "Bonjour monde"
mock_img.ocr_language = "fr"
mock_img.ocr_confidence = 0.95
mock_img.ocr_has_text = True
mock_img.ai_description = "Une belle photo"
mock_img.ai_tags = ["nature", "paysage"]
mock_img.ai_confidence = 0.92
mock_img.ai_model_used = "gemini-1.5-pro"
mock_img.ai_processed_at = None
mock_img.ai_prompt_tokens = 500
mock_img.ai_output_tokens = 200
mock_img.processing_error = None
mock_img.processing_started_at = None
mock_img.processing_done_at = None
detail = ImageDetail.from_orm_full(mock_img)
assert detail.id == 1
assert detail.exif.camera.make == "Canon"
assert detail.exif.gps.has_gps is True
assert detail.ocr.has_text is True
assert detail.ai.tags == ["nature", "paysage"]
+46
View File
@@ -0,0 +1,46 @@
import pytest
from fastapi import HTTPException
from app.services.storage import save_upload, delete_files, get_image_url
from unittest.mock import MagicMock, patch, AsyncMock
import io
@pytest.mark.asyncio
async def test_save_upload_unsupported_mime():
mock_file = MagicMock()
mock_file.content_type = "text/plain"
with pytest.raises(HTTPException) as exc:
await save_upload(mock_file, "client_id")
assert exc.value.status_code == 415
@pytest.mark.asyncio
@patch("app.services.storage.settings")
async def test_save_upload_too_large(mock_settings):
mock_settings.max_upload_bytes = 10
mock_settings.MAX_UPLOAD_SIZE_MB = 10
mock_file = MagicMock()
mock_file.content_type = "image/jpeg"
mock_file.read = AsyncMock(return_value=b"a" * 20)
with pytest.raises(HTTPException) as exc:
await save_upload(mock_file, "client_id")
assert exc.value.status_code == 413
def test_delete_files_exists(tmp_path):
f1 = tmp_path / "f1.txt"
f1.write_text("hello")
f2 = tmp_path / "f2.txt"
f2.write_text("world")
assert f1.exists()
assert f2.exists()
delete_files(str(f1), str(f2))
assert not f1.exists()
assert not f2.exists()
def test_get_image_url():
url = get_image_url("img.jpg", "client1")
assert "/static/uploads/client1/img.jpg" in url
thumb_url = get_image_url("img.jpg", "client1", thumb=True)
assert "/static/thumbnails/client1/img.jpg" in thumb_url