Add admin panel, WebSocket support, and API versioning
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:
+9
-7
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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/")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user