Add core API integration tests

End-to-end coverage of the tenancy/people flow and the privacy seam (private-tree isolation, public-tree view-but-not-edit, duplicate-email conflict, auth-required). DB-backed tests run against TEST_DATABASE_URL and skip cleanly when it is unset, so the no-DB suite still runs anywhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
2026-06-06 10:40:19 -04:00
parent dffd05d303
commit 64388b75bf
2 changed files with 138 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
"""Test fixtures.
DB-backed tests require ``TEST_DATABASE_URL`` (an async URL to a *disposable*
Postgres); without it they skip, so the no-DB unit suite still runs anywhere.
The schema is built from the models via ``create_all`` and dropped per test for
isolation.
"""
import os
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
import app.models # noqa: F401 — register all models on Base.metadata
from app.core.db import get_session
from app.main import app
from app.models import Base
TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL")
@pytest_asyncio.fixture
async def client():
if not TEST_DATABASE_URL:
import pytest
pytest.skip("TEST_DATABASE_URL not set")
engine = create_async_engine(TEST_DATABASE_URL)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
sessionmaker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async def _override_session():
async with sessionmaker() as session:
yield session
app.dependency_overrides[get_session] = _override_session
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
app.dependency_overrides.clear()
await engine.dispose()
+91
View File
@@ -0,0 +1,91 @@
"""End-to-end coverage of the core data model through the API: tenancy, the
privacy seam, and the pre-auth actor shim."""
async def _create_user(client, email: str) -> str:
resp = await client.post(
"/api/v1/users", json={"email": email, "display_name": email}
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
async def test_tree_and_person_flow(client):
user_id = await _create_user(client, "keeper@example.com")
headers = {"X-User-Id": user_id}
resp = await client.post(
"/api/v1/trees", json={"name": "Smith Family", "visibility": "private"}, headers=headers
)
assert resp.status_code == 201, resp.text
tree = resp.json()
assert tree["visibility"] == "private"
assert tree["owner_id"] == user_id
tree_id = tree["id"]
resp = await client.get("/api/v1/trees", headers=headers)
assert resp.status_code == 200
assert len(resp.json()) == 1
resp = await client.post(
f"/api/v1/trees/{tree_id}/persons",
json={"given": "John", "surname": "Smith"},
headers=headers,
)
assert resp.status_code == 201, resp.text
person = resp.json()
assert person["primary_name"] == "John Smith"
assert person["tree_id"] == tree_id
resp = await client.get(f"/api/v1/trees/{tree_id}/persons", headers=headers)
assert resp.status_code == 200
assert len(resp.json()) == 1
async def test_private_tree_isolated_from_other_users(client):
owner = await _create_user(client, "owner@example.com")
other = await _create_user(client, "stranger@example.com")
resp = await client.post(
"/api/v1/trees", json={"name": "Private", "visibility": "private"},
headers={"X-User-Id": owner},
)
tree_id = resp.json()["id"]
# A non-member cannot view a private tree, nor list its people.
resp = await client.get(f"/api/v1/trees/{tree_id}", headers={"X-User-Id": other})
assert resp.status_code == 403
resp = await client.get(f"/api/v1/trees/{tree_id}/persons", headers={"X-User-Id": other})
assert resp.status_code == 403
async def test_public_tree_viewable_but_not_editable_by_non_member(client):
owner = await _create_user(client, "owner2@example.com")
viewer = await _create_user(client, "viewer2@example.com")
resp = await client.post(
"/api/v1/trees", json={"name": "Public", "visibility": "public"},
headers={"X-User-Id": owner},
)
tree_id = resp.json()["id"]
# Visible to a non-member...
resp = await client.get(f"/api/v1/trees/{tree_id}", headers={"X-User-Id": viewer})
assert resp.status_code == 200
# ...but not writable (not an editor).
resp = await client.post(
f"/api/v1/trees/{tree_id}/persons", json={"given": "Nope"},
headers={"X-User-Id": viewer},
)
assert resp.status_code == 403
async def test_duplicate_email_conflicts(client):
await _create_user(client, "dupe@example.com")
resp = await client.post("/api/v1/users", json={"email": "dupe@example.com"})
assert resp.status_code == 409
async def test_auth_required_without_header(client):
resp = await client.get("/api/v1/trees")
assert resp.status_code == 401