initial commit

This commit is contained in:
2026-02-12 18:45:10 +01:00
commit be7bbba456
42 changed files with 3767 additions and 0 deletions

View File

86
backend/tests/conftest.py Normal file
View 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"]

View 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

View 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

View 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

View 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