diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..9abe1b8 --- /dev/null +++ b/backend/tests/conftest.py @@ -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() diff --git a/backend/tests/test_core_api.py b/backend/tests/test_core_api.py new file mode 100644 index 0000000..5da62cb --- /dev/null +++ b/backend/tests/test_core_api.py @@ -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