Spaces:
Running
Running
| """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 | |
| def settings() -> Settings: | |
| return get_settings() | |
| 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() | |
| 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() | |
| 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 | |
| def offline_client() -> OfflineClaudeClient: | |
| client = OfflineClaudeClient() | |
| for name, handler in build_offline_handlers().items(): | |
| client.register(name, handler) | |
| return client | |
| def orchestrator(settings: Settings, offline_client: OfflineClaudeClient) -> PipelineOrchestrator: | |
| return PipelineOrchestrator(client=offline_client, settings=settings, tracer=LocalTracer()) | |
| def invoice_specs() -> list[InvoiceSpec]: | |
| return [item.spec for item in build_seed_corpus()] | |
| 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]) | |
| def sample_spec(invoice_specs: list[InvoiceSpec]) -> InvoiceSpec: | |
| return invoice_specs[0] | |
| def tmp_documents(tmp_path: Path) -> Iterator[Path]: | |
| yield tmp_path | |