This commit is contained in:
parent
acadf77e2e
commit
2cae5d9445
6 changed files with 200 additions and 0 deletions
24
.forgejo/workflows/test.yaml
Normal file
24
.forgejo/workflows/test.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "main"
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
|
||||||
|
- name: Install pytest
|
||||||
|
run: pip install pytest
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
working-directory: test
|
||||||
|
run: docker-compose up -d && pytest -v
|
||||||
59
docker-compose.test.yml
Normal file
59
docker-compose.test.yml
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: langlearn_test
|
||||||
|
POSTGRES_PASSWORD: testpassword
|
||||||
|
POSTGRES_DB: langlearn_test
|
||||||
|
tmpfs:
|
||||||
|
- /var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U langlearn_test"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
storage:
|
||||||
|
image: minio/minio:latest
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: langlearn_test
|
||||||
|
MINIO_ROOT_PASSWORD: testpassword123
|
||||||
|
tmpfs:
|
||||||
|
- /data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -sf http://localhost:9000/minio/health/live || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: ./api
|
||||||
|
ports:
|
||||||
|
- "18000:8000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://langlearn_test:testpassword@db:5432/langlearn_test
|
||||||
|
JWT_SECRET: test-jwt-secret-not-for-production
|
||||||
|
ANTHROPIC_API_KEY: test-key
|
||||||
|
DEEPL_API_KEY: test-key
|
||||||
|
DEEPGRAM_API_KEY: test-key
|
||||||
|
GEMINI_API_KEY: test-key
|
||||||
|
ADMIN_USER_EMAILS: admin@test.com
|
||||||
|
API_BASE_URL: http://localhost:18000
|
||||||
|
STORAGE_ENDPOINT_URL: http://storage:9000
|
||||||
|
STORAGE_ACCESS_KEY: langlearn_test
|
||||||
|
STORAGE_SECRET_KEY: testpassword123
|
||||||
|
STORAGE_BUCKET: langlearn-test
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
storage:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- "CMD-SHELL"
|
||||||
|
- "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
start_period: 10s
|
||||||
5
tests/README.md
Normal file
5
tests/README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Tests
|
||||||
|
|
||||||
|
This module contains system-level tests for the Langauge Learning App, e.g. end-to-end (e2e) or API-level tests.
|
||||||
|
|
||||||
|
Because the whole system relies on multiple components (api, frontend, storage, db), it can be efficient to centralise these tests into a single module, rather than having each module re-create them. Additionally, it is not the responsibility of any single feature module to assert the correct behaviour of the entire cohort.
|
||||||
42
tests/conftest.py
Normal file
42
tests/conftest.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""
|
||||||
|
Session-scoped fixtures that spin up and tear down the test stack.
|
||||||
|
|
||||||
|
The test stack uses docker-compose.test.yml which:
|
||||||
|
- Runs on port 18000 (won't collide with the dev stack on 8000)
|
||||||
|
- Uses tmpfs for all storage (no data survives after `down`)
|
||||||
|
- Uses project name "langlearn-test" to stay isolated from dev containers
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
PROJECT_ROOT = pathlib.Path(__file__).parent.parent
|
||||||
|
COMPOSE_FILE = str(PROJECT_ROOT / "docker-compose.test.yml")
|
||||||
|
COMPOSE_PROJECT = "langlearn-test"
|
||||||
|
API_BASE_URL = "http://localhost:18000"
|
||||||
|
|
||||||
|
|
||||||
|
def _compose(*args: str) -> None:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "compose", "-p", COMPOSE_PROJECT, "-f", COMPOSE_FILE, *args],
|
||||||
|
cwd=PROJECT_ROOT,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def docker_stack():
|
||||||
|
"""Bring the test stack up before the session; tear it down (including volumes) after."""
|
||||||
|
_compose("up", "--build", "--wait", "-d")
|
||||||
|
yield
|
||||||
|
_compose("down", "-v")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client() -> httpx.Client:
|
||||||
|
"""A plain httpx client pointed at the test API. Not authenticated."""
|
||||||
|
with httpx.Client(base_url=API_BASE_URL) as c:
|
||||||
|
yield c
|
||||||
11
tests/pyproject.toml
Normal file
11
tests/pyproject.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[project]
|
||||||
|
name = "language-learning-api-tests"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"httpx>=0.28.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["."]
|
||||||
59
tests/test_auth.py
Normal file
59
tests/test_auth.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_creates_account(client: httpx.Client):
|
||||||
|
response = client.post(
|
||||||
|
"/auth/register",
|
||||||
|
json={"email": "newuser@example.com", "password": "securepassword123"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
body = response.json()
|
||||||
|
assert body["email"] == "newuser@example.com"
|
||||||
|
assert "id" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_duplicate_email_returns_409(client: httpx.Client):
|
||||||
|
payload = {"email": "duplicate@example.com", "password": "securepassword123"}
|
||||||
|
client.post("/auth/register", json=payload)
|
||||||
|
|
||||||
|
response = client.post("/auth/register", json=payload)
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_returns_token(client: httpx.Client):
|
||||||
|
credentials = {"email": "loginuser@example.com", "password": "securepassword123"}
|
||||||
|
client.post("/auth/register", json=credentials)
|
||||||
|
|
||||||
|
response = client.post("/auth/login", json=credentials)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert "access_token" in body
|
||||||
|
assert body["token_type"] == "bearer"
|
||||||
|
assert len(body["access_token"]) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_wrong_password_returns_401(client: httpx.Client):
|
||||||
|
client.post(
|
||||||
|
"/auth/register",
|
||||||
|
json={"email": "wrongpass@example.com", "password": "correctpassword"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/auth/login",
|
||||||
|
json={"email": "wrongpass@example.com", "password": "wrongpassword"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_unknown_email_returns_401(client: httpx.Client):
|
||||||
|
response = client.post(
|
||||||
|
"/auth/login",
|
||||||
|
json={"email": "nobody@example.com", "password": "doesntmatter"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
Loading…
Reference in a new issue