Add admin panel, WebSocket support, and API versioning
CI / Lint & Format (push) Failing after 28s
CI / Tests (push) Has been skipped
CI / Security Scan (push) Failing after 8s
CI / Docker Build (push) Has been skipped

Introduce an admin portal (React + Nginx), WebSocket routing, and
API versioning middleware with `/api/v1/` prefix deprecation.
Add master API key authentication, new Prometheus metrics for AI
token consumption and active WebSockets, and extend S3 config
with a public endpoint URL. Update test paths and fixtures to
align with the new routing structure.
This commit is contained in:
2026-06-22 11:25:22 -04:00
parent d68deb9c74
commit 2f2a5e9b4a
158 changed files with 64811 additions and 503 deletions
+9 -7
View File
@@ -55,7 +55,7 @@ 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
from unittest.mock import AsyncMock, patch
_mock_arq_pool = AsyncMock()
_mock_arq_pool.enqueue_job = AsyncMock(return_value=None)
@@ -75,12 +75,14 @@ app.state.redis = _mock_redis
@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)
"""Crée et détruit toutes les tables avant/après chaque test. Patch also STORAGE_BACKEND."""
with patch("app.services.storage.settings.STORAGE_BACKEND", "local"), \
patch("app.config.settings.STORAGE_BACKEND", "local"):
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
+52
View File
@@ -0,0 +1,52 @@
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_admin_stats(async_client: AsyncClient, admin_headers: dict, admin_client):
response = await async_client.get("/admin/api/stats", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert "total_images" in data
assert "total_storage_bytes" in data
assert "total_ai_tokens" in data
assert "total_clients" in data
assert data["total_clients"] >= 1 # at least the admin client
@pytest.mark.asyncio
async def test_admin_clients(async_client: AsyncClient, admin_headers: dict, admin_client):
response = await async_client.get("/admin/api/clients", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) >= 1
assert data[0]["name"] == "Admin Client"
@pytest.mark.asyncio
async def test_admin_queue_status(async_client: AsyncClient, admin_headers: dict, admin_client):
response = await async_client.get("/admin/api/queue/status", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert "pending_jobs" in data
assert data["status"] == "active"
@pytest.mark.asyncio
async def test_admin_toggle_and_reset(async_client: AsyncClient, admin_headers: dict, admin_client, client_a):
# Get all clients
resp = await async_client.get("/admin/api/clients", headers=admin_headers)
clients = resp.json()
# Find client_a to toggle (don't toggle the admin client we're using!)
target_client = next(c for c in clients if c["id"] != admin_client.id)
client_id = target_client["id"]
is_active_initial = target_client["is_active"]
# Toggle
toggle_resp = await async_client.post(f"/admin/api/clients/{client_id}/toggle", headers=admin_headers)
assert toggle_resp.status_code == 200
assert toggle_resp.json()["is_active"] == (not is_active_initial)
# Reset Quota
reset_resp = await async_client.post(f"/admin/api/clients/{client_id}/reset-quota", headers=admin_headers)
assert reset_resp.status_code == 200
assert reset_resp.json()["storage_used"] == 0
+3 -3
View File
@@ -20,7 +20,7 @@ async def test_ai_summarize(async_client: AsyncClient, client_a, auth_headers_a)
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)
response = await async_client.post("/api/v1/ai/summarize", json=payload, headers=auth_headers_a)
assert response.status_code == 200
data = response.json()
@@ -42,7 +42,7 @@ async def test_ai_draft_task(async_client: AsyncClient, client_a, auth_headers_a
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)
response = await async_client.post("/api/v1/ai/draft-task", json=payload, headers=auth_headers_a)
assert response.status_code == 200
data = response.json()
@@ -52,6 +52,6 @@ async def test_ai_draft_task(async_client: AsyncClient, client_a, auth_headers_a
@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)
response = await async_client.post("/api/v1/ai/draft-task", json={"description": "test"}, headers=auth_headers_a)
assert response.status_code == 503
assert response.json()["detail"] == "AI désactivée"
+6 -5
View File
@@ -2,7 +2,7 @@ import pytest
import os
import base64
from pathlib import Path
from unittest.mock import patch, MagicMock
from unittest.mock import patch, MagicMock, AsyncMock
from app.services.ai_vision import (
_read_image, _extract_json, _usage_tokens_gemini,
_generate_gemini, _generate_openrouter, _generate,
@@ -10,11 +10,12 @@ from app.services.ai_vision import (
)
from app.config import settings
def test_read_image_variants(tmp_path):
@pytest.mark.asyncio
async 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))
data, mime = await _read_image(str(img_file))
assert data == b"fake_png_data"
assert mime == "image/png"
@@ -67,7 +68,7 @@ async def test_generate_dispatcher_openrouter(mock_settings, mock_gen_or):
@pytest.mark.asyncio
@patch("app.services.ai_vision._generate")
@patch("app.services.ai_vision._read_image")
@patch("app.services.ai_vision._read_image", new_callable=AsyncMock)
@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
@@ -83,7 +84,7 @@ async def test_extract_text_with_ai(mock_settings, mock_read_image, mock_generat
@pytest.mark.asyncio
@patch("app.services.ai_vision._generate")
@patch("app.services.ai_vision._read_image")
@patch("app.services.ai_vision._read_image", new_callable=AsyncMock)
@patch("app.services.ai_vision.settings")
async def test_analyze_image_failure(mock_settings, mock_read_image, mock_generate):
mock_settings.AI_ENABLED = True
+19 -19
View File
@@ -18,14 +18,14 @@ pytestmark = pytest.mark.asyncio
async def test_no_auth_returns_401(async_client: AsyncClient):
"""Requête sans header Authorization → HTTP 401."""
response = await async_client.get("/images")
response = await async_client.get("/api/v1/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",
"/api/v1/images",
headers={"Authorization": "Bearer invalid-key-that-does-not-exist"},
)
assert response.status_code == 401
@@ -35,7 +35,7 @@ async def test_invalid_key_returns_401(async_client: AsyncClient):
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",
"/api/v1/images",
headers={"Authorization": "Basic some-key"},
)
assert response.status_code == 401
@@ -44,7 +44,7 @@ async def test_no_bearer_prefix_returns_401(async_client: AsyncClient):
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",
"/api/v1/images",
headers={"Authorization": "Bearer "},
)
assert response.status_code == 401
@@ -60,7 +60,7 @@ async def test_valid_key_returns_200(
auth_headers_a: dict,
):
"""Requête avec une clé valide → HTTP 200."""
response = await async_client.get("/images", headers=auth_headers_a)
response = await async_client.get("/api/v1/images", headers=auth_headers_a)
assert response.status_code == 200
@@ -80,7 +80,7 @@ async def test_inactive_client_returns_401(
db_session.add(client_a)
await db_session.commit()
response = await async_client.get("/images", headers=auth_headers_a)
response = await async_client.get("/api/v1/images", headers=auth_headers_a)
assert response.status_code == 401
@@ -106,7 +106,7 @@ async def test_missing_scope_returns_403(
# Tenter d'accéder aux endpoints admin
response = await async_client.get(
"/auth/clients",
"/api/v1/auth/clients",
headers={"Authorization": f"Bearer {key}"},
)
assert response.status_code == 403
@@ -119,7 +119,7 @@ async def test_scope_images_read_allowed(
auth_headers_a: dict,
):
"""Client avec scope images:read peut lister les images."""
response = await async_client.get("/images", headers=auth_headers_a)
response = await async_client.get("/api/v1/images", headers=auth_headers_a)
assert response.status_code == 200
@@ -136,12 +136,12 @@ async def test_key_rotation(
):
"""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)
response = await async_client.get("/api/v1/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",
f"/api/v1/auth/clients/{client_a.id}/rotate-key",
headers=admin_headers,
)
assert response.status_code == 200
@@ -149,12 +149,12 @@ async def test_key_rotation(
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)
response = await async_client.get("/api/v1/images", headers=auth_headers_a)
assert response.status_code == 401
# La nouvelle clé fonctionne
response = await async_client.get(
"/images",
"/api/v1/images",
headers={"Authorization": f"Bearer {new_key}"},
)
assert response.status_code == 200
@@ -171,7 +171,7 @@ async def test_create_client_returns_key_once(
):
"""La clé API est retournée une seule fois à la création."""
response = await async_client.post(
"/auth/clients",
"/api/v1/auth/clients",
json={
"name": "New Test Client",
"scopes": ["images:read"],
@@ -189,7 +189,7 @@ async def test_create_client_returns_key_once(
# 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}",
f"/api/v1/auth/clients/{client_id}",
headers=admin_headers,
)
assert response.status_code == 200
@@ -209,11 +209,11 @@ async def test_list_clients_admin_only(
):
"""Seul un admin peut lister les clients."""
# Non-admin → 403
response = await async_client.get("/auth/clients", headers=auth_headers_a)
response = await async_client.get("/api/v1/auth/clients", headers=auth_headers_a)
assert response.status_code == 403
# Admin → 200
response = await async_client.get("/auth/clients", headers=admin_headers)
response = await async_client.get("/api/v1/auth/clients", headers=admin_headers)
assert response.status_code == 200
@@ -225,7 +225,7 @@ async def test_update_client(
):
"""L'admin peut modifier les scopes et le plan d'un client."""
response = await async_client.patch(
f"/auth/clients/{client_a.id}",
f"/api/v1/auth/clients/{client_a.id}",
json={"plan": "premium", "scopes": ["images:read"]},
headers=admin_headers,
)
@@ -243,12 +243,12 @@ async def test_soft_delete_client(
):
"""Soft delete désactive le client — les requêtes suivantes retournent 401."""
response = await async_client.delete(
f"/auth/clients/{client_a.id}",
f"/api/v1/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)
response = await async_client.get("/api/v1/images", headers=auth_headers_a)
assert response.status_code == 401
+8 -2
View File
@@ -8,10 +8,16 @@ def test_dms_to_decimal():
assert _dms_to_decimal(dms, "N") == 48.8566167
assert _dms_to_decimal(dms, "S") == -48.8566167
@pytest.mark.asyncio
@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):
@patch("app.services.exif_service.get_storage_backend")
async def test_extract_exif_success(mock_get_storage_backend, mock_path_exists, mock_pil, mock_piexif):
from unittest.mock import AsyncMock
mock_backend = MagicMock()
mock_backend.get_bytes = AsyncMock(return_value=b"fake")
mock_get_storage_backend.return_value = mock_backend
# Mock piexif data
import piexif
mock_data = {
@@ -45,7 +51,7 @@ def test_extract_exif_success(mock_path_exists, mock_pil, mock_piexif):
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")
result = await extract_exif("fake/path.jpg")
assert result["make"] == "Canon"
assert result["model"] == "EOS R5"
+6 -6
View File
@@ -31,7 +31,7 @@ async def test_get_image_exif(async_client: AsyncClient, client_a, auth_headers_
await db_session.commit()
await db_session.refresh(image)
response = await async_client.get(f"/images/{image.id}/exif", headers=auth_headers_a)
response = await async_client.get(f"/api/v1/images/{image.id}/exif", headers=auth_headers_a)
assert response.status_code == 200
data = response.json()
assert data["camera"]["make"] == "Apple"
@@ -52,7 +52,7 @@ async def test_get_image_ocr(async_client: AsyncClient, client_a, auth_headers_a
await db_session.commit()
await db_session.refresh(image)
response = await async_client.get(f"/images/{image.id}/ocr", headers=auth_headers_a)
response = await async_client.get(f"/api/v1/images/{image.id}/ocr", headers=auth_headers_a)
assert response.status_code == 200
data = response.json()
assert data["has_text"] is True
@@ -72,7 +72,7 @@ async def test_get_image_ai(async_client: AsyncClient, client_a, auth_headers_a,
await db_session.commit()
await db_session.refresh(image)
response = await async_client.get(f"/images/{image.id}/ai", headers=auth_headers_a)
response = await async_client.get(f"/api/v1/images/{image.id}/ai", headers=auth_headers_a)
assert response.status_code == 200
data = response.json()
assert data["description"] == "A beautiful landscape"
@@ -85,7 +85,7 @@ async def test_get_all_tags(async_client: AsyncClient, client_a, auth_headers_a,
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)
response = await async_client.get("/api/v1/images/tags/all", headers=auth_headers_a)
assert response.status_code == 200
data = response.json()
assert set(data["tags"]) == {"tag1", "tag2", "tag3"}
@@ -102,7 +102,7 @@ async def test_reprocess_image(async_client: AsyncClient, client_a, auth_headers
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)
response = await async_client.post(f"/api/v1/images/{image.id}/reprocess", headers=auth_headers_a)
assert response.status_code == 200
await db_session.refresh(image)
@@ -120,7 +120,7 @@ async def test_delete_image(async_client: AsyncClient, client_a, auth_headers_a,
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)
response = await async_client.delete(f"/api/v1/images/{image.id}", headers=auth_headers_a)
assert response.status_code == 200
mock_delete.assert_called_once_with("fake/path.jpg", "fake/thumb.jpg")
+24 -16
View File
@@ -46,12 +46,20 @@ async def _upload_test_image(
)
# 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
import httpx
print(f"Uploading {filename}...")
try:
response = await async_client.post(
"/api/v1/images/upload",
files={"file": (filename, io.BytesIO(jpeg_bytes), "image/jpeg")},
headers=headers,
timeout=httpx.Timeout(5.0)
)
print(f"Uploaded {filename}: {response.status_code}")
return response
except Exception as e:
print(f"Error uploading {filename}: {e}")
raise
# ─────────────────────────────────────────────────────────────
@@ -71,14 +79,14 @@ async def test_client_a_image_invisible_to_client_b(
assert upload_resp.status_code == 201
# Liste pour A → contient l'image
response = await async_client.get("/images", headers=auth_headers_a)
response = await async_client.get("/api/v1/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)
response = await async_client.get("/api/v1/images", headers=auth_headers_b)
assert response.status_code == 200
data_b = response.json()
assert data_b["total"] == 0
@@ -103,7 +111,7 @@ async def test_client_b_cannot_read_client_a_image(
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)
response = await async_client.get(f"/api/v1/images/{image_id}", headers=auth_headers_b)
assert response.status_code == 404
@@ -125,11 +133,11 @@ async def test_client_b_cannot_delete_client_a_image(
image_id = upload_resp.json()["id"]
# B essaie de supprimer → 404
response = await async_client.delete(f"/images/{image_id}", headers=auth_headers_b)
response = await async_client.delete(f"/api/v1/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)
response = await async_client.get(f"/api/v1/images/{image_id}", headers=auth_headers_a)
assert response.status_code == 200
@@ -153,11 +161,11 @@ async def test_listing_returns_only_own_images(
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)
resp_a = await async_client.get("/api/v1/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)
resp_b = await async_client.get("/api/v1/images", headers=auth_headers_b)
assert resp_b.json()["total"] == 1
assert resp_b.json()["items"][0]["original_name"] == "b1.jpg"
@@ -181,7 +189,7 @@ async def test_reprocess_other_client_image_returns_404(
# B essaie de reprocess → 404
response = await async_client.post(
f"/images/{image_id}/reprocess",
f"/api/v1/images/{image_id}/reprocess",
headers=auth_headers_b,
)
assert response.status_code == 404
@@ -203,7 +211,7 @@ async def test_sub_endpoints_other_client_returns_404(
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"]:
for endpoint in [f"/api/v1/images/{image_id}/status", f"/api/v1/images/{image_id}/exif",
f"/api/v1/images/{image_id}/ocr", f"/api/v1/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"
+11 -4
View File
@@ -8,11 +8,17 @@ def test_detect_language():
assert _detect_language("") == "unknown"
assert _detect_language("123456789") == "unknown"
@pytest.mark.asyncio
@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):
@patch("app.services.ocr_service.get_storage_backend")
async def test_extract_text_success(mock_get_storage_backend, mock_settings, mock_path_exists, mock_pil, mock_tesseract):
from unittest.mock import AsyncMock
mock_backend = MagicMock()
mock_backend.get_bytes = AsyncMock(return_value=b"fake")
mock_get_storage_backend.return_value = mock_backend
mock_settings.OCR_ENABLED = True
mock_settings.OCR_LANGUAGES = "fra+eng"
mock_settings.TESSERACT_CMD = None
@@ -26,16 +32,17 @@ def test_extract_text_success(mock_settings, mock_path_exists, mock_pil, mock_te
# Mock image_to_string
mock_tesseract.image_to_string.return_value = "This is a test OCR output."
result = extract_text("fake/path.jpg")
result = await 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"
@pytest.mark.asyncio
@patch("app.services.ocr_service.settings")
def test_extract_text_disabled(mock_settings):
async def test_extract_text_disabled(mock_settings):
mock_settings.OCR_ENABLED = False
result = extract_text("any/path.jpg")
result = await extract_text("any/path.jpg")
assert result["has_text"] is False
assert result["text"] is None
+2 -2
View File
@@ -42,7 +42,7 @@ async def _quick_upload(async_client: AsyncClient, headers: dict) -> int:
# Le pipeline ARQ est mocké globalement dans conftest.py
response = await async_client.post(
"/images/upload",
"/api/v1/images/upload",
files={"file": ("test.jpg", io.BytesIO(jpeg_bytes), "image/jpeg")},
headers=headers,
)
@@ -61,7 +61,7 @@ async def test_rate_limit_headers_present(
# 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",
"/api/v1/images/upload",
files={"file": ("test.jpg", io.BytesIO(jpeg_bytes), "image/jpeg")},
headers=auth_headers_a,
)
+8 -4
View File
@@ -9,9 +9,10 @@ from pathlib import Path
# ── Tests EXIF ────────────────────────────────────────────────
def test_exif_missing_file():
@pytest.mark.asyncio
async def test_exif_missing_file():
from app.services.exif_service import extract_exif
result = extract_exif("/tmp/non_existant.jpg")
result = await extract_exif("/tmp/non_existant.jpg")
assert result["make"] is None
assert result["gps_lat"] is None
assert result["raw"] == {}
@@ -32,10 +33,11 @@ def test_exif_dms_to_decimal():
# ── Tests OCR ─────────────────────────────────────────────────
def test_ocr_disabled(monkeypatch):
@pytest.mark.asyncio
async 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")
result = await extract_text("/tmp/test.jpg")
assert result["has_text"] is False
assert result["text"] is None
@@ -72,6 +74,8 @@ def test_image_detail_schema():
# Simule un objet Image ORM
mock_img = MagicMock()
mock_img.client_id = "test-client-123"
mock_img.client.name = "Test Client"
mock_img.id = 1
mock_img.uuid = "abc-123"
mock_img.original_name = "test.jpg"
+53
View File
@@ -0,0 +1,53 @@
import sys
import os
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
# Add sdk to path so we can import it locally if not installed
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'sdk')))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from integration.shaarli.hub_plugin import ShaarliHubPlugin
from imago_client.exceptions import ImagoError
from imago_client.models import AiData
@pytest.mark.asyncio
async def test_shaarli_plugin_process_bookmark_success():
"""Test processing a bookmark which correctly uses the HubClient."""
plugin = ShaarliHubPlugin(api_url="http://test/api/v1", api_key="sk-test")
# Mock the underlying HubClient.ai.draft_task
mock_ai = AsyncMock()
mock_ai.draft_task.return_value = {
"title": "Sample Title",
"description": "Sample description",
"suggested_tags": ["tech", "ai"]
}
# Context manager mock
plugin.client.ai = mock_ai
plugin.client.__aenter__ = AsyncMock(return_value=plugin.client)
plugin.client.__aexit__ = AsyncMock(return_value=False)
result = await plugin.process_bookmark("https://example.com", "Test", "Test desc")
assert "suggested_tags" in result
assert result["suggested_tags"] == ["tech", "ai"]
mock_ai.draft_task.assert_awaited_once_with(url="https://example.com", prompt="Test desc")
@pytest.mark.asyncio
async def test_shaarli_plugin_process_bookmark_error():
"""Test handling of ImagoError during processing."""
plugin = ShaarliHubPlugin(api_url="http://test/api/v1", api_key="sk-test")
mock_ai = AsyncMock()
mock_ai.draft_task.side_effect = ImagoError("Quota exceeded")
plugin.client.ai = mock_ai
plugin.client.__aenter__ = AsyncMock(return_value=plugin.client)
plugin.client.__aexit__ = AsyncMock(return_value=False)
result = await plugin.process_bookmark("https://example.com", "Test", "Test desc")
assert "error" in result
assert result["error"] == "Quota exceeded"
+7 -7
View File
@@ -1,6 +1,7 @@
import pytest
from fastapi import HTTPException
from app.services.storage import save_upload, delete_files, get_image_url
from app.services.storage import save_upload, delete_files
from app.services.storage_backend import LocalStorage, S3Storage
from unittest.mock import MagicMock, patch, AsyncMock
import io
@@ -38,9 +39,8 @@ def test_delete_files_exists(tmp_path):
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
@pytest.mark.asyncio
async def test_get_signed_url():
storage = LocalStorage("/", "test_secret")
url = await storage.get_signed_url("uploads/client1/img.jpg")
assert url.startswith("/files/signed/")
+64
View File
@@ -0,0 +1,64 @@
"""
Tests pour vérifier le versionnement de l'API (Middleware & router prefix).
"""
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app as main_app
@pytest.mark.asyncio
async def test_api_v1_headers():
"""
Vérifie que les requêtes sous /api/v1/ renvoient bien l'entête X-API-Version.
"""
async with AsyncClient(
transport=ASGITransport(app=main_app), base_url="http://testserver"
) as client:
# On teste une route qui retourne typiquement un 401 ou 422
# mais on vérifie juste la présence de l'entête
response = await client.get("/api/v1/images/")
assert "x-api-version" in response.headers
assert response.headers["x-api-version"] == "v1"
@pytest.mark.asyncio
async def test_non_versioned_routes_no_header():
"""
Vérifie que les requêtes hors /api/v1/ (ex: /health) n'ont pas l'entête.
"""
async with AsyncClient(
transport=ASGITransport(app=main_app), base_url="http://testserver"
) as client:
response = await client.get("/health")
assert response.status_code == 200
assert "x-api-version" not in response.headers
@pytest.mark.asyncio
async def test_sunset_header_if_configured(monkeypatch):
"""
Vérifie que l'entête Sunset est ajouté si la config est présente.
"""
import app.middleware.versioning
# Mock settings in the versioning module
class MockSettings:
API_V1_SUNSET_DATE = "2025-12-31"
monkeypatch.setattr(app.middleware.versioning, "settings", MockSettings())
async with AsyncClient(
transport=ASGITransport(app=main_app), base_url="http://testserver"
) as client:
response = await client.get("/api/v1/images/")
assert "sunset" in response.headers
assert response.headers["sunset"] == "2025-12-31"
@pytest.mark.asyncio
async def test_deprecated_root_paths_return_404():
"""
Vérifie que l'ancienne route /images renvoie bien un 404.
"""
async with AsyncClient(
transport=ASGITransport(app=main_app), base_url="http://testserver"
) as client:
response = await client.get("/images/")
assert response.status_code == 404
+159
View File
@@ -0,0 +1,159 @@
"""
Tests WebSocket — suivi pipeline temps réel.
Utilise le TestClient de Starlette pour les WebSockets.
"""
import pytest
from unittest.mock import patch
from starlette.testclient import TestClient
from app.main import app
from app.models.image import Image, ProcessingStatus
from app.models.client import APIClient
# Client synchrone pour les WebSockets (Starlette TestClient)
# On mocke complètement la DB pour éviter les soucis d'event loop (greenlet_spawn)
sync_client = TestClient(app)
# ─────────────────────────────────────────────────────────────
# Tests
# ─────────────────────────────────────────────────────────────
class TestWSPipelineNoToken:
"""Connexion sans token → refus immédiat."""
def test_no_token_rejected(self):
"""WS sans token reçoit un close 4001."""
with pytest.raises(Exception):
with sync_client.websocket_connect("/ws/pipeline/1"):
pass
@patch("app.routers.websocket._get_image")
@patch("app.routers.websocket._authenticate_ws")
class TestWSPipelineAuth:
"""Tests d'authentification WebSocket appliqués avec un mock de DB."""
def test_invalid_token_rejected(self, mock_auth, mock_get_image):
"""WS avec un token invalide reçoit un close."""
mock_auth.return_value = None
with pytest.raises(Exception):
with sync_client.websocket_connect("/ws/pipeline/1?token=invalid-key"):
pass
def test_valid_token_accepted(self, mock_auth, mock_get_image):
"""WS avec token valide d'un client qui possède l'image → accepté."""
# Mock du client authentifié
client_mock = APIClient(id="client-a", scopes=[])
client_mock.has_scope = lambda x: False
mock_auth.return_value = client_mock
# Mock de l'image (DONE -> envoie message synthétique)
image_mock = Image()
image_mock.id = 1
image_mock.client_id = "client-a"
image_mock.processing_status = ProcessingStatus.DONE
mock_get_image.return_value = image_mock
with sync_client.websocket_connect(
"/ws/pipeline/1?token=valid-key"
) as ws:
data = ws.receive_json()
assert data["event"] == "pipeline.done"
assert data["synthetic"] is True
def test_wrong_owner_rejected(self, mock_auth, mock_get_image):
"""WS avec token d'un client B sur image de client A → refus 4003."""
# Mock du client authentifié (client B)
client_mock = APIClient(id="client-b", scopes=[])
client_mock.has_scope = lambda x: False
mock_auth.return_value = client_mock
# Mock de l'image appartenant au client A
image_mock = Image()
image_mock.id = 1
image_mock.client_id = "client-a"
image_mock.processing_status = ProcessingStatus.PENDING
mock_get_image.return_value = image_mock
with pytest.raises(Exception):
with sync_client.websocket_connect(
"/ws/pipeline/1?token=valid-key"
):
pass
@patch("app.routers.websocket._get_image")
@patch("app.routers.websocket._authenticate_ws")
class TestWSPipelineSynthetic:
"""Tests des messages synthétiques."""
def test_done_image_sends_synthetic(self, mock_auth, mock_get_image):
"""Image déjà 'done' → reçoit pipeline.done synthétique."""
client_mock = APIClient(id="client-a", scopes=[])
client_mock.has_scope = lambda x: False
mock_auth.return_value = client_mock
image_mock = Image()
image_mock.id = 1
image_mock.client_id = "client-a"
image_mock.processing_status = ProcessingStatus.DONE
mock_get_image.return_value = image_mock
with sync_client.websocket_connect(
"/ws/pipeline/1?token=valid-key"
) as ws:
data = ws.receive_json()
assert data["event"] == "pipeline.done"
assert data["image_id"] == 1
assert data["synthetic"] is True
def test_error_image_sends_synthetic(self, mock_auth, mock_get_image):
"""Image déjà 'error' → reçoit pipeline.error synthétique."""
client_mock = APIClient(id="client-a", scopes=[])
client_mock.has_scope = lambda x: False
mock_auth.return_value = client_mock
image_mock = Image()
image_mock.id = 1
image_mock.client_id = "client-a"
image_mock.processing_status = ProcessingStatus.ERROR
image_mock.processing_error = "AI timeout"
mock_get_image.return_value = image_mock
with sync_client.websocket_connect(
"/ws/pipeline/1?token=valid-key"
) as ws:
data = ws.receive_json()
assert data["event"] == "pipeline.error"
assert data["image_id"] == 1
assert "AI timeout" in data["error"]
assert data["synthetic"] is True
@patch("app.routers.websocket._authenticate_ws")
class TestWSAdminMonitor:
"""Tests du monitoring admin."""
def test_admin_no_token_rejected(self, mock_auth):
"""WS admin sans token → refus."""
mock_auth.return_value = None
with pytest.raises(Exception):
with sync_client.websocket_connect("/ws/admin/monitor"):
pass
def test_admin_non_admin_rejected(self, mock_auth):
"""WS admin avec token non-admin → refus 4003."""
client_mock = APIClient(id="client-a", scopes=[])
client_mock.has_scope = lambda x: False
mock_auth.return_value = client_mock
with pytest.raises(Exception):
with sync_client.websocket_connect(
"/ws/admin/monitor?token=valid-key"
):
pass