Add comprehensive test suite for image processing and related services
- 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:
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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__()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user