Refactor Nginx configuration and update project dependencies
- Cleaned up the Nginx configuration for the Coolify deployment, ensuring proper formatting and organization. - Updated the `pyproject.toml` file to maintain consistent formatting. - Reformatted the `requirements.txt` file for better readability while keeping the dependencies unchanged.
This commit is contained in:
1 parent
3b8c30c289
commit
a4b874598d
88 files changed
+9766
-9766
No files matched your search
+119
-119
@@ -1,119 +1,119 @@
|
||||
"""Integration tests for admin endpoints."""
|
||||
import os
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import app
|
||||
from app import db as db_module
|
||||
|
||||
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
db_module._conn = None
|
||||
db_module.init_db(":memory:")
|
||||
yield
|
||||
db_module.close_db()
|
||||
db_module._conn = None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(fresh_db):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
async def _register_login(client, email="user@example.com", password="secret123"):
|
||||
await client.post("/auth/register", json={"email": email, "password": password})
|
||||
resp = await client.post("/auth/login", json={"email": email, "password": password})
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _admin_token(client, email="admin@example.com", password="secret123"):
|
||||
tokens = await _register_login(client, email, password)
|
||||
me = await client.get("/users/me", headers={"Authorization": f"Bearer {tokens['access_token']}"})
|
||||
db_module.get_conn().execute(
|
||||
"UPDATE users SET role = 'admin' WHERE id = ?", [me.json()["id"]])
|
||||
login = await client.post("/auth/login", json={"email": email, "password": password})
|
||||
return login.json()["access_token"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /admin/stats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_stats_as_admin(client):
|
||||
await _register_login(client, "user1@example.com")
|
||||
await _register_login(client, "user2@example.com")
|
||||
token = await _admin_token(client)
|
||||
|
||||
resp = await client.get("/admin/stats", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# 2 users + 1 admin + 1 seeded admin (ai@allucanget.biz)
|
||||
assert data["users"]["total"] == 4
|
||||
assert "by_role" in data["users"]
|
||||
assert "refresh_tokens" in data
|
||||
|
||||
|
||||
async def test_stats_as_regular_user(client):
|
||||
tokens = await _register_login(client)
|
||||
resp = await client.get("/admin/stats", headers={"Authorization": f"Bearer {tokens['access_token']}"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_stats_unauthenticated(client):
|
||||
resp = await client.get("/admin/stats")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /admin/health/db
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_db_health_as_admin(client):
|
||||
token = await _admin_token(client)
|
||||
resp = await client.get("/admin/health/db", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
|
||||
async def test_db_health_as_regular_user(client):
|
||||
tokens = await _register_login(client)
|
||||
resp = await client.get("/admin/health/db", headers={"Authorization": f"Bearer {tokens['access_token']}"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /admin/tokens/purge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_purge_removes_revoked_tokens(client):
|
||||
tokens = await _register_login(client, "user@example.com")
|
||||
refresh_token = tokens["refresh_token"]
|
||||
|
||||
# Logout to revoke the token
|
||||
await client.post("/auth/logout", json={"refresh_token": refresh_token})
|
||||
|
||||
token = await _admin_token(client)
|
||||
resp = await client.post("/admin/tokens/purge", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["deleted"] >= 1
|
||||
|
||||
|
||||
async def test_purge_nothing_to_remove(client):
|
||||
token = await _admin_token(client)
|
||||
resp = await client.post("/admin/tokens/purge", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
# Admin login issued one active token — nothing to purge
|
||||
assert resp.json()["deleted"] == 0
|
||||
|
||||
|
||||
async def test_purge_as_regular_user(client):
|
||||
tokens = await _register_login(client)
|
||||
resp = await client.post("/admin/tokens/purge", headers={"Authorization": f"Bearer {tokens['access_token']}"})
|
||||
assert resp.status_code == 403
|
||||
"""Integration tests for admin endpoints."""
|
||||
import os
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import app
|
||||
from app import db as db_module
|
||||
|
||||
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
db_module._conn = None
|
||||
db_module.init_db(":memory:")
|
||||
yield
|
||||
db_module.close_db()
|
||||
db_module._conn = None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(fresh_db):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
async def _register_login(client, email="user@example.com", password="secret123"):
|
||||
await client.post("/auth/register", json={"email": email, "password": password})
|
||||
resp = await client.post("/auth/login", json={"email": email, "password": password})
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _admin_token(client, email="admin@example.com", password="secret123"):
|
||||
tokens = await _register_login(client, email, password)
|
||||
me = await client.get("/users/me", headers={"Authorization": f"Bearer {tokens['access_token']}"})
|
||||
db_module.get_conn().execute(
|
||||
"UPDATE users SET role = 'admin' WHERE id = ?", [me.json()["id"]])
|
||||
login = await client.post("/auth/login", json={"email": email, "password": password})
|
||||
return login.json()["access_token"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /admin/stats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_stats_as_admin(client):
|
||||
await _register_login(client, "user1@example.com")
|
||||
await _register_login(client, "user2@example.com")
|
||||
token = await _admin_token(client)
|
||||
|
||||
resp = await client.get("/admin/stats", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# 2 users + 1 admin + 1 seeded admin (ai@allucanget.biz)
|
||||
assert data["users"]["total"] == 4
|
||||
assert "by_role" in data["users"]
|
||||
assert "refresh_tokens" in data
|
||||
|
||||
|
||||
async def test_stats_as_regular_user(client):
|
||||
tokens = await _register_login(client)
|
||||
resp = await client.get("/admin/stats", headers={"Authorization": f"Bearer {tokens['access_token']}"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_stats_unauthenticated(client):
|
||||
resp = await client.get("/admin/stats")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /admin/health/db
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_db_health_as_admin(client):
|
||||
token = await _admin_token(client)
|
||||
resp = await client.get("/admin/health/db", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
|
||||
async def test_db_health_as_regular_user(client):
|
||||
tokens = await _register_login(client)
|
||||
resp = await client.get("/admin/health/db", headers={"Authorization": f"Bearer {tokens['access_token']}"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /admin/tokens/purge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_purge_removes_revoked_tokens(client):
|
||||
tokens = await _register_login(client, "user@example.com")
|
||||
refresh_token = tokens["refresh_token"]
|
||||
|
||||
# Logout to revoke the token
|
||||
await client.post("/auth/logout", json={"refresh_token": refresh_token})
|
||||
|
||||
token = await _admin_token(client)
|
||||
resp = await client.post("/admin/tokens/purge", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["deleted"] >= 1
|
||||
|
||||
|
||||
async def test_purge_nothing_to_remove(client):
|
||||
token = await _admin_token(client)
|
||||
resp = await client.post("/admin/tokens/purge", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
# Admin login issued one active token — nothing to purge
|
||||
assert resp.json()["deleted"] == 0
|
||||
|
||||
|
||||
async def test_purge_as_regular_user(client):
|
||||
tokens = await _register_login(client)
|
||||
resp = await client.post("/admin/tokens/purge", headers={"Authorization": f"Bearer {tokens['access_token']}"})
|
||||
assert resp.status_code == 403
|
||||
+172
-172
@@ -1,172 +1,172 @@
|
||||
"""Tests for AI endpoints — OpenRouter HTTP calls are fully mocked."""
|
||||
import os
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import app
|
||||
from app import db as db_module
|
||||
|
||||
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
||||
os.environ.setdefault("OPENROUTER_API_KEY", "test-key")
|
||||
|
||||
FAKE_MODELS = [
|
||||
{"id": "openai/gpt-4o", "name": "GPT-4o", "context_length": 128000, "pricing": {"prompt": "0.000005"}},
|
||||
{"id": "anthropic/claude-3-haiku", "name": "Claude 3 Haiku", "context_length": 200000, "pricing": {}},
|
||||
]
|
||||
|
||||
FAKE_CHAT_RESPONSE = {
|
||||
"id": "gen-abc123",
|
||||
"model": "openai/gpt-4o",
|
||||
"choices": [{"message": {"role": "assistant", "content": "Hello! How can I help?"}}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
db_module._conn = None
|
||||
db_module.init_db(":memory:")
|
||||
yield
|
||||
db_module.close_db()
|
||||
db_module._conn = None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(fresh_db):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
async def _user_token(client):
|
||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /ai/models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_list_models(client):
|
||||
token = await _user_token(client)
|
||||
with patch(
|
||||
"app.routers.ai.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_MODELS,
|
||||
):
|
||||
resp = await client.get("/ai/models", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2
|
||||
assert data[0]["id"] == "openai/gpt-4o"
|
||||
assert data[1]["name"] == "Claude 3 Haiku"
|
||||
|
||||
|
||||
async def test_list_models_unauthenticated(client):
|
||||
resp = await client.get("/ai/models")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_list_models_upstream_error(client):
|
||||
token = await _user_token(client)
|
||||
with patch(
|
||||
"app.routers.ai.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception("Connection refused"),
|
||||
):
|
||||
resp = await client.get("/ai/models", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
assert resp.status_code == 502
|
||||
assert "OpenRouter error" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /ai/chat
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_chat_success(client):
|
||||
token = await _user_token(client)
|
||||
with patch(
|
||||
"app.routers.ai.openrouter.chat_completion",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_CHAT_RESPONSE,
|
||||
):
|
||||
resp = await client.post(
|
||||
"/ai/chat",
|
||||
json={
|
||||
"model": "openai/gpt-4o",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == "gen-abc123"
|
||||
assert data["model"] == "openai/gpt-4o"
|
||||
assert data["content"] == "Hello! How can I help?"
|
||||
assert data["usage"]["total_tokens"] == 18
|
||||
|
||||
|
||||
async def test_chat_passes_parameters(client):
|
||||
token = await _user_token(client)
|
||||
mock = AsyncMock(return_value=FAKE_CHAT_RESPONSE)
|
||||
with patch("app.routers.ai.openrouter.chat_completion", new_callable=AsyncMock, return_value=FAKE_CHAT_RESPONSE) as mock:
|
||||
await client.post(
|
||||
"/ai/chat",
|
||||
json={
|
||||
"model": "anthropic/claude-3-haiku",
|
||||
"messages": [{"role": "user", "content": "Hi"}],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 512,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
mock.assert_called_once_with(
|
||||
model="anthropic/claude-3-haiku",
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
temperature=0.3,
|
||||
max_tokens=512,
|
||||
)
|
||||
|
||||
|
||||
async def test_chat_unauthenticated(client):
|
||||
resp = await client.post(
|
||||
"/ai/chat",
|
||||
json={"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hi"}]},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_chat_upstream_error(client):
|
||||
token = await _user_token(client)
|
||||
with patch(
|
||||
"app.routers.ai.openrouter.chat_completion",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception("timeout"),
|
||||
):
|
||||
resp = await client.post(
|
||||
"/ai/chat",
|
||||
json={"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hi"}]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 502
|
||||
|
||||
|
||||
async def test_chat_malformed_upstream_response(client):
|
||||
token = await _user_token(client)
|
||||
with patch(
|
||||
"app.routers.ai.openrouter.chat_completion",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"id": "x", "choices": []}, # empty choices
|
||||
):
|
||||
resp = await client.post(
|
||||
"/ai/chat",
|
||||
json={"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hi"}]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 502
|
||||
"""Tests for AI endpoints — OpenRouter HTTP calls are fully mocked."""
|
||||
import os
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import app
|
||||
from app import db as db_module
|
||||
|
||||
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
||||
os.environ.setdefault("OPENROUTER_API_KEY", "test-key")
|
||||
|
||||
FAKE_MODELS = [
|
||||
{"id": "openai/gpt-4o", "name": "GPT-4o", "context_length": 128000, "pricing": {"prompt": "0.000005"}},
|
||||
{"id": "anthropic/claude-3-haiku", "name": "Claude 3 Haiku", "context_length": 200000, "pricing": {}},
|
||||
]
|
||||
|
||||
FAKE_CHAT_RESPONSE = {
|
||||
"id": "gen-abc123",
|
||||
"model": "openai/gpt-4o",
|
||||
"choices": [{"message": {"role": "assistant", "content": "Hello! How can I help?"}}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
db_module._conn = None
|
||||
db_module.init_db(":memory:")
|
||||
yield
|
||||
db_module.close_db()
|
||||
db_module._conn = None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(fresh_db):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
async def _user_token(client):
|
||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /ai/models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_list_models(client):
|
||||
token = await _user_token(client)
|
||||
with patch(
|
||||
"app.routers.ai.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_MODELS,
|
||||
):
|
||||
resp = await client.get("/ai/models", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2
|
||||
assert data[0]["id"] == "openai/gpt-4o"
|
||||
assert data[1]["name"] == "Claude 3 Haiku"
|
||||
|
||||
|
||||
async def test_list_models_unauthenticated(client):
|
||||
resp = await client.get("/ai/models")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_list_models_upstream_error(client):
|
||||
token = await _user_token(client)
|
||||
with patch(
|
||||
"app.routers.ai.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception("Connection refused"),
|
||||
):
|
||||
resp = await client.get("/ai/models", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
assert resp.status_code == 502
|
||||
assert "OpenRouter error" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /ai/chat
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_chat_success(client):
|
||||
token = await _user_token(client)
|
||||
with patch(
|
||||
"app.routers.ai.openrouter.chat_completion",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_CHAT_RESPONSE,
|
||||
):
|
||||
resp = await client.post(
|
||||
"/ai/chat",
|
||||
json={
|
||||
"model": "openai/gpt-4o",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == "gen-abc123"
|
||||
assert data["model"] == "openai/gpt-4o"
|
||||
assert data["content"] == "Hello! How can I help?"
|
||||
assert data["usage"]["total_tokens"] == 18
|
||||
|
||||
|
||||
async def test_chat_passes_parameters(client):
|
||||
token = await _user_token(client)
|
||||
mock = AsyncMock(return_value=FAKE_CHAT_RESPONSE)
|
||||
with patch("app.routers.ai.openrouter.chat_completion", new_callable=AsyncMock, return_value=FAKE_CHAT_RESPONSE) as mock:
|
||||
await client.post(
|
||||
"/ai/chat",
|
||||
json={
|
||||
"model": "anthropic/claude-3-haiku",
|
||||
"messages": [{"role": "user", "content": "Hi"}],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 512,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
mock.assert_called_once_with(
|
||||
model="anthropic/claude-3-haiku",
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
temperature=0.3,
|
||||
max_tokens=512,
|
||||
)
|
||||
|
||||
|
||||
async def test_chat_unauthenticated(client):
|
||||
resp = await client.post(
|
||||
"/ai/chat",
|
||||
json={"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hi"}]},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_chat_upstream_error(client):
|
||||
token = await _user_token(client)
|
||||
with patch(
|
||||
"app.routers.ai.openrouter.chat_completion",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception("timeout"),
|
||||
):
|
||||
resp = await client.post(
|
||||
"/ai/chat",
|
||||
json={"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hi"}]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 502
|
||||
|
||||
|
||||
async def test_chat_malformed_upstream_response(client):
|
||||
token = await _user_token(client)
|
||||
with patch(
|
||||
"app.routers.ai.openrouter.chat_completion",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"id": "x", "choices": []}, # empty choices
|
||||
):
|
||||
resp = await client.post(
|
||||
"/ai/chat",
|
||||
json={"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hi"}]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 502
|
||||
+103
-103
@@ -1,103 +1,103 @@
|
||||
"""Integration tests for auth endpoints using in-memory DuckDB."""
|
||||
from app.main import app
|
||||
from app import db as db_module
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
import os
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
"""Reset the DB singleton to a fresh in-memory DB for each test."""
|
||||
db_module._conn = None
|
||||
db_module.init_db(":memory:")
|
||||
yield
|
||||
db_module.close_db()
|
||||
db_module._conn = None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(fresh_db):
|
||||
"""HTTP client wired to the app; explicitly depends on fresh_db to guarantee ordering."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
async def test_register_success(client):
|
||||
resp = await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["email"] == "user@example.com"
|
||||
assert data["role"] == "user"
|
||||
assert "id" in data
|
||||
|
||||
|
||||
async def test_register_duplicate_email(client):
|
||||
payload = {"email": "dup@example.com", "password": "secret123"}
|
||||
await client.post("/auth/register", json=payload)
|
||||
resp = await client.post("/auth/register", json=payload)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
async def test_login_success(client):
|
||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
|
||||
async def test_login_wrong_password(client):
|
||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "wrong"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_login_unknown_user(client):
|
||||
resp = await client.post("/auth/login", json={"email": "nobody@example.com", "password": "x"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_refresh_success(client):
|
||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||
refresh_token = login.json()["refresh_token"]
|
||||
|
||||
resp = await client.post("/auth/refresh", json={"refresh_token": refresh_token})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
# New refresh token must differ (rotation)
|
||||
assert data["refresh_token"] != refresh_token
|
||||
|
||||
|
||||
async def test_refresh_revoked_token(client):
|
||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||
refresh_token = login.json()["refresh_token"]
|
||||
|
||||
# Use once (rotates)
|
||||
await client.post("/auth/refresh", json={"refresh_token": refresh_token})
|
||||
# Try to reuse old token — must fail
|
||||
resp = await client.post("/auth/refresh", json={"refresh_token": refresh_token})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_logout_success(client):
|
||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||
refresh_token = login.json()["refresh_token"]
|
||||
|
||||
resp = await client.post("/auth/logout", json={"refresh_token": refresh_token})
|
||||
assert resp.status_code == 204
|
||||
|
||||
# Refresh after logout must fail
|
||||
resp2 = await client.post("/auth/refresh", json={"refresh_token": refresh_token})
|
||||
assert resp2.status_code == 401
|
||||
"""Integration tests for auth endpoints using in-memory DuckDB."""
|
||||
from app.main import app
|
||||
from app import db as db_module
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
import os
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
"""Reset the DB singleton to a fresh in-memory DB for each test."""
|
||||
db_module._conn = None
|
||||
db_module.init_db(":memory:")
|
||||
yield
|
||||
db_module.close_db()
|
||||
db_module._conn = None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(fresh_db):
|
||||
"""HTTP client wired to the app; explicitly depends on fresh_db to guarantee ordering."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
async def test_register_success(client):
|
||||
resp = await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["email"] == "user@example.com"
|
||||
assert data["role"] == "user"
|
||||
assert "id" in data
|
||||
|
||||
|
||||
async def test_register_duplicate_email(client):
|
||||
payload = {"email": "dup@example.com", "password": "secret123"}
|
||||
await client.post("/auth/register", json=payload)
|
||||
resp = await client.post("/auth/register", json=payload)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
async def test_login_success(client):
|
||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
|
||||
async def test_login_wrong_password(client):
|
||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "wrong"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_login_unknown_user(client):
|
||||
resp = await client.post("/auth/login", json={"email": "nobody@example.com", "password": "x"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_refresh_success(client):
|
||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||
refresh_token = login.json()["refresh_token"]
|
||||
|
||||
resp = await client.post("/auth/refresh", json={"refresh_token": refresh_token})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
# New refresh token must differ (rotation)
|
||||
assert data["refresh_token"] != refresh_token
|
||||
|
||||
|
||||
async def test_refresh_revoked_token(client):
|
||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||
refresh_token = login.json()["refresh_token"]
|
||||
|
||||
# Use once (rotates)
|
||||
await client.post("/auth/refresh", json={"refresh_token": refresh_token})
|
||||
# Try to reuse old token — must fail
|
||||
resp = await client.post("/auth/refresh", json={"refresh_token": refresh_token})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_logout_success(client):
|
||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||
refresh_token = login.json()["refresh_token"]
|
||||
|
||||
resp = await client.post("/auth/logout", json={"refresh_token": refresh_token})
|
||||
assert resp.status_code == 204
|
||||
|
||||
# Refresh after logout must fail
|
||||
resp2 = await client.post("/auth/refresh", json={"refresh_token": refresh_token})
|
||||
assert resp2.status_code == 401
|
||||
+230
-230
@@ -1,230 +1,230 @@
|
||||
"""Tests for DuckDB initialization and schema."""
|
||||
import asyncio
|
||||
import pytest
|
||||
import duckdb
|
||||
|
||||
from app import db as db_module
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
"""Use an in-memory DB for each test and reset global state."""
|
||||
db_module._conn = None
|
||||
yield
|
||||
db_module.close_db()
|
||||
db_module._conn = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_init_creates_users_table():
|
||||
conn = db_module.init_db(":memory:")
|
||||
result = conn.execute(
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_name = 'users'"
|
||||
).fetchone()
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_init_creates_refresh_tokens_table():
|
||||
conn = db_module.init_db(":memory:")
|
||||
result = conn.execute(
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_name = 'refresh_tokens'"
|
||||
).fetchone()
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_init_is_idempotent():
|
||||
conn1 = db_module.init_db(":memory:")
|
||||
conn2 = db_module.init_db(":memory:")
|
||||
assert conn1 is conn2
|
||||
|
||||
|
||||
def test_get_conn_raises_before_init():
|
||||
with pytest.raises(RuntimeError, match="not initialised"):
|
||||
db_module.get_conn()
|
||||
|
||||
|
||||
def test_get_conn_returns_connection_after_init():
|
||||
db_module.init_db(":memory:")
|
||||
conn = db_module.get_conn()
|
||||
assert conn is not None
|
||||
|
||||
|
||||
def test_close_db_resets_connection():
|
||||
db_module.init_db(":memory:")
|
||||
db_module.close_db()
|
||||
with pytest.raises(RuntimeError):
|
||||
db_module.get_conn()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema: users table columns and defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_users_columns():
|
||||
conn = db_module.init_db(":memory:")
|
||||
cols = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name = 'users'"
|
||||
).fetchall()
|
||||
}
|
||||
assert {"id", "email", "password_hash",
|
||||
"role", "created_at", "updated_at"} <= cols
|
||||
|
||||
|
||||
def test_users_default_role_is_user():
|
||||
conn = db_module.init_db(":memory:")
|
||||
conn.execute(
|
||||
"INSERT INTO users (email, password_hash) VALUES ('a@example.com', 'hash')"
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT role FROM users WHERE email = 'a@example.com'").fetchone()
|
||||
assert row[0] == "user"
|
||||
|
||||
|
||||
def test_users_id_auto_generated():
|
||||
conn = db_module.init_db(":memory:")
|
||||
conn.execute(
|
||||
"INSERT INTO users (email, password_hash) VALUES ('b@example.com', 'hash')"
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT id FROM users WHERE email = 'b@example.com'").fetchone()
|
||||
assert row[0] is not None
|
||||
|
||||
|
||||
def test_users_email_unique_constraint():
|
||||
conn = db_module.init_db(":memory:")
|
||||
conn.execute(
|
||||
"INSERT INTO users (email, password_hash) VALUES ('c@example.com', 'h')")
|
||||
with pytest.raises(Exception):
|
||||
conn.execute(
|
||||
"INSERT INTO users (email, password_hash) VALUES ('c@example.com', 'h2')")
|
||||
|
||||
|
||||
def test_users_timestamps_auto_set():
|
||||
conn = db_module.init_db(":memory:")
|
||||
conn.execute(
|
||||
"INSERT INTO users (email, password_hash) VALUES ('d@example.com', 'hash')"
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT created_at, updated_at FROM users WHERE email = 'd@example.com'"
|
||||
).fetchone()
|
||||
assert row[0] is not None
|
||||
assert row[1] is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema: refresh_tokens table columns and defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_refresh_tokens_columns():
|
||||
conn = db_module.init_db(":memory:")
|
||||
cols = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name = 'refresh_tokens'"
|
||||
).fetchall()
|
||||
}
|
||||
assert {"jti", "user_id", "issued_at", "expires_at", "revoked"} <= cols
|
||||
|
||||
|
||||
def test_refresh_tokens_default_revoked_false():
|
||||
conn = db_module.init_db(":memory:")
|
||||
conn.execute(
|
||||
"INSERT INTO users (email, password_hash) VALUES ('e@example.com', 'h')")
|
||||
user_id = conn.execute(
|
||||
"SELECT id FROM users WHERE email = 'e@example.com'").fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO refresh_tokens (user_id, expires_at) VALUES (?, now() + INTERVAL 7 DAY)",
|
||||
[user_id],
|
||||
)
|
||||
row = conn.execute("SELECT revoked FROM refresh_tokens WHERE user_id = ?", [
|
||||
user_id]).fetchone()
|
||||
assert row[0] is False
|
||||
|
||||
|
||||
def test_refresh_tokens_jti_auto_generated():
|
||||
conn = db_module.init_db(":memory:")
|
||||
conn.execute(
|
||||
"INSERT INTO users (email, password_hash) VALUES ('f@example.com', 'h')")
|
||||
user_id = conn.execute(
|
||||
"SELECT id FROM users WHERE email = 'f@example.com'").fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO refresh_tokens (user_id, expires_at) VALUES (?, now() + INTERVAL 7 DAY)",
|
||||
[user_id],
|
||||
)
|
||||
row = conn.execute("SELECT jti FROM refresh_tokens WHERE user_id = ?", [
|
||||
user_id]).fetchone()
|
||||
assert row[0] is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write lock
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_write_lock_returns_asyncio_lock():
|
||||
lock = db_module.get_write_lock()
|
||||
assert isinstance(lock, asyncio.Lock)
|
||||
|
||||
|
||||
def test_get_write_lock_returns_same_instance():
|
||||
lock1 = db_module.get_write_lock()
|
||||
lock2 = db_module.get_write_lock()
|
||||
assert lock1 is lock2
|
||||
|
||||
|
||||
async def test_write_lock_serialises_concurrent_writes():
|
||||
"""Two coroutines acquiring the lock must not overlap."""
|
||||
conn = db_module.init_db(":memory:")
|
||||
lock = db_module.get_write_lock()
|
||||
order = []
|
||||
|
||||
async def writer(label: str):
|
||||
async with lock:
|
||||
order.append(f"{label}-start")
|
||||
await asyncio.sleep(0) # yield to event loop
|
||||
order.append(f"{label}-end")
|
||||
|
||||
await asyncio.gather(writer("A"), writer("B"))
|
||||
# Each writer's start and end must be adjacent (no interleaving)
|
||||
assert order.index("A-start") + 1 == order.index("A-end") or \
|
||||
order.index("B-start") + 1 == order.index("B-end")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin seed user
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_seed_admin_user_created_on_init():
|
||||
import os
|
||||
conn = db_module.init_db(":memory:")
|
||||
row = conn.execute(
|
||||
"SELECT email, role FROM users WHERE email = 'ai@allucanget.biz'"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == "ai@allucanget.biz"
|
||||
assert row[1] == "admin"
|
||||
|
||||
|
||||
def test_seed_admin_is_idempotent():
|
||||
conn = db_module.init_db(":memory:")
|
||||
# Simulate re-running seed (second init_db call reuses connection, so call _seed_admin directly)
|
||||
db_module._seed_admin(conn)
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE email = 'ai@allucanget.biz'"
|
||||
).fetchone()[0]
|
||||
assert count == 1
|
||||
|
||||
|
||||
def test_seed_admin_email_env_override(monkeypatch):
|
||||
monkeypatch.setenv("ADMIN_EMAIL", "custom@example.com")
|
||||
monkeypatch.setenv("ADMIN_PASSWORD", "custompass")
|
||||
conn = db_module.init_db(":memory:")
|
||||
row = conn.execute(
|
||||
"SELECT email, role FROM users WHERE email = 'custom@example.com'"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row[1] == "admin"
|
||||
"""Tests for DuckDB initialization and schema."""
|
||||
import asyncio
|
||||
import pytest
|
||||
import duckdb
|
||||
|
||||
from app import db as db_module
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
"""Use an in-memory DB for each test and reset global state."""
|
||||
db_module._conn = None
|
||||
yield
|
||||
db_module.close_db()
|
||||
db_module._conn = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_init_creates_users_table():
|
||||
conn = db_module.init_db(":memory:")
|
||||
result = conn.execute(
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_name = 'users'"
|
||||
).fetchone()
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_init_creates_refresh_tokens_table():
|
||||
conn = db_module.init_db(":memory:")
|
||||
result = conn.execute(
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_name = 'refresh_tokens'"
|
||||
).fetchone()
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_init_is_idempotent():
|
||||
conn1 = db_module.init_db(":memory:")
|
||||
conn2 = db_module.init_db(":memory:")
|
||||
assert conn1 is conn2
|
||||
|
||||
|
||||
def test_get_conn_raises_before_init():
|
||||
with pytest.raises(RuntimeError, match="not initialised"):
|
||||
db_module.get_conn()
|
||||
|
||||
|
||||
def test_get_conn_returns_connection_after_init():
|
||||
db_module.init_db(":memory:")
|
||||
conn = db_module.get_conn()
|
||||
assert conn is not None
|
||||
|
||||
|
||||
def test_close_db_resets_connection():
|
||||
db_module.init_db(":memory:")
|
||||
db_module.close_db()
|
||||
with pytest.raises(RuntimeError):
|
||||
db_module.get_conn()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema: users table columns and defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_users_columns():
|
||||
conn = db_module.init_db(":memory:")
|
||||
cols = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name = 'users'"
|
||||
).fetchall()
|
||||
}
|
||||
assert {"id", "email", "password_hash",
|
||||
"role", "created_at", "updated_at"} <= cols
|
||||
|
||||
|
||||
def test_users_default_role_is_user():
|
||||
conn = db_module.init_db(":memory:")
|
||||
conn.execute(
|
||||
"INSERT INTO users (email, password_hash) VALUES ('a@example.com', 'hash')"
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT role FROM users WHERE email = 'a@example.com'").fetchone()
|
||||
assert row[0] == "user"
|
||||
|
||||
|
||||
def test_users_id_auto_generated():
|
||||
conn = db_module.init_db(":memory:")
|
||||
conn.execute(
|
||||
"INSERT INTO users (email, password_hash) VALUES ('b@example.com', 'hash')"
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT id FROM users WHERE email = 'b@example.com'").fetchone()
|
||||
assert row[0] is not None
|
||||
|
||||
|
||||
def test_users_email_unique_constraint():
|
||||
conn = db_module.init_db(":memory:")
|
||||
conn.execute(
|
||||
"INSERT INTO users (email, password_hash) VALUES ('c@example.com', 'h')")
|
||||
with pytest.raises(Exception):
|
||||
conn.execute(
|
||||
"INSERT INTO users (email, password_hash) VALUES ('c@example.com', 'h2')")
|
||||
|
||||
|
||||
def test_users_timestamps_auto_set():
|
||||
conn = db_module.init_db(":memory:")
|
||||
conn.execute(
|
||||
"INSERT INTO users (email, password_hash) VALUES ('d@example.com', 'hash')"
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT created_at, updated_at FROM users WHERE email = 'd@example.com'"
|
||||
).fetchone()
|
||||
assert row[0] is not None
|
||||
assert row[1] is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema: refresh_tokens table columns and defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_refresh_tokens_columns():
|
||||
conn = db_module.init_db(":memory:")
|
||||
cols = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name = 'refresh_tokens'"
|
||||
).fetchall()
|
||||
}
|
||||
assert {"jti", "user_id", "issued_at", "expires_at", "revoked"} <= cols
|
||||
|
||||
|
||||
def test_refresh_tokens_default_revoked_false():
|
||||
conn = db_module.init_db(":memory:")
|
||||
conn.execute(
|
||||
"INSERT INTO users (email, password_hash) VALUES ('e@example.com', 'h')")
|
||||
user_id = conn.execute(
|
||||
"SELECT id FROM users WHERE email = 'e@example.com'").fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO refresh_tokens (user_id, expires_at) VALUES (?, now() + INTERVAL 7 DAY)",
|
||||
[user_id],
|
||||
)
|
||||
row = conn.execute("SELECT revoked FROM refresh_tokens WHERE user_id = ?", [
|
||||
user_id]).fetchone()
|
||||
assert row[0] is False
|
||||
|
||||
|
||||
def test_refresh_tokens_jti_auto_generated():
|
||||
conn = db_module.init_db(":memory:")
|
||||
conn.execute(
|
||||
"INSERT INTO users (email, password_hash) VALUES ('f@example.com', 'h')")
|
||||
user_id = conn.execute(
|
||||
"SELECT id FROM users WHERE email = 'f@example.com'").fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO refresh_tokens (user_id, expires_at) VALUES (?, now() + INTERVAL 7 DAY)",
|
||||
[user_id],
|
||||
)
|
||||
row = conn.execute("SELECT jti FROM refresh_tokens WHERE user_id = ?", [
|
||||
user_id]).fetchone()
|
||||
assert row[0] is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write lock
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_write_lock_returns_asyncio_lock():
|
||||
lock = db_module.get_write_lock()
|
||||
assert isinstance(lock, asyncio.Lock)
|
||||
|
||||
|
||||
def test_get_write_lock_returns_same_instance():
|
||||
lock1 = db_module.get_write_lock()
|
||||
lock2 = db_module.get_write_lock()
|
||||
assert lock1 is lock2
|
||||
|
||||
|
||||
async def test_write_lock_serialises_concurrent_writes():
|
||||
"""Two coroutines acquiring the lock must not overlap."""
|
||||
conn = db_module.init_db(":memory:")
|
||||
lock = db_module.get_write_lock()
|
||||
order = []
|
||||
|
||||
async def writer(label: str):
|
||||
async with lock:
|
||||
order.append(f"{label}-start")
|
||||
await asyncio.sleep(0) # yield to event loop
|
||||
order.append(f"{label}-end")
|
||||
|
||||
await asyncio.gather(writer("A"), writer("B"))
|
||||
# Each writer's start and end must be adjacent (no interleaving)
|
||||
assert order.index("A-start") + 1 == order.index("A-end") or \
|
||||
order.index("B-start") + 1 == order.index("B-end")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin seed user
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_seed_admin_user_created_on_init():
|
||||
import os
|
||||
conn = db_module.init_db(":memory:")
|
||||
row = conn.execute(
|
||||
"SELECT email, role FROM users WHERE email = 'ai@allucanget.biz'"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == "ai@allucanget.biz"
|
||||
assert row[1] == "admin"
|
||||
|
||||
|
||||
def test_seed_admin_is_idempotent():
|
||||
conn = db_module.init_db(":memory:")
|
||||
# Simulate re-running seed (second init_db call reuses connection, so call _seed_admin directly)
|
||||
db_module._seed_admin(conn)
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE email = 'ai@allucanget.biz'"
|
||||
).fetchone()[0]
|
||||
assert count == 1
|
||||
|
||||
|
||||
def test_seed_admin_email_env_override(monkeypatch):
|
||||
monkeypatch.setenv("ADMIN_EMAIL", "custom@example.com")
|
||||
monkeypatch.setenv("ADMIN_PASSWORD", "custompass")
|
||||
conn = db_module.init_db(":memory:")
|
||||
row = conn.execute(
|
||||
"SELECT email, role FROM users WHERE email = 'custom@example.com'"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row[1] == "admin"
|
||||
+591
-591
File diff suppressed because it is too large.
Load diff
+184
-184
@@ -1,184 +1,184 @@
|
||||
"""Tests for image upload and retrieval endpoints."""
|
||||
import io
|
||||
import os
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import app
|
||||
from app import db as db_module
|
||||
|
||||
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
||||
# Use a temp dir so file I/O works without polluting project data/
|
||||
os.environ.setdefault("UPLOAD_DIR", "/tmp/test_uploads")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
db_module._conn = None
|
||||
db_module.init_db(":memory:")
|
||||
yield
|
||||
db_module.close_db()
|
||||
db_module._conn = None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(fresh_db):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
async def _user_token(client) -> str:
|
||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
async def _other_token(client) -> str:
|
||||
await client.post("/auth/register", json={"email": "other@example.com", "password": "secret123"})
|
||||
resp = await client.post("/auth/login", json={"email": "other@example.com", "password": "secret123"})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
def _png_bytes() -> bytes:
|
||||
"""Minimal valid 1x1 PNG."""
|
||||
return (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
b"\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00"
|
||||
b"\x00\x01\x01\x00\x05\x18\xd4n\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /images/upload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_upload_image_success(client):
|
||||
token = await _user_token(client)
|
||||
resp = await client.post(
|
||||
"/images/upload",
|
||||
files={"file": ("test.png", io.BytesIO(_png_bytes()), "image/png")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["filename"] == "test.png"
|
||||
assert data["content_type"] == "image/png"
|
||||
assert "id" in data
|
||||
assert data["size_bytes"] > 0
|
||||
|
||||
|
||||
async def test_upload_image_unauthenticated(client):
|
||||
resp = await client.post(
|
||||
"/images/upload",
|
||||
files={"file": ("test.png", io.BytesIO(_png_bytes()), "image/png")},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_upload_image_unsupported_type(client):
|
||||
token = await _user_token(client)
|
||||
resp = await client.post(
|
||||
"/images/upload",
|
||||
files={"file": ("doc.pdf", io.BytesIO(
|
||||
b"%PDF-fake"), "application/pdf")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 415
|
||||
|
||||
|
||||
async def test_upload_image_too_large(client, monkeypatch):
|
||||
import app.routers.images as images_mod
|
||||
monkeypatch.setattr(images_mod, "MAX_SIZE_BYTES", 5)
|
||||
token = await _user_token(client)
|
||||
resp = await client.post(
|
||||
"/images/upload",
|
||||
files={"file": ("big.png", io.BytesIO(b"x" * 10), "image/png")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 413
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /images/
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_list_images_empty(client):
|
||||
token = await _user_token(client)
|
||||
resp = await client.get("/images/", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
async def test_list_images_returns_own_only(client):
|
||||
token = await _user_token(client)
|
||||
other = await _other_token(client)
|
||||
|
||||
# Upload one image as user, one as other
|
||||
for tok, name in [(token, "mine.png"), (other, "theirs.png")]:
|
||||
await client.post(
|
||||
"/images/upload",
|
||||
files={"file": (name, io.BytesIO(_png_bytes()), "image/png")},
|
||||
headers={"Authorization": f"Bearer {tok}"},
|
||||
)
|
||||
|
||||
resp = await client.get("/images/", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
items = resp.json()
|
||||
assert len(items) == 1
|
||||
assert items[0]["filename"] == "mine.png"
|
||||
|
||||
|
||||
async def test_list_images_unauthenticated(client):
|
||||
resp = await client.get("/images/")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /images/{id}/file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_serve_image_success(client):
|
||||
token = await _user_token(client)
|
||||
up = await client.post(
|
||||
"/images/upload",
|
||||
files={"file": ("pixel.png", io.BytesIO(_png_bytes()), "image/png")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
image_id = up.json()["id"]
|
||||
|
||||
resp = await client.get(
|
||||
f"/images/{image_id}/file",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("image/png")
|
||||
assert resp.content == _png_bytes()
|
||||
|
||||
|
||||
async def test_serve_image_wrong_user(client):
|
||||
token = await _user_token(client)
|
||||
other = await _other_token(client)
|
||||
|
||||
up = await client.post(
|
||||
"/images/upload",
|
||||
files={"file": ("secret.png", io.BytesIO(_png_bytes()), "image/png")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
image_id = up.json()["id"]
|
||||
|
||||
resp = await client.get(
|
||||
f"/images/{image_id}/file",
|
||||
headers={"Authorization": f"Bearer {other}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_serve_image_not_found(client):
|
||||
token = await _user_token(client)
|
||||
resp = await client.get(
|
||||
"/images/00000000-0000-0000-0000-000000000000/file",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
"""Tests for image upload and retrieval endpoints."""
|
||||
import io
|
||||
import os
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import app
|
||||
from app import db as db_module
|
||||
|
||||
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
||||
# Use a temp dir so file I/O works without polluting project data/
|
||||
os.environ.setdefault("UPLOAD_DIR", "/tmp/test_uploads")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
db_module._conn = None
|
||||
db_module.init_db(":memory:")
|
||||
yield
|
||||
db_module.close_db()
|
||||
db_module._conn = None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(fresh_db):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
async def _user_token(client) -> str:
|
||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
async def _other_token(client) -> str:
|
||||
await client.post("/auth/register", json={"email": "other@example.com", "password": "secret123"})
|
||||
resp = await client.post("/auth/login", json={"email": "other@example.com", "password": "secret123"})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
def _png_bytes() -> bytes:
|
||||
"""Minimal valid 1x1 PNG."""
|
||||
return (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
b"\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00"
|
||||
b"\x00\x01\x01\x00\x05\x18\xd4n\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /images/upload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_upload_image_success(client):
|
||||
token = await _user_token(client)
|
||||
resp = await client.post(
|
||||
"/images/upload",
|
||||
files={"file": ("test.png", io.BytesIO(_png_bytes()), "image/png")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["filename"] == "test.png"
|
||||
assert data["content_type"] == "image/png"
|
||||
assert "id" in data
|
||||
assert data["size_bytes"] > 0
|
||||
|
||||
|
||||
async def test_upload_image_unauthenticated(client):
|
||||
resp = await client.post(
|
||||
"/images/upload",
|
||||
files={"file": ("test.png", io.BytesIO(_png_bytes()), "image/png")},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_upload_image_unsupported_type(client):
|
||||
token = await _user_token(client)
|
||||
resp = await client.post(
|
||||
"/images/upload",
|
||||
files={"file": ("doc.pdf", io.BytesIO(
|
||||
b"%PDF-fake"), "application/pdf")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 415
|
||||
|
||||
|
||||
async def test_upload_image_too_large(client, monkeypatch):
|
||||
import app.routers.images as images_mod
|
||||
monkeypatch.setattr(images_mod, "MAX_SIZE_BYTES", 5)
|
||||
token = await _user_token(client)
|
||||
resp = await client.post(
|
||||
"/images/upload",
|
||||
files={"file": ("big.png", io.BytesIO(b"x" * 10), "image/png")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 413
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /images/
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_list_images_empty(client):
|
||||
token = await _user_token(client)
|
||||
resp = await client.get("/images/", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
async def test_list_images_returns_own_only(client):
|
||||
token = await _user_token(client)
|
||||
other = await _other_token(client)
|
||||
|
||||
# Upload one image as user, one as other
|
||||
for tok, name in [(token, "mine.png"), (other, "theirs.png")]:
|
||||
await client.post(
|
||||
"/images/upload",
|
||||
files={"file": (name, io.BytesIO(_png_bytes()), "image/png")},
|
||||
headers={"Authorization": f"Bearer {tok}"},
|
||||
)
|
||||
|
||||
resp = await client.get("/images/", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
items = resp.json()
|
||||
assert len(items) == 1
|
||||
assert items[0]["filename"] == "mine.png"
|
||||
|
||||
|
||||
async def test_list_images_unauthenticated(client):
|
||||
resp = await client.get("/images/")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /images/{id}/file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_serve_image_success(client):
|
||||
token = await _user_token(client)
|
||||
up = await client.post(
|
||||
"/images/upload",
|
||||
files={"file": ("pixel.png", io.BytesIO(_png_bytes()), "image/png")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
image_id = up.json()["id"]
|
||||
|
||||
resp = await client.get(
|
||||
f"/images/{image_id}/file",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("image/png")
|
||||
assert resp.content == _png_bytes()
|
||||
|
||||
|
||||
async def test_serve_image_wrong_user(client):
|
||||
token = await _user_token(client)
|
||||
other = await _other_token(client)
|
||||
|
||||
up = await client.post(
|
||||
"/images/upload",
|
||||
files={"file": ("secret.png", io.BytesIO(_png_bytes()), "image/png")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
image_id = up.json()["id"]
|
||||
|
||||
resp = await client.get(
|
||||
f"/images/{image_id}/file",
|
||||
headers={"Authorization": f"Bearer {other}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_serve_image_not_found(client):
|
||||
token = await _user_token(client)
|
||||
resp = await client.get(
|
||||
"/images/00000000-0000-0000-0000-000000000000/file",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
+366
-366
@@ -1,366 +1,366 @@
|
||||
"""Tests for model cache service and router."""
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app import db as db_module
|
||||
from app.main import app
|
||||
from app.services.models import (
|
||||
_extract_output_modality,
|
||||
_normalize_modality,
|
||||
_parse_modality,
|
||||
get_cached_models,
|
||||
get_model_output_modalities,
|
||||
is_cache_stale,
|
||||
refresh_models_cache,
|
||||
)
|
||||
|
||||
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
||||
os.environ.setdefault("OPENROUTER_API_KEY", "test-key")
|
||||
|
||||
FAKE_MODELS_RAW = [
|
||||
{
|
||||
"id": "openai/gpt-4o",
|
||||
"name": "GPT-4o",
|
||||
"context_length": 128000,
|
||||
"pricing": {"prompt": "0.000005"},
|
||||
"architecture": {"modality": "text->text", "output_modalities": ["text"]},
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-3-haiku",
|
||||
"name": "Claude 3 Haiku",
|
||||
"context_length": 200000,
|
||||
"pricing": {},
|
||||
"architecture": {"modality": "text+image->text", "output_modalities": ["text"]},
|
||||
},
|
||||
{
|
||||
"id": "openai/dall-e-3",
|
||||
"name": "DALL-E 3",
|
||||
"context_length": None,
|
||||
"pricing": {"image": "0.04"},
|
||||
"architecture": {"modality": "text->image", "output_modalities": ["image"]},
|
||||
},
|
||||
{
|
||||
"id": "openai/sora-2",
|
||||
"name": "Sora 2",
|
||||
"context_length": None,
|
||||
"pricing": {"video": "0.10"},
|
||||
"architecture": {"modality": "text->video", "output_modalities": ["video"]},
|
||||
},
|
||||
{
|
||||
"id": "google/gemini-2.5-flash-image",
|
||||
"name": "Gemini 2.5 Flash Image",
|
||||
"context_length": None,
|
||||
"pricing": {},
|
||||
"architecture": {"output_modalities": ["image", "text"]},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
db_module._conn = None
|
||||
db_module.init_db(":memory:")
|
||||
yield
|
||||
db_module.close_db()
|
||||
db_module._conn = None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(fresh_db):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
async def _register_login(client, email, password, is_admin=False):
|
||||
"""Register + login; optionally promote to admin directly in DB."""
|
||||
await client.post("/auth/register", json={"email": email, "password": password})
|
||||
if is_admin:
|
||||
db_module.get_conn().execute(
|
||||
"UPDATE users SET role = 'admin' WHERE email = ?", [email]
|
||||
)
|
||||
resp = await client.post("/auth/login", json={"email": email, "password": password})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests: _parse_modality
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_parse_modality_text():
|
||||
assert _parse_modality("text->text") == "text"
|
||||
|
||||
|
||||
def test_parse_modality_multimodal_input_text_output():
|
||||
assert _parse_modality("text+image->text") == "text"
|
||||
|
||||
|
||||
def test_parse_modality_image():
|
||||
assert _parse_modality("text->image") == "image"
|
||||
|
||||
|
||||
def test_parse_modality_video():
|
||||
assert _parse_modality("text->video") == "video"
|
||||
|
||||
|
||||
def test_parse_modality_audio():
|
||||
assert _parse_modality("text->audio") == "audio"
|
||||
|
||||
|
||||
def test_parse_modality_no_arrow_fallback():
|
||||
assert _parse_modality("text") == "text"
|
||||
|
||||
|
||||
def test_normalize_embedding_alias():
|
||||
assert _normalize_modality("embedding") == "embeddings"
|
||||
|
||||
|
||||
def test_extract_output_modality_prefers_output_modalities():
|
||||
model = {
|
||||
"architecture": {
|
||||
"modality": "text->text",
|
||||
"output_modalities": ["image"],
|
||||
}
|
||||
}
|
||||
assert _extract_output_modality(model) == "image"
|
||||
|
||||
|
||||
def test_extract_output_modality_legacy_fallback():
|
||||
model = {"architecture": {"modality": "text->audio"}}
|
||||
assert _extract_output_modality(model) == "audio"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests: is_cache_stale
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_cache_stale_when_empty():
|
||||
conn = db_module.get_conn()
|
||||
assert is_cache_stale(conn) is True
|
||||
|
||||
|
||||
def test_cache_not_stale_after_fresh_insert():
|
||||
conn = db_module.get_conn()
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
conn.execute(
|
||||
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
|
||||
["openai/gpt-4o", "GPT-4o", "text", now],
|
||||
)
|
||||
assert is_cache_stale(conn) is False
|
||||
|
||||
|
||||
def test_cache_stale_after_ttl_exceeded():
|
||||
conn = db_module.get_conn()
|
||||
# Store naive UTC to match DuckDB TIMESTAMP behaviour
|
||||
old_time = datetime.now(timezone.utc).replace(
|
||||
tzinfo=None) - timedelta(hours=25)
|
||||
conn.execute(
|
||||
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
|
||||
["openai/gpt-4o", "GPT-4o", "text", old_time],
|
||||
)
|
||||
assert is_cache_stale(conn) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests: refresh_models_cache + get_cached_models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_refresh_stores_models():
|
||||
conn = db_module.get_conn()
|
||||
with patch(
|
||||
"app.services.models.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_MODELS_RAW,
|
||||
):
|
||||
count = await refresh_models_cache(conn)
|
||||
assert count == 5
|
||||
all_models = get_cached_models(conn)
|
||||
assert len(all_models) == 5
|
||||
|
||||
|
||||
async def test_refresh_replaces_old_cache():
|
||||
conn = db_module.get_conn()
|
||||
old_time = datetime.now(timezone.utc).replace(
|
||||
tzinfo=None) - timedelta(hours=30)
|
||||
conn.execute(
|
||||
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
|
||||
["old/model", "Old Model", "text", old_time],
|
||||
)
|
||||
with patch(
|
||||
"app.services.models.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_MODELS_RAW,
|
||||
):
|
||||
await refresh_models_cache(conn)
|
||||
ids = [m["id"] for m in get_cached_models(conn)]
|
||||
assert "old/model" not in ids
|
||||
assert "openai/gpt-4o" in ids
|
||||
assert len(ids) == 5
|
||||
|
||||
|
||||
def test_get_cached_models_filter_by_modality():
|
||||
conn = db_module.get_conn()
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
for m in FAKE_MODELS_RAW:
|
||||
modality = _extract_output_modality(m)
|
||||
conn.execute(
|
||||
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
|
||||
[m["id"], m["name"], modality, now],
|
||||
)
|
||||
text_models = get_cached_models(conn, modality="text")
|
||||
# gpt-4o, claude-3-haiku (gemini has output_modalities=["image","text"] → classified as "image")
|
||||
assert len(text_models) == 2
|
||||
assert all(m["modality"] == "text" for m in text_models)
|
||||
|
||||
image_models = get_cached_models(conn, modality="image")
|
||||
# dall-e-3 + gemini (output_modalities starts with image)
|
||||
assert len(image_models) == 2
|
||||
image_ids = [m["id"] for m in image_models]
|
||||
assert "openai/dall-e-3" in image_ids
|
||||
assert "google/gemini-2.5-flash-image" in image_ids
|
||||
|
||||
video_models = get_cached_models(conn, modality="video")
|
||||
assert len(video_models) == 1
|
||||
assert video_models[0]["id"] == "openai/sora-2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests: GET /models/
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_list_models_endpoint_auto_refreshes(client):
|
||||
token = await _register_login(client, "user@example.com", "secret123")
|
||||
with patch(
|
||||
"app.services.models.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_MODELS_RAW,
|
||||
) as mock_fetch:
|
||||
resp = await client.get(
|
||||
"/models/", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 5
|
||||
assert mock_fetch.await_count >= 1
|
||||
|
||||
|
||||
async def test_list_models_endpoint_uses_cache(client):
|
||||
token = await _register_login(client, "user@example.com", "secret123")
|
||||
conn = db_module.get_conn()
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
conn.execute(
|
||||
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
|
||||
["cached/model", "Cached Model", "text", now],
|
||||
)
|
||||
with patch(
|
||||
"app.services.models.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_fetch:
|
||||
resp = await client.get(
|
||||
"/models/?modality=text", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()[0]["id"] == "cached/model"
|
||||
mock_fetch.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_list_models_endpoint_requires_auth(client):
|
||||
resp = await client.get("/models/")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_list_models_filter_by_modality(client):
|
||||
token = await _register_login(client, "user@example.com", "secret123")
|
||||
with patch(
|
||||
"app.services.models.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_MODELS_RAW,
|
||||
):
|
||||
resp = await client.get(
|
||||
"/models/?modality=image", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2 # dall-e-3 + gemini-2.5-flash-image
|
||||
image_ids = [m["id"] for m in data]
|
||||
assert "openai/dall-e-3" in image_ids
|
||||
assert "google/gemini-2.5-flash-image" in image_ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests: POST /models/refresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_refresh_endpoint_requires_admin(client):
|
||||
token = await _register_login(client, "user@example.com", "secret123")
|
||||
resp = await client.post(
|
||||
"/models/refresh", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_refresh_endpoint_admin_succeeds(client):
|
||||
token = await _register_login(client, "admin@example.com", "secret123", is_admin=True)
|
||||
with patch(
|
||||
"app.services.models.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_MODELS_RAW,
|
||||
):
|
||||
resp = await client.post(
|
||||
"/models/refresh", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["refreshed"] == 5
|
||||
|
||||
|
||||
async def test_refresh_endpoint_502_on_openrouter_error(client):
|
||||
token = await _register_login(client, "admin@example.com", "secret123", is_admin=True)
|
||||
with patch(
|
||||
"app.services.models.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("network error"),
|
||||
):
|
||||
resp = await client.post(
|
||||
"/models/refresh", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert resp.status_code == 502
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests: get_model_output_modalities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_get_model_output_modalities_image_only():
|
||||
conn = db_module.get_conn()
|
||||
with patch(
|
||||
"app.services.models.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_MODELS_RAW,
|
||||
):
|
||||
await refresh_models_cache(conn)
|
||||
modalities = get_model_output_modalities(conn, "openai/dall-e-3")
|
||||
assert modalities == ["image"]
|
||||
|
||||
|
||||
async def test_get_model_output_modalities_image_text():
|
||||
conn = db_module.get_conn()
|
||||
with patch(
|
||||
"app.services.models.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_MODELS_RAW,
|
||||
):
|
||||
await refresh_models_cache(conn)
|
||||
modalities = get_model_output_modalities(
|
||||
conn, "google/gemini-2.5-flash-image")
|
||||
assert set(modalities) == {"image", "text"}
|
||||
|
||||
|
||||
def test_get_model_output_modalities_unknown_model():
|
||||
conn = db_module.get_conn()
|
||||
result = get_model_output_modalities(conn, "unknown/model")
|
||||
assert result == []
|
||||
"""Tests for model cache service and router."""
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app import db as db_module
|
||||
from app.main import app
|
||||
from app.services.models import (
|
||||
_extract_output_modality,
|
||||
_normalize_modality,
|
||||
_parse_modality,
|
||||
get_cached_models,
|
||||
get_model_output_modalities,
|
||||
is_cache_stale,
|
||||
refresh_models_cache,
|
||||
)
|
||||
|
||||
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
||||
os.environ.setdefault("OPENROUTER_API_KEY", "test-key")
|
||||
|
||||
FAKE_MODELS_RAW = [
|
||||
{
|
||||
"id": "openai/gpt-4o",
|
||||
"name": "GPT-4o",
|
||||
"context_length": 128000,
|
||||
"pricing": {"prompt": "0.000005"},
|
||||
"architecture": {"modality": "text->text", "output_modalities": ["text"]},
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-3-haiku",
|
||||
"name": "Claude 3 Haiku",
|
||||
"context_length": 200000,
|
||||
"pricing": {},
|
||||
"architecture": {"modality": "text+image->text", "output_modalities": ["text"]},
|
||||
},
|
||||
{
|
||||
"id": "openai/dall-e-3",
|
||||
"name": "DALL-E 3",
|
||||
"context_length": None,
|
||||
"pricing": {"image": "0.04"},
|
||||
"architecture": {"modality": "text->image", "output_modalities": ["image"]},
|
||||
},
|
||||
{
|
||||
"id": "openai/sora-2",
|
||||
"name": "Sora 2",
|
||||
"context_length": None,
|
||||
"pricing": {"video": "0.10"},
|
||||
"architecture": {"modality": "text->video", "output_modalities": ["video"]},
|
||||
},
|
||||
{
|
||||
"id": "google/gemini-2.5-flash-image",
|
||||
"name": "Gemini 2.5 Flash Image",
|
||||
"context_length": None,
|
||||
"pricing": {},
|
||||
"architecture": {"output_modalities": ["image", "text"]},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
db_module._conn = None
|
||||
db_module.init_db(":memory:")
|
||||
yield
|
||||
db_module.close_db()
|
||||
db_module._conn = None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(fresh_db):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
async def _register_login(client, email, password, is_admin=False):
|
||||
"""Register + login; optionally promote to admin directly in DB."""
|
||||
await client.post("/auth/register", json={"email": email, "password": password})
|
||||
if is_admin:
|
||||
db_module.get_conn().execute(
|
||||
"UPDATE users SET role = 'admin' WHERE email = ?", [email]
|
||||
)
|
||||
resp = await client.post("/auth/login", json={"email": email, "password": password})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests: _parse_modality
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_parse_modality_text():
|
||||
assert _parse_modality("text->text") == "text"
|
||||
|
||||
|
||||
def test_parse_modality_multimodal_input_text_output():
|
||||
assert _parse_modality("text+image->text") == "text"
|
||||
|
||||
|
||||
def test_parse_modality_image():
|
||||
assert _parse_modality("text->image") == "image"
|
||||
|
||||
|
||||
def test_parse_modality_video():
|
||||
assert _parse_modality("text->video") == "video"
|
||||
|
||||
|
||||
def test_parse_modality_audio():
|
||||
assert _parse_modality("text->audio") == "audio"
|
||||
|
||||
|
||||
def test_parse_modality_no_arrow_fallback():
|
||||
assert _parse_modality("text") == "text"
|
||||
|
||||
|
||||
def test_normalize_embedding_alias():
|
||||
assert _normalize_modality("embedding") == "embeddings"
|
||||
|
||||
|
||||
def test_extract_output_modality_prefers_output_modalities():
|
||||
model = {
|
||||
"architecture": {
|
||||
"modality": "text->text",
|
||||
"output_modalities": ["image"],
|
||||
}
|
||||
}
|
||||
assert _extract_output_modality(model) == "image"
|
||||
|
||||
|
||||
def test_extract_output_modality_legacy_fallback():
|
||||
model = {"architecture": {"modality": "text->audio"}}
|
||||
assert _extract_output_modality(model) == "audio"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests: is_cache_stale
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_cache_stale_when_empty():
|
||||
conn = db_module.get_conn()
|
||||
assert is_cache_stale(conn) is True
|
||||
|
||||
|
||||
def test_cache_not_stale_after_fresh_insert():
|
||||
conn = db_module.get_conn()
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
conn.execute(
|
||||
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
|
||||
["openai/gpt-4o", "GPT-4o", "text", now],
|
||||
)
|
||||
assert is_cache_stale(conn) is False
|
||||
|
||||
|
||||
def test_cache_stale_after_ttl_exceeded():
|
||||
conn = db_module.get_conn()
|
||||
# Store naive UTC to match DuckDB TIMESTAMP behaviour
|
||||
old_time = datetime.now(timezone.utc).replace(
|
||||
tzinfo=None) - timedelta(hours=25)
|
||||
conn.execute(
|
||||
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
|
||||
["openai/gpt-4o", "GPT-4o", "text", old_time],
|
||||
)
|
||||
assert is_cache_stale(conn) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests: refresh_models_cache + get_cached_models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_refresh_stores_models():
|
||||
conn = db_module.get_conn()
|
||||
with patch(
|
||||
"app.services.models.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_MODELS_RAW,
|
||||
):
|
||||
count = await refresh_models_cache(conn)
|
||||
assert count == 5
|
||||
all_models = get_cached_models(conn)
|
||||
assert len(all_models) == 5
|
||||
|
||||
|
||||
async def test_refresh_replaces_old_cache():
|
||||
conn = db_module.get_conn()
|
||||
old_time = datetime.now(timezone.utc).replace(
|
||||
tzinfo=None) - timedelta(hours=30)
|
||||
conn.execute(
|
||||
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
|
||||
["old/model", "Old Model", "text", old_time],
|
||||
)
|
||||
with patch(
|
||||
"app.services.models.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_MODELS_RAW,
|
||||
):
|
||||
await refresh_models_cache(conn)
|
||||
ids = [m["id"] for m in get_cached_models(conn)]
|
||||
assert "old/model" not in ids
|
||||
assert "openai/gpt-4o" in ids
|
||||
assert len(ids) == 5
|
||||
|
||||
|
||||
def test_get_cached_models_filter_by_modality():
|
||||
conn = db_module.get_conn()
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
for m in FAKE_MODELS_RAW:
|
||||
modality = _extract_output_modality(m)
|
||||
conn.execute(
|
||||
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
|
||||
[m["id"], m["name"], modality, now],
|
||||
)
|
||||
text_models = get_cached_models(conn, modality="text")
|
||||
# gpt-4o, claude-3-haiku (gemini has output_modalities=["image","text"] → classified as "image")
|
||||
assert len(text_models) == 2
|
||||
assert all(m["modality"] == "text" for m in text_models)
|
||||
|
||||
image_models = get_cached_models(conn, modality="image")
|
||||
# dall-e-3 + gemini (output_modalities starts with image)
|
||||
assert len(image_models) == 2
|
||||
image_ids = [m["id"] for m in image_models]
|
||||
assert "openai/dall-e-3" in image_ids
|
||||
assert "google/gemini-2.5-flash-image" in image_ids
|
||||
|
||||
video_models = get_cached_models(conn, modality="video")
|
||||
assert len(video_models) == 1
|
||||
assert video_models[0]["id"] == "openai/sora-2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests: GET /models/
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_list_models_endpoint_auto_refreshes(client):
|
||||
token = await _register_login(client, "user@example.com", "secret123")
|
||||
with patch(
|
||||
"app.services.models.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_MODELS_RAW,
|
||||
) as mock_fetch:
|
||||
resp = await client.get(
|
||||
"/models/", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 5
|
||||
assert mock_fetch.await_count >= 1
|
||||
|
||||
|
||||
async def test_list_models_endpoint_uses_cache(client):
|
||||
token = await _register_login(client, "user@example.com", "secret123")
|
||||
conn = db_module.get_conn()
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
conn.execute(
|
||||
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
|
||||
["cached/model", "Cached Model", "text", now],
|
||||
)
|
||||
with patch(
|
||||
"app.services.models.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_fetch:
|
||||
resp = await client.get(
|
||||
"/models/?modality=text", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()[0]["id"] == "cached/model"
|
||||
mock_fetch.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_list_models_endpoint_requires_auth(client):
|
||||
resp = await client.get("/models/")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_list_models_filter_by_modality(client):
|
||||
token = await _register_login(client, "user@example.com", "secret123")
|
||||
with patch(
|
||||
"app.services.models.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_MODELS_RAW,
|
||||
):
|
||||
resp = await client.get(
|
||||
"/models/?modality=image", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2 # dall-e-3 + gemini-2.5-flash-image
|
||||
image_ids = [m["id"] for m in data]
|
||||
assert "openai/dall-e-3" in image_ids
|
||||
assert "google/gemini-2.5-flash-image" in image_ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests: POST /models/refresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_refresh_endpoint_requires_admin(client):
|
||||
token = await _register_login(client, "user@example.com", "secret123")
|
||||
resp = await client.post(
|
||||
"/models/refresh", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_refresh_endpoint_admin_succeeds(client):
|
||||
token = await _register_login(client, "admin@example.com", "secret123", is_admin=True)
|
||||
with patch(
|
||||
"app.services.models.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_MODELS_RAW,
|
||||
):
|
||||
resp = await client.post(
|
||||
"/models/refresh", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["refreshed"] == 5
|
||||
|
||||
|
||||
async def test_refresh_endpoint_502_on_openrouter_error(client):
|
||||
token = await _register_login(client, "admin@example.com", "secret123", is_admin=True)
|
||||
with patch(
|
||||
"app.services.models.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("network error"),
|
||||
):
|
||||
resp = await client.post(
|
||||
"/models/refresh", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert resp.status_code == 502
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests: get_model_output_modalities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_get_model_output_modalities_image_only():
|
||||
conn = db_module.get_conn()
|
||||
with patch(
|
||||
"app.services.models.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_MODELS_RAW,
|
||||
):
|
||||
await refresh_models_cache(conn)
|
||||
modalities = get_model_output_modalities(conn, "openai/dall-e-3")
|
||||
assert modalities == ["image"]
|
||||
|
||||
|
||||
async def test_get_model_output_modalities_image_text():
|
||||
conn = db_module.get_conn()
|
||||
with patch(
|
||||
"app.services.models.openrouter.list_models",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_MODELS_RAW,
|
||||
):
|
||||
await refresh_models_cache(conn)
|
||||
modalities = get_model_output_modalities(
|
||||
conn, "google/gemini-2.5-flash-image")
|
||||
assert set(modalities) == {"image", "text"}
|
||||
|
||||
|
||||
def test_get_model_output_modalities_unknown_model():
|
||||
conn = db_module.get_conn()
|
||||
result = get_model_output_modalities(conn, "unknown/model")
|
||||
assert result == []
|
||||
+208
-208
@@ -1,208 +1,208 @@
|
||||
"""Integration tests for user management endpoints."""
|
||||
import os
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import app
|
||||
from app import db as db_module
|
||||
|
||||
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
db_module._conn = None
|
||||
db_module.init_db(":memory:")
|
||||
yield
|
||||
db_module.close_db()
|
||||
db_module._conn = None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(fresh_db):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
async def _register_and_login(client, email="user@example.com", password="secret123"):
|
||||
await client.post("/auth/register", json={"email": email, "password": password})
|
||||
resp = await client.post("/auth/login", json={"email": email, "password": password})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
async def _make_admin(user_id: str):
|
||||
"""Directly set a user's role to admin in the DB."""
|
||||
conn = db_module.get_conn()
|
||||
conn.execute("UPDATE users SET role = 'admin' WHERE id = ?", [user_id])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /users/me
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_get_me(client):
|
||||
token = await _register_and_login(client)
|
||||
resp = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["email"] == "user@example.com"
|
||||
assert data["role"] == "user"
|
||||
assert "id" in data
|
||||
|
||||
|
||||
async def test_get_me_unauthenticated(client):
|
||||
resp = await client.get("/users/me")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /users/me
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_update_me_email(client):
|
||||
token = await _register_and_login(client)
|
||||
resp = await client.put(
|
||||
"/users/me",
|
||||
json={"email": "new@example.com"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["email"] == "new@example.com"
|
||||
|
||||
|
||||
async def test_update_me_password(client):
|
||||
token = await _register_and_login(client)
|
||||
resp = await client.put(
|
||||
"/users/me",
|
||||
json={"password": "newpassword123"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# Verify new password works for login
|
||||
login = await client.post(
|
||||
"/auth/login", json={"email": "user@example.com", "password": "newpassword123"}
|
||||
)
|
||||
assert login.status_code == 200
|
||||
|
||||
|
||||
async def test_update_me_duplicate_email(client):
|
||||
await _register_and_login(client, "other@example.com")
|
||||
token = await _register_and_login(client, "user@example.com")
|
||||
resp = await client.put(
|
||||
"/users/me",
|
||||
json={"email": "other@example.com"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /users (admin only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_list_users_as_admin(client):
|
||||
token = await _register_and_login(client)
|
||||
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
||||
await _make_admin(me.json()["id"])
|
||||
|
||||
# Re-login to get a token with admin role
|
||||
login = await client.post(
|
||||
"/auth/login", json={"email": "user@example.com", "password": "secret123"}
|
||||
)
|
||||
admin_token = login.json()["access_token"]
|
||||
resp = await client.get("/users", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(resp.json(), list)
|
||||
assert len(resp.json()) >= 1
|
||||
emails = [u["email"] for u in resp.json()]
|
||||
assert "user@example.com" in emails
|
||||
|
||||
|
||||
async def test_list_users_as_regular_user(client):
|
||||
token = await _register_and_login(client)
|
||||
resp = await client.get("/users", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /users/{id} (admin only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_delete_user_as_admin(client):
|
||||
# Register target user
|
||||
await client.post("/auth/register", json={"email": "target@example.com", "password": "secret123"})
|
||||
target_resp = await client.post("/auth/login", json={"email": "target@example.com", "password": "secret123"})
|
||||
target_token = target_resp.json()["access_token"]
|
||||
target_me = await client.get("/users/me", headers={"Authorization": f"Bearer {target_token}"})
|
||||
target_id = target_me.json()["id"]
|
||||
|
||||
# Register admin
|
||||
token = await _register_and_login(client)
|
||||
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
||||
await _make_admin(me.json()["id"])
|
||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||
admin_token = login.json()["access_token"]
|
||||
|
||||
resp = await client.delete(f"/users/{target_id}", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
async def test_delete_own_account_forbidden(client):
|
||||
token = await _register_and_login(client)
|
||||
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
||||
user_id = me.json()["id"]
|
||||
await _make_admin(user_id)
|
||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||
admin_token = login.json()["access_token"]
|
||||
|
||||
resp = await client.delete(f"/users/{user_id}", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /users/{id}/role (admin only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_set_role_as_admin(client):
|
||||
# Register target
|
||||
await client.post("/auth/register", json={"email": "target@example.com", "password": "secret123"})
|
||||
target_resp = await client.post("/auth/login", json={"email": "target@example.com", "password": "secret123"})
|
||||
target_me = await client.get("/users/me", headers={"Authorization": f"Bearer {target_resp.json()['access_token']}"})
|
||||
target_id = target_me.json()["id"]
|
||||
|
||||
# Register admin
|
||||
token = await _register_and_login(client)
|
||||
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
||||
await _make_admin(me.json()["id"])
|
||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||
admin_token = login.json()["access_token"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/users/{target_id}/role",
|
||||
json={"role": "admin"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["role"] == "admin"
|
||||
|
||||
|
||||
async def test_set_invalid_role(client):
|
||||
await client.post("/auth/register", json={"email": "target@example.com", "password": "secret123"})
|
||||
target_resp = await client.post("/auth/login", json={"email": "target@example.com", "password": "secret123"})
|
||||
target_me = await client.get("/users/me", headers={"Authorization": f"Bearer {target_resp.json()['access_token']}"})
|
||||
target_id = target_me.json()["id"]
|
||||
|
||||
token = await _register_and_login(client)
|
||||
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
||||
await _make_admin(me.json()["id"])
|
||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||
admin_token = login.json()["access_token"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/users/{target_id}/role",
|
||||
json={"role": "superuser"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
"""Integration tests for user management endpoints."""
|
||||
import os
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import app
|
||||
from app import db as db_module
|
||||
|
||||
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
db_module._conn = None
|
||||
db_module.init_db(":memory:")
|
||||
yield
|
||||
db_module.close_db()
|
||||
db_module._conn = None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(fresh_db):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
async def _register_and_login(client, email="user@example.com", password="secret123"):
|
||||
await client.post("/auth/register", json={"email": email, "password": password})
|
||||
resp = await client.post("/auth/login", json={"email": email, "password": password})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
async def _make_admin(user_id: str):
|
||||
"""Directly set a user's role to admin in the DB."""
|
||||
conn = db_module.get_conn()
|
||||
conn.execute("UPDATE users SET role = 'admin' WHERE id = ?", [user_id])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /users/me
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_get_me(client):
|
||||
token = await _register_and_login(client)
|
||||
resp = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["email"] == "user@example.com"
|
||||
assert data["role"] == "user"
|
||||
assert "id" in data
|
||||
|
||||
|
||||
async def test_get_me_unauthenticated(client):
|
||||
resp = await client.get("/users/me")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /users/me
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_update_me_email(client):
|
||||
token = await _register_and_login(client)
|
||||
resp = await client.put(
|
||||
"/users/me",
|
||||
json={"email": "new@example.com"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["email"] == "new@example.com"
|
||||
|
||||
|
||||
async def test_update_me_password(client):
|
||||
token = await _register_and_login(client)
|
||||
resp = await client.put(
|
||||
"/users/me",
|
||||
json={"password": "newpassword123"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# Verify new password works for login
|
||||
login = await client.post(
|
||||
"/auth/login", json={"email": "user@example.com", "password": "newpassword123"}
|
||||
)
|
||||
assert login.status_code == 200
|
||||
|
||||
|
||||
async def test_update_me_duplicate_email(client):
|
||||
await _register_and_login(client, "other@example.com")
|
||||
token = await _register_and_login(client, "user@example.com")
|
||||
resp = await client.put(
|
||||
"/users/me",
|
||||
json={"email": "other@example.com"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /users (admin only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_list_users_as_admin(client):
|
||||
token = await _register_and_login(client)
|
||||
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
||||
await _make_admin(me.json()["id"])
|
||||
|
||||
# Re-login to get a token with admin role
|
||||
login = await client.post(
|
||||
"/auth/login", json={"email": "user@example.com", "password": "secret123"}
|
||||
)
|
||||
admin_token = login.json()["access_token"]
|
||||
resp = await client.get("/users", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(resp.json(), list)
|
||||
assert len(resp.json()) >= 1
|
||||
emails = [u["email"] for u in resp.json()]
|
||||
assert "user@example.com" in emails
|
||||
|
||||
|
||||
async def test_list_users_as_regular_user(client):
|
||||
token = await _register_and_login(client)
|
||||
resp = await client.get("/users", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /users/{id} (admin only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_delete_user_as_admin(client):
|
||||
# Register target user
|
||||
await client.post("/auth/register", json={"email": "target@example.com", "password": "secret123"})
|
||||
target_resp = await client.post("/auth/login", json={"email": "target@example.com", "password": "secret123"})
|
||||
target_token = target_resp.json()["access_token"]
|
||||
target_me = await client.get("/users/me", headers={"Authorization": f"Bearer {target_token}"})
|
||||
target_id = target_me.json()["id"]
|
||||
|
||||
# Register admin
|
||||
token = await _register_and_login(client)
|
||||
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
||||
await _make_admin(me.json()["id"])
|
||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||
admin_token = login.json()["access_token"]
|
||||
|
||||
resp = await client.delete(f"/users/{target_id}", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
async def test_delete_own_account_forbidden(client):
|
||||
token = await _register_and_login(client)
|
||||
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
||||
user_id = me.json()["id"]
|
||||
await _make_admin(user_id)
|
||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||
admin_token = login.json()["access_token"]
|
||||
|
||||
resp = await client.delete(f"/users/{user_id}", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /users/{id}/role (admin only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_set_role_as_admin(client):
|
||||
# Register target
|
||||
await client.post("/auth/register", json={"email": "target@example.com", "password": "secret123"})
|
||||
target_resp = await client.post("/auth/login", json={"email": "target@example.com", "password": "secret123"})
|
||||
target_me = await client.get("/users/me", headers={"Authorization": f"Bearer {target_resp.json()['access_token']}"})
|
||||
target_id = target_me.json()["id"]
|
||||
|
||||
# Register admin
|
||||
token = await _register_and_login(client)
|
||||
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
||||
await _make_admin(me.json()["id"])
|
||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||
admin_token = login.json()["access_token"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/users/{target_id}/role",
|
||||
json={"role": "admin"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["role"] == "admin"
|
||||
|
||||
|
||||
async def test_set_invalid_role(client):
|
||||
await client.post("/auth/register", json={"email": "target@example.com", "password": "secret123"})
|
||||
target_resp = await client.post("/auth/login", json={"email": "target@example.com", "password": "secret123"})
|
||||
target_me = await client.get("/users/me", headers={"Authorization": f"Bearer {target_resp.json()['access_token']}"})
|
||||
target_id = target_me.json()["id"]
|
||||
|
||||
token = await _register_and_login(client)
|
||||
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
||||
await _make_admin(me.json()["id"])
|
||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||
admin_token = login.json()["access_token"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/users/{target_id}/role",
|
||||
json={"role": "superuser"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
Reference in new issue
Block a user