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