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