Spaces:
Running
Running
File size: 7,370 Bytes
6741fc6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 | """Test fixtures.
Integration tests run against a **real PostgreSQL database**, not SQLite and not
a mock. Every guarantee this project makes — `UNIQUE(file_hash)`,
`INSERT … ON CONFLICT`, the conditional status transition, the append-only
trigger — is a Postgres behaviour. Testing them anywhere else would prove
nothing about production.
Set `TEST_DATABASE_URL` to point at a throwaway database; the suite skips its
integration tests if none is reachable, so unit tests still run anywhere.
"""
from __future__ import annotations
import os
from collections.abc import AsyncIterator, Iterator
from pathlib import Path
import pytest
# Configure the environment before any app module reads settings.
os.environ.setdefault(
"TEST_DATABASE_URL",
"postgresql+asyncpg://ledgerlens:ledgerlens@localhost:5433/ledgerlens_test",
)
os.environ["DATABASE_URL"] = os.environ["TEST_DATABASE_URL"]
os.environ["LLM_MODE"] = "stub" # never touch the network from a test
os.environ["ENVIRONMENT"] = "test"
os.environ["LOG_LEVEL"] = "ERROR"
os.environ["ALLOWED_ORIGINS"] = "http://localhost:3000"
# Treat X-Forwarded-For as authoritative in tests so each case can present a
# distinct client IP and get its own rate-limit bucket.
os.environ["TRUSTED_PROXY_COUNT"] = "1"
# Nothing in the suite may reach the network. `LLM_MODE=stub` covers Claude, but
# Langfuse is configured independently: leave its keys in place and the tracer
# instantiates a real client and exports every span to cloud.langfuse.com. That
# turned a 4-second suite into a hang the moment a developer put working keys in
# their .env — tests that depend on someone's local secrets are not tests.
# Set to empty rather than popped: pydantic-settings also reads the `.env` file,
# so unsetting the environment variable is not enough — a developer with working
# keys on disk would still get a live client. An empty value is normalised to
# None by Settings, which is the same thing the platform does for a blank field.
os.environ["ANTHROPIC_API_KEY"] = ""
os.environ["LANGFUSE_PUBLIC_KEY"] = ""
os.environ["LANGFUSE_SECRET_KEY"] = ""
from sqlalchemy import text
from sqlalchemy.engine.url import make_url
from sqlalchemy.ext.asyncio import AsyncEngine
from app.core.bootstrap import init_schema
from app.core.claude import OfflineClaudeClient
from app.core.db import _connect_args, dispose_engine, init_engine, transaction
from app.core.settings import Settings, get_settings
from app.core.tracing import LocalTracer
from app.devtools.corpus import build_seed_corpus
from app.devtools.documents import InvoiceSpec, render_invoice_pdf
from app.pipeline.offline import build_offline_handlers
from app.pipeline.orchestrator import PipelineOrchestrator
_TABLES = ("anomalies", "extractions", "llm_traces", "audit_log", "failed_jobs", "documents")
# PostgreSQL: 3D000 invalid_catalog_name — the server answered, the database does not exist.
_UNDEFINED_DATABASE = "3D000"
def _is_missing_database(exc: BaseException) -> bool:
"""True when the server answered but the database does not exist.
Walks the cause chain: asyncpg raises `InvalidCatalogNameError` at connect
time, before SQLAlchemy wraps it, so the driver exception may be either the
outermost error or a cause of one.
"""
seen: set[int] = set()
current: BaseException | None = exc
while current is not None and id(current) not in seen:
seen.add(id(current))
if getattr(current, "sqlstate", None) == _UNDEFINED_DATABASE:
return True
if _UNDEFINED_DATABASE in str(current) or "does not exist" in str(current):
return True
current = current.__cause__ or current.__context__
return False
@pytest.fixture(scope="session")
def settings() -> Settings:
return get_settings()
@pytest.fixture(scope="session")
def anyio_backend() -> str:
return "asyncio"
async def _create_test_database(settings: Settings) -> None:
"""Create the test database, connecting through the server's maintenance DB.
Without this, a fresh clone that runs `make up && make test` finds no
`ledgerlens_test`, every integration fixture errors, and the suite *skips* —
printing green while having exercised nothing. A missing test database is a
setup step, not a reason to stop testing.
"""
from sqlalchemy.ext.asyncio import create_async_engine
url = make_url(settings.sqlalchemy_url)
target = url.database
admin_url = url.set(database="postgres")
admin = create_async_engine(
admin_url,
isolation_level="AUTOCOMMIT",
connect_args=_connect_args(settings),
)
try:
async with admin.connect() as connection:
exists = await connection.scalar(
text("SELECT 1 FROM pg_database WHERE datname = :name"), {"name": target}
)
if not exists:
# Identifier, so it cannot be bound as a parameter; the value comes
# from our own configuration, never from a request.
await connection.execute(text(f'CREATE DATABASE "{target}"'))
finally:
await admin.dispose()
@pytest.fixture(scope="session")
async def engine(settings: Settings) -> AsyncIterator[AsyncEngine]:
"""A live engine against the test database.
Skips only when the PostgreSQL *server* cannot be reached — the one case a
developer without Docker legitimately hits. Anything else fails loudly.
"""
try:
instance = init_engine(settings)
await init_schema(instance)
except Exception as exc: # narrowed immediately: recoverable, or skip
if not _is_missing_database(exc):
pytest.skip(f"PostgreSQL is not reachable for integration tests: {exc}")
await dispose_engine()
await _create_test_database(settings)
instance = init_engine(settings)
await init_schema(instance)
yield instance
await dispose_engine()
@pytest.fixture
async def clean_db(engine: AsyncEngine) -> AsyncIterator[None]:
"""Empty every table before each test.
TRUNCATE rather than DELETE: the audit log has a trigger that rejects DELETE,
which is exactly the guarantee `test_audit_log_is_append_only` asserts.
"""
async with transaction() as session:
await session.execute(text(f"TRUNCATE {', '.join(_TABLES)} RESTART IDENTITY CASCADE"))
yield
@pytest.fixture
def offline_client() -> OfflineClaudeClient:
client = OfflineClaudeClient()
for name, handler in build_offline_handlers().items():
client.register(name, handler)
return client
@pytest.fixture
def orchestrator(settings: Settings, offline_client: OfflineClaudeClient) -> PipelineOrchestrator:
return PipelineOrchestrator(client=offline_client, settings=settings, tracer=LocalTracer())
@pytest.fixture(scope="session")
def invoice_specs() -> list[InvoiceSpec]:
return [item.spec for item in build_seed_corpus()]
@pytest.fixture(scope="session")
def sample_pdf(invoice_specs: list[InvoiceSpec]) -> bytes:
"""A real, clean invoice PDF with a real text layer."""
return render_invoice_pdf(invoice_specs[0])
@pytest.fixture(scope="session")
def sample_spec(invoice_specs: list[InvoiceSpec]) -> InvoiceSpec:
return invoice_specs[0]
@pytest.fixture
def tmp_documents(tmp_path: Path) -> Iterator[Path]:
yield tmp_path
|