SPB Git

spb/air Public MIT

AIR — The Language of Accounting.

Python 100%
6.1 KB · 153 lines python
Raw Blame History
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