initial commit
This commit is contained in:
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
86
backend/tests/conftest.py
Normal file
86
backend/tests/conftest.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""FluentGerman.ai — Test configuration & fixtures."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.database import Base, get_db
|
||||
from app.main import app
|
||||
|
||||
# Use SQLite for tests (in-memory)
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///./test.db"
|
||||
|
||||
|
||||
def get_test_settings() -> Settings:
|
||||
return Settings(
|
||||
database_url=TEST_DATABASE_URL,
|
||||
secret_key="test-secret-key",
|
||||
llm_api_key="test-key",
|
||||
admin_password="testadmin123",
|
||||
)
|
||||
|
||||
|
||||
test_engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
test_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with test_session() as session:
|
||||
yield session
|
||||
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
app.dependency_overrides[get_settings] = get_test_settings
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def setup_database():
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client() -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def admin_token(client: AsyncClient) -> str:
|
||||
"""Create admin and return token."""
|
||||
from app.auth import hash_password
|
||||
from app.models import User
|
||||
|
||||
async with test_session() as db:
|
||||
admin = User(
|
||||
username="admin",
|
||||
email="admin@test.com",
|
||||
hashed_password=hash_password("admin123"),
|
||||
is_admin=True,
|
||||
)
|
||||
db.add(admin)
|
||||
await db.commit()
|
||||
|
||||
resp = await client.post("/api/auth/login", json={"username": "admin", "password": "admin123"})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def user_token(client: AsyncClient, admin_token: str) -> str:
|
||||
"""Create a regular user via admin API and return their token."""
|
||||
await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "testuser", "email": "user@test.com", "password": "user123"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
resp = await client.post("/api/auth/login", json={"username": "testuser", "password": "user123"})
|
||||
return resp.json()["access_token"]
|
||||
55
backend/tests/test_auth.py
Normal file
55
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""FluentGerman.ai — Auth tests."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(client: AsyncClient, admin_token: str):
|
||||
"""Admin can log in and receives a token."""
|
||||
assert admin_token is not None
|
||||
assert len(admin_token) > 20
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_wrong_password(client: AsyncClient):
|
||||
"""Wrong password returns 401."""
|
||||
from app.auth import hash_password
|
||||
from app.models import User
|
||||
from tests.conftest import test_session
|
||||
|
||||
async with test_session() as db:
|
||||
user = User(
|
||||
username="logintest",
|
||||
email="logintest@test.com",
|
||||
hashed_password=hash_password("correct"),
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
|
||||
resp = await client.post("/api/auth/login", json={"username": "logintest", "password": "wrong"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_nonexistent_user(client: AsyncClient):
|
||||
"""Nonexistent user returns 401."""
|
||||
resp = await client.post("/api/auth/login", json={"username": "nobody", "password": "pass"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_me_endpoint(client: AsyncClient, admin_token: str):
|
||||
"""Authenticated user can access /me."""
|
||||
resp = await client.get("/api/auth/me", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["username"] == "admin"
|
||||
assert data["is_admin"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_me_unauthenticated(client: AsyncClient):
|
||||
"""Unauthenticated request to /me returns 401."""
|
||||
resp = await client.get("/api/auth/me")
|
||||
assert resp.status_code == 401
|
||||
117
backend/tests/test_instructions.py
Normal file
117
backend/tests/test_instructions.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""FluentGerman.ai — Instruction management tests."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_global_instruction(client: AsyncClient, admin_token: str):
|
||||
"""Admin can create a global instruction."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
resp = await client.post(
|
||||
"/api/instructions/",
|
||||
json={"title": "Teaching Method", "content": "Use immersive conversation", "type": "global"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["title"] == "Teaching Method"
|
||||
assert data["type"] == "global"
|
||||
assert data["user_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_personal_instruction(client: AsyncClient, admin_token: str, user_token: str):
|
||||
"""Admin can create a personal instruction for a user."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
# Get user id
|
||||
users_resp = await client.get("/api/users/", headers=headers)
|
||||
user_id = users_resp.json()[0]["id"]
|
||||
|
||||
resp = await client.post(
|
||||
"/api/instructions/",
|
||||
json={
|
||||
"title": "Focus on verbs",
|
||||
"content": "This student needs extra practice with irregular verbs.",
|
||||
"type": "personal",
|
||||
"user_id": user_id,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["user_id"] == user_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_instructions_by_user(client: AsyncClient, admin_token: str, user_token: str):
|
||||
"""Filtering instructions by user returns personal + global."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
users_resp = await client.get("/api/users/", headers=headers)
|
||||
user_id = users_resp.json()[0]["id"]
|
||||
|
||||
# Create global
|
||||
await client.post(
|
||||
"/api/instructions/",
|
||||
json={"title": "Global Rule", "content": "Always respond in German", "type": "global"},
|
||||
headers=headers,
|
||||
)
|
||||
# Create personal
|
||||
await client.post(
|
||||
"/api/instructions/",
|
||||
json={"title": "Personal", "content": "Focus on A1", "type": "personal", "user_id": user_id},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
resp = await client.get(f"/api/instructions/?user_id={user_id}", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
instructions = resp.json()
|
||||
types = [i["type"] for i in instructions]
|
||||
assert "global" in types
|
||||
assert "personal" in types
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_instruction(client: AsyncClient, admin_token: str):
|
||||
"""Admin can update an instruction."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
create_resp = await client.post(
|
||||
"/api/instructions/",
|
||||
json={"title": "Old Title", "content": "Old content", "type": "global"},
|
||||
headers=headers,
|
||||
)
|
||||
inst_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/instructions/{inst_id}",
|
||||
json={"title": "New Title", "content": "Updated content"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["title"] == "New Title"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_instruction(client: AsyncClient, admin_token: str):
|
||||
"""Admin can delete an instruction."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
create_resp = await client.post(
|
||||
"/api/instructions/",
|
||||
json={"title": "Delete me", "content": "Temp", "type": "global"},
|
||||
headers=headers,
|
||||
)
|
||||
inst_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.delete(f"/api/instructions/{inst_id}", headers=headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_cannot_manage_instructions(client: AsyncClient, user_token: str):
|
||||
"""Regular user cannot access instruction endpoints."""
|
||||
headers = {"Authorization": f"Bearer {user_token}"}
|
||||
resp = await client.get("/api/instructions/", headers=headers)
|
||||
assert resp.status_code == 403
|
||||
58
backend/tests/test_llm_service.py
Normal file
58
backend/tests/test_llm_service.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""FluentGerman.ai — LLM service tests (mocked)."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.instruction_service import get_system_prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_prompt_empty():
|
||||
"""When no instructions exist, returns default prompt."""
|
||||
db = AsyncMock()
|
||||
# Mock two queries returning empty results
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
db.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
prompt = await get_system_prompt(db, user_id=1)
|
||||
assert "German" in prompt
|
||||
assert "tutor" in prompt.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_prompt_with_instructions():
|
||||
"""System prompt includes global and personal instructions."""
|
||||
from app.models import Instruction, InstructionType
|
||||
|
||||
global_inst = MagicMock()
|
||||
global_inst.title = "Method"
|
||||
global_inst.content = "Use immersive conversation"
|
||||
global_inst.type = InstructionType.GLOBAL
|
||||
|
||||
personal_inst = MagicMock()
|
||||
personal_inst.title = "Focus"
|
||||
personal_inst.content = "Practice articles"
|
||||
personal_inst.type = InstructionType.PERSONAL
|
||||
|
||||
db = AsyncMock()
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
result = MagicMock()
|
||||
if call_count == 1:
|
||||
result.scalars.return_value.all.return_value = [global_inst]
|
||||
else:
|
||||
result.scalars.return_value.all.return_value = [personal_inst]
|
||||
return result
|
||||
|
||||
db.execute = mock_execute
|
||||
|
||||
prompt = await get_system_prompt(db, user_id=1)
|
||||
assert "TEACHING METHOD" in prompt
|
||||
assert "immersive conversation" in prompt
|
||||
assert "PERSONAL INSTRUCTIONS" in prompt
|
||||
assert "Practice articles" in prompt
|
||||
95
backend/tests/test_users.py
Normal file
95
backend/tests/test_users.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""FluentGerman.ai — User management tests."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user(client: AsyncClient, admin_token: str):
|
||||
"""Admin can create a new client."""
|
||||
resp = await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "alice", "email": "alice@test.com", "password": "pass123"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["username"] == "alice"
|
||||
assert data["is_admin"] is False
|
||||
assert data["is_active"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_duplicate_user(client: AsyncClient, admin_token: str):
|
||||
"""Duplicate username/email returns 409."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "bob", "email": "bob@test.com", "password": "pass"},
|
||||
headers=headers,
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "bob", "email": "bob2@test.com", "password": "pass"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users(client: AsyncClient, admin_token: str):
|
||||
"""Admin can list all clients."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "charlie", "email": "charlie@test.com", "password": "pass"},
|
||||
headers=headers,
|
||||
)
|
||||
resp = await client.get("/api/users/", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
users = resp.json()
|
||||
assert len(users) >= 1
|
||||
assert any(u["username"] == "charlie" for u in users)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user(client: AsyncClient, admin_token: str):
|
||||
"""Admin can update a client."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
create_resp = await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "dave", "email": "dave@test.com", "password": "pass"},
|
||||
headers=headers,
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/users/{user_id}",
|
||||
json={"username": "dave_updated"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["username"] == "dave_updated"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user(client: AsyncClient, admin_token: str):
|
||||
"""Admin can delete a client."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
create_resp = await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "eve", "email": "eve@test.com", "password": "pass"},
|
||||
headers=headers,
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.delete(f"/api/users/{user_id}", headers=headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_cannot_manage_users(client: AsyncClient, user_token: str):
|
||||
"""Regular user cannot access user management."""
|
||||
headers = {"Authorization": f"Bearer {user_token}"}
|
||||
resp = await client.get("/api/users/", headers=headers)
|
||||
assert resp.status_code == 403
|
||||
Reference in New Issue
Block a user