spb/air Public MIT
AIR — The Language of Accounting.
Python 100%
1# =============================================================================2# Projet : AIR — Accounting Intermediate Representation3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : test_ingestion.py6# Description : Tests — extraction, confidence routing, human approval queue (all offline).7# =============================================================================8"""Ingestion tests. NO network, NO API key: everything runs on MockExtractor.9Core guarantees under test:10- the LLM layer only ever produces AIR (validated by schema), never entries;11- schema-invalid extractions ALWAYS route to a human;12- low confidence routes to a human; approval stamps approver + timestamp;13- approved documents still go through the deterministic compiler.14"""15from __future__ import annotations1617from datetime import datetime18from decimal import Decimal19from pathlib import Path2021import pytest2223from ingestion.approval import ApprovalQueue24from ingestion.extractor import ExtractionError, MockExtractor25from ingestion.pipeline import Route, ingest26from sdk.cli import main2728ROOT = Path(__file__).resolve().parents[1]29POLICY = str(ROOT / "alsl" / "policies" / "ca-qc-2026.yaml")30HIGH = ROOT / "tests" / "fixtures" / "invoice_high_confidence.txt"31LOW = ROOT / "tests" / "fixtures" / "invoice_low_confidence.txt"323334# --- extractor ---------------------------------------------------------------------35def test_mock_extractor_parses_fixture_format() -> None:36 result = MockExtractor().extract(HIGH.read_text())37 assert result.confidence == Decimal("0.97")38 (event,) = result.events39 assert event["type"] == "Sale"40 assert event["amount"] == {"amount": "999.99", "currency": "CAD"}41 assert event["tax"] == {"jurisdiction": "CA-QC"}424344def test_mock_extractor_empty_source_is_an_error() -> None:45 with pytest.raises(ExtractionError):46 MockExtractor().extract("nothing here")474849# --- routing -----------------------------------------------------------------------50def test_high_confidence_routes_to_auto_approved() -> None:51 outcome = ingest(HIGH.read_text(), MockExtractor(),52 ingested_at=datetime(2026, 8, 5, 9, 0))53 assert outcome.route is Route.AUTO_APPROVED54 assert outcome.document is not None55 event = outcome.document.events[0]56 # traceability stamped into meta57 assert event.meta is not None and event.meta.llm is not None58 assert event.meta.llm.model == "mock"59 assert str(event.meta.llm.confidence) == "0.97"606162def test_low_confidence_routes_to_review() -> None:63 outcome = ingest(LOW.read_text(), MockExtractor())64 assert outcome.route is Route.NEEDS_REVIEW65 assert outcome.document is not None # schema-valid, just uncertain66 assert any("below threshold" in r for r in outcome.reasons)676869def test_schema_invalid_extraction_always_goes_to_review() -> None:70 bad = "type: Sale\nid: evt_bad\ndate: not-a-date\namount: 10.00 CAD\nconfidence: 0.99\n"71 outcome = ingest(bad, MockExtractor())72 assert outcome.route is Route.NEEDS_REVIEW # despite 0.99 confidence73 assert outcome.document is None74 assert outcome.validation_errors757677def test_threshold_is_configurable() -> None:78 outcome = ingest(LOW.read_text(), MockExtractor(),79 threshold=Decimal("0.50"))80 assert outcome.route is Route.AUTO_APPROVED818283# --- approval queue -------------------------------------------------------------------84def test_approval_flow_stamps_approver(tmp_path: Path) -> None:85 queue = ApprovalQueue(tmp_path)86 outcome = ingest(LOW.read_text(), MockExtractor())87 item_id = queue.submit(outcome, LOW.read_text())8889 assert [i.id for i in queue.pending()] == [item_id]9091 document = queue.approve(item_id, approver="simon-pierre",92 approved_at=datetime(2026, 8, 5, 10, 30))93 assert queue.pending() == []94 event = document.events[0]95 assert event.meta is not None96 assert event.meta.approver == "simon-pierre"97 assert event.meta.timestamps is not None98 assert event.meta.timestamps.approved is not None99100101def test_reject_moves_item_out_of_pending(tmp_path: Path) -> None:102 queue = ApprovalQueue(tmp_path)103 item_id = queue.submit(ingest(LOW.read_text(), MockExtractor()),104 LOW.read_text())105 queue.reject(item_id, reason="duplicate of INV-000")106 assert queue.pending() == []107 with pytest.raises(KeyError):108 queue.approve(item_id, approver="x")109110111# --- end-to-end CLI --------------------------------------------------------------------112def test_cli_ingest_auto_approve_posts_to_ledger(tmp_path: Path, capsys) -> None:113 home = str(tmp_path / "books")114 main(["init", "--home", home, "--policies", POLICY])115 assert main(["ingest", str(HIGH), "--home", home]) == 0116 out = capsys.readouterr().out117 assert "auto-approved" in out and "+1 appended" in out118119120def test_cli_ingest_review_then_approve(tmp_path: Path, capsys) -> None:121 home = str(tmp_path / "books")122 main(["init", "--home", home, "--policies", POLICY])123 capsys.readouterr()124125 assert main(["ingest", str(LOW), "--home", home]) == 0126 out = capsys.readouterr().out127 assert "routed to human review" in out128 item_id = next(w for w in out.split() if w.startswith("inbox_"))129130 assert main(["inbox", "--home", home]) == 0131 assert item_id in capsys.readouterr().out132133 assert main(["approve", item_id, "--home", home,134 "--approver", "simon-pierre"]) == 0135 out = capsys.readouterr().out136 assert "approved by simon-pierre" in out and "+1 appended" in out137138 # the purchase (412.50 + GST 20.63 + QST 41.15) is now in the books139 assert main(["report", "trial-balance", "--home", home]) == 0140 tb = capsys.readouterr().out141 assert "412.50" in tb142143144def test_cli_reject(tmp_path: Path, capsys) -> None:145 home = str(tmp_path / "books")146 main(["init", "--home", home, "--policies", POLICY])147 main(["ingest", str(LOW), "--home", home])148 out = capsys.readouterr().out149 item_id = next(w for w in out.split() if w.startswith("inbox_"))150 assert main(["reject", item_id, "--home", home, "--reason", "not ours"]) == 0151 assert main(["inbox", "--home", home]) == 0152 assert "inbox empty" in capsys.readouterr().out153