From 2cae5d9445d350dfeb8617b91a4cb60da551f59b Mon Sep 17 00:00:00 2001 From: wilson Date: Tue, 7 Apr 2026 07:55:57 +0100 Subject: [PATCH] feat: add the 'tests' module to the project --- .forgejo/workflows/test.yaml | 24 +++++++++++++++ docker-compose.test.yml | 59 ++++++++++++++++++++++++++++++++++++ tests/README.md | 5 +++ tests/conftest.py | 42 +++++++++++++++++++++++++ tests/pyproject.toml | 11 +++++++ tests/test_auth.py | 59 ++++++++++++++++++++++++++++++++++++ 6 files changed, 200 insertions(+) create mode 100644 .forgejo/workflows/test.yaml create mode 100644 docker-compose.test.yml create mode 100644 tests/README.md create mode 100644 tests/conftest.py create mode 100644 tests/pyproject.toml create mode 100644 tests/test_auth.py diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml new file mode 100644 index 0000000..4e85d7e --- /dev/null +++ b/.forgejo/workflows/test.yaml @@ -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 diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..28c394b --- /dev/null +++ b/docker-compose.test.yml @@ -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 diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..652dedc --- /dev/null +++ b/tests/README.md @@ -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. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2bf28ea --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/pyproject.toml b/tests/pyproject.toml new file mode 100644 index 0000000..de7921f --- /dev/null +++ b/tests/pyproject.toml @@ -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 = ["."] diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..aa07fe0 --- /dev/null +++ b/tests/test_auth.py @@ -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