# ============================================================================= # Projet : AIR — Accounting Intermediate Representation # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : test_ingestion.py # Description : Tests — extraction, confidence routing, human approval queue (all offline). # ============================================================================= """Ingestion tests. NO network, NO API key: everything runs on MockExtractor. Core guarantees under test: - the LLM layer only ever produces AIR (validated by schema), never entries; - schema-invalid extractions ALWAYS route to a human; - low confidence routes to a human; approval stamps approver + timestamp; - approved documents still go through the deterministic compiler. """ from __future__ import annotations from datetime import datetime from decimal import Decimal from pathlib import Path import pytest from ingestion.approval import ApprovalQueue from ingestion.extractor import ExtractionError, MockExtractor from ingestion.pipeline import Route, ingest from sdk.cli import main ROOT = Path(__file__).resolve().parents[1] POLICY = str(ROOT / "alsl" / "policies" / "ca-qc-2026.yaml") HIGH = ROOT / "tests" / "fixtures" / "invoice_high_confidence.txt" LOW = ROOT / "tests" / "fixtures" / "invoice_low_confidence.txt" # --- extractor --------------------------------------------------------------------- def test_mock_extractor_parses_fixture_format() -> None: result = MockExtractor().extract(HIGH.read_text()) assert result.confidence == Decimal("0.97") (event,) = result.events assert event["type"] == "Sale" assert event["amount"] == {"amount": "999.99", "currency": "CAD"} assert event["tax"] == {"jurisdiction": "CA-QC"} def test_mock_extractor_empty_source_is_an_error() -> None: with pytest.raises(ExtractionError): MockExtractor().extract("nothing here") # --- routing ----------------------------------------------------------------------- def test_high_confidence_routes_to_auto_approved() -> None: outcome = ingest(HIGH.read_text(), MockExtractor(), ingested_at=datetime(2026, 8, 5, 9, 0)) assert outcome.route is Route.AUTO_APPROVED assert outcome.document is not None event = outcome.document.events[0] # traceability stamped into meta assert event.meta is not None and event.meta.llm is not None assert event.meta.llm.model == "mock" assert str(event.meta.llm.confidence) == "0.97" def test_low_confidence_routes_to_review() -> None: outcome = ingest(LOW.read_text(), MockExtractor()) assert outcome.route is Route.NEEDS_REVIEW assert outcome.document is not None # schema-valid, just uncertain assert any("below threshold" in r for r in outcome.reasons) def test_schema_invalid_extraction_always_goes_to_review() -> None: bad = "type: Sale\nid: evt_bad\ndate: not-a-date\namount: 10.00 CAD\nconfidence: 0.99\n" outcome = ingest(bad, MockExtractor()) assert outcome.route is Route.NEEDS_REVIEW # despite 0.99 confidence assert outcome.document is None assert outcome.validation_errors def test_threshold_is_configurable() -> None: outcome = ingest(LOW.read_text(), MockExtractor(), threshold=Decimal("0.50")) assert outcome.route is Route.AUTO_APPROVED # --- approval queue ------------------------------------------------------------------- def test_approval_flow_stamps_approver(tmp_path: Path) -> None: queue = ApprovalQueue(tmp_path) outcome = ingest(LOW.read_text(), MockExtractor()) item_id = queue.submit(outcome, LOW.read_text()) assert [i.id for i in queue.pending()] == [item_id] document = queue.approve(item_id, approver="simon-pierre", approved_at=datetime(2026, 8, 5, 10, 30)) assert queue.pending() == [] event = document.events[0] assert event.meta is not None assert event.meta.approver == "simon-pierre" assert event.meta.timestamps is not None assert event.meta.timestamps.approved is not None def test_reject_moves_item_out_of_pending(tmp_path: Path) -> None: queue = ApprovalQueue(tmp_path) item_id = queue.submit(ingest(LOW.read_text(), MockExtractor()), LOW.read_text()) queue.reject(item_id, reason="duplicate of INV-000") assert queue.pending() == [] with pytest.raises(KeyError): queue.approve(item_id, approver="x") # --- end-to-end CLI -------------------------------------------------------------------- def test_cli_ingest_auto_approve_posts_to_ledger(tmp_path: Path, capsys) -> None: home = str(tmp_path / "books") main(["init", "--home", home, "--policies", POLICY]) assert main(["ingest", str(HIGH), "--home", home]) == 0 out = capsys.readouterr().out assert "auto-approved" in out and "+1 appended" in out def test_cli_ingest_review_then_approve(tmp_path: Path, capsys) -> None: home = str(tmp_path / "books") main(["init", "--home", home, "--policies", POLICY]) capsys.readouterr() assert main(["ingest", str(LOW), "--home", home]) == 0 out = capsys.readouterr().out assert "routed to human review" in out item_id = next(w for w in out.split() if w.startswith("inbox_")) assert main(["inbox", "--home", home]) == 0 assert item_id in capsys.readouterr().out assert main(["approve", item_id, "--home", home, "--approver", "simon-pierre"]) == 0 out = capsys.readouterr().out assert "approved by simon-pierre" in out and "+1 appended" in out # the purchase (412.50 + GST 20.63 + QST 41.15) is now in the books assert main(["report", "trial-balance", "--home", home]) == 0 tb = capsys.readouterr().out assert "412.50" in tb def test_cli_reject(tmp_path: Path, capsys) -> None: home = str(tmp_path / "books") main(["init", "--home", home, "--policies", POLICY]) main(["ingest", str(LOW), "--home", home]) out = capsys.readouterr().out item_id = next(w for w in out.split() if w.startswith("inbox_")) assert main(["reject", item_id, "--home", home, "--reason", "not ours"]) == 0 assert main(["inbox", "--home", home]) == 0 assert "inbox empty" in capsys.readouterr().out