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:
zwitschi committed 2026-09-03 15:05:27 +02:00
1 parent 3b8c30c289
commit a4b874598d
88 files changed
+9766 -9766

No files matched your search

+119 -119
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
File diff suppressed because it is too large. Load diff
+184 -184
View File
@@ -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
View File
@@ -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
View File
@@ -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