Flaxon 0.3.0 and Teloce-Py 0.3.0 are available on PyPI. Release instructions are maintained on this site; source links identify the framework snapshot. Check version and upgrade guidance.

Framework source · Markdown

07 | Backend tests

Goal

Run realistic HTTP requests against disposable ORM databases before writing the UI.

What you are building

conftest.py creates a test app and owns its event loop and ORM lifecycle. generate_schemas is used ONLY to prepare disposable test databases; production and development use migrations. TestClient exercises the ASGI app with HTTPX. Account carries its cookie and rotated CSRF token. Tests cover valid workflows, rejected input, expired sessions and cross-user access. Read an ownership test aloud: arrange two accounts, create a project, attempt another user's request and assert it is denied.

Build this chapter

Stop the server. Work in project_manager. Follow each CREATE, whole-file replacement, or marked-block edit below in order.

Step 1: CREATE - make parent folders, then create this file - tests/conftest.py

Own the test database lifecycle and event loop. Never use development files in this fixture.

Create the parent directories, then save this complete file.

import asyncio
import pytest
from app import create_app
import httpx


class TestClient:
    def __init__(self, app):
        self.app = app
        self.loop = getattr(app, "test_loop", None)

    def __getattr__(self, method):
        def request(path, json_data=None, **kwargs):
            async def send():
                async with httpx.AsyncClient(
                    transport=httpx.ASGITransport(app=self.app),
                    base_url="http://testserver",
                ) as client:
                    return await client.request(
                        method.upper(), path, json=json_data, **kwargs
                    )

            return (
                self.loop.run_until_complete(send())
                if self.loop
                else asyncio.run(send())
            )

        return request


from settings import ROOT


@pytest.fixture
def application(tmp_path):
    path = tmp_path / "app.sqlite3"
    app = create_app(path, tmp_path / "admin.sqlite3", debug=True)
    loop = asyncio.new_event_loop()
    app.test_loop = loop
    loop.run_until_complete(app.db.initialize())
    with app.db.bind():
        loop.run_until_complete(app.db.context.generate_schemas())
    yield app
    loop.run_until_complete(app.db.close())
    loop.close()


@pytest.fixture
def client(application):
    return TestClient(application)


class Account:
    def __init__(self, client, email="learner@example.test"):
        self.client = client
        response = client.get("/api/auth/session")
        self.accept_session(response)
        response = self.call(
            "/api/auth/register",
            "post",
            {"name": "Learner", "email": email, "password": "LearningPython123!"},
        )
        assert response.status_code == 201, response.content
        self.accept_session(response)

    def accept_session(self, response):
        self.cookie_header = next(
            value
            for value in response.headers.get_list("set-cookie")
            if value.startswith("project_session=")
        )
        self.cookie = self.cookie_header.split(";")[0]
        self.csrf = response.json()["data"]["csrf"]

    @property
    def headers(self):
        return {
            "cookie": self.cookie,
            "x-csrf-token": self.csrf,
            "content-type": "application/json",
        }

    def call(self, path, method="get", data=None, headers=None):
        options = {"headers": self.headers if headers is None else headers}
        if data is not None:
            options["json_data"] = data
        return getattr(self.client, method)(path, **options)


@pytest.fixture
def account(client):
    return Account(client)


def orm(application, query):
    """Run a direct ORM assertion in the same database context as this test app."""
    with application.db.bind():
        return application.test_loop.run_until_complete(query())

Say: "Own the test database lifecycle and event loop. Never use development files in this fixture."

Type: Follow the action above in tests/conftest.py; explain each new function or binding as you add it.

Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.

Step 2: CREATE - make parent folders, then create this file - tests/test_api.py

This file owns the feature named by its module or component. Read the route/form flow before continuing.

Create the parent directories, then save this complete file.

import asyncio
import time
import pytest
from conftest import Account, orm
from models import Task, Session


def project(account):
    response = account.call(
        "/api/projects/",
        "post",
        {"name": "Course project", "description": "Build the app"},
    )
    assert response.status_code == 201
    return response.json()["data"]


def test_registration_rotates_session_and_never_returns_password_hash(client, account):
    response = account.call("/api/auth/session")
    assert response.status_code == 200
    assert response.json()["data"]["user"]["email"] == "learner@example.test"
    assert "password" not in str(response.json())
    assert "httponly" in account.cookie_header.lower()
    assert "samesite=lax" in account.cookie_header.lower()


def test_login_logout_and_old_session_revocation(account):
    old_headers = account.headers
    response = account.call("/api/auth/logout", "post", {})
    assert response.status_code == 200
    account.accept_session(response)
    assert account.call("/api/projects/", headers=old_headers).status_code == 401
    response = account.call(
        "/api/auth/login",
        "post",
        {"email": "learner@example.test", "password": "LearningPython123!"},
    )
    assert response.status_code == 200
    account.accept_session(response)
    assert account.call("/api/projects/").status_code == 200


@pytest.mark.parametrize(
    "payload",
    [
        [],
        {"name": "", "email": "bad", "password": "short"},
        {"name": "A", "email": "a@test.test", "password": "short"},
    ],
)
def test_invalid_registration(account, payload):
    assert account.call("/api/auth/register", "post", payload).status_code == 400


def test_wrong_password_and_duplicate_registration(account):
    assert (
        account.call(
            "/api/auth/login",
            "post",
            {"email": "learner@example.test", "password": "IncorrectPassword123!"},
        ).status_code
        == 401
    )
    assert (
        account.call(
            "/api/auth/register",
            "post",
            {
                "name": "Other",
                "email": "LEARNER@example.test",
                "password": "LearningPython123!",
            },
        ).status_code
        == 409
    )


def test_authentication_and_csrf_boundaries(client, account):
    assert client.get("/api/projects/").status_code == 401
    assert (
        account.call(
            "/api/projects/",
            "post",
            {"name": "Blocked"},
            headers={"cookie": account.cookie},
        ).status_code
        == 403
    )
    other = Account(client, "other@example.test")
    headers = {**account.headers, "x-csrf-token": other.csrf}
    assert (
        account.call(
            "/api/projects/", "post", {"name": "Blocked"}, headers=headers
        ).status_code
        == 403
    )
    headers = {**account.headers, "origin": "https://evil.example"}
    assert (
        account.call(
            "/api/projects/", "post", {"name": "Blocked"}, headers=headers
        ).status_code
        == 403
    )


def test_project_crud_and_ownership(client, account):
    item = project(account)
    other = Account(client, "other@example.test")
    path = f"/api/projects/{item['id']}"
    for method in ["get", "put", "delete"]:
        assert (
            other.call(
                path, method, {"name": "Stolen"} if method != "get" else None
            ).status_code
            == 404
        )
    assert other.call("/api/projects/").json()["data"] == []
    assert (
        account.call(path, "put", {"name": "Renamed", "description": "Updated"}).json()[
            "data"
        ]["name"]
        == "Renamed"
    )
    assert account.call(path, "delete", {}).status_code == 200
    assert account.call(path).status_code == 404


def test_task_workflow_filter_and_cascade(account, application):
    item = project(account)
    path = f"/api/tasks/project/{item['id']}"
    response = account.call(
        path, "post", {"title": "Record lesson", "due_date": "2026-12-01"}
    )
    assert response.status_code == 201
    task_id = response.json()["data"]["id"]
    assert (
        account.call(f"/api/tasks/{task_id}", "patch", {"status": "done"}).status_code
        == 200
    )
    assert len(account.call(path + "?status=done").json()["data"]) == 1
    assert account.call(path + "?status=todo").json()["data"] == []
    assert account.call(path + "?status=unknown").status_code == 400
    account.call(f"/api/projects/{item['id']}", "delete", {})
    assert orm(application, lambda: Task.filter(id=task_id).first()) is None


@pytest.mark.parametrize(
    "data",
    [
        {"title": ""},
        {"title": "Task", "status": "invalid"},
        {"title": "Task", "due_date": "2026-02-30"},
        {"title": 123},
    ],
)
def test_task_validation(account, data):
    item = project(account)
    assert (
        account.call(f"/api/tasks/project/{item['id']}", "post", data).status_code
        == 400
    )


def test_task_ownership_and_deletion(client, account):
    item = project(account)
    path = f"/api/tasks/project/{item['id']}"
    task = account.call(path, "post", {"title": "Private task"}).json()["data"]
    other = Account(client, "other@example.test")
    assert other.call(path).status_code == 404
    assert other.call(path, "post", {"title": "Wrong owner"}).status_code == 404
    assert (
        other.call(f"/api/tasks/{task['id']}", "patch", {"status": "done"}).status_code
        == 404
    )
    assert other.call(f"/api/tasks/{task['id']}", "delete", {}).status_code == 404
    assert account.call(f"/api/tasks/{task['id']}", "delete", {}).status_code == 200


def test_expired_session_is_rejected(account, application):
    orm(application, lambda: Session.all().update(expires_at=int(time.time()) - 1))
    assert account.call("/api/projects/").status_code == 401


def test_sql_values_are_not_executed(account):
    response = account.call(
        "/api/projects/", "post", {"name": "Robert'); DROP TABLE users;--"}
    )
    assert response.status_code == 201
    assert account.call("/api/auth/session").json()["data"]["user"] is not None


def test_auth_rate_limit(account):
    codes = [
        account.call(
            "/api/auth/login",
            "post",
            {"email": "learner@example.test", "password": "WrongPassword123!"},
        ).status_code
        for _ in range(11)
    ]
    assert 429 in codes


def test_unknown_api_is_404(client):
    assert client.get("/api/not-a-route").status_code == 404


def test_request_body_limit(client):
    response = client.post("/api/auth/register", json_data={"name": "x" * (300 * 1024)})
    assert response.status_code == 413


def test_production_requires_secret(monkeypatch, tmp_path):
    from app import create_app

    monkeypatch.delenv("FLAXON_SECRET_KEY", raising=False)
    with pytest.raises(ValueError, match="FLAXON_SECRET_KEY"):
        create_app(tmp_path / "app.sqlite3", tmp_path / "admin.sqlite3", debug=False)

Say: "This file owns the feature named by its module or component. Read the route/form flow before continuing."

Type: Follow the action above in tests/test_api.py; explain each new function or binding as you add it.

Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.

Run and check

python -m pytest -q tests/test_api.py

Expected result

tests/test_api.py passes. Each test uses temporary files and does not modify your learner database.

Common errors

Do not run later Admin tests yet. Keep the ORM context and event loop alive for the whole fixture. Sharing one account's cookie with another makes an ownership test invalid.

Short exercise

Add a regression test that sends an extra owner_id in project JSON and proves it cannot choose a different owner.

What to say and show

Say: "Run realistic HTTP requests against disposable ORM databases before writing the UI. The server remains responsible for persistence and authorization; the browser presents the result."

Show the expected result above, then point to the file responsible for it. Run the chapter check before the next lesson.

Save your checkpoint

git add .
git commit -m "Complete chapter 07: Backend tests"