Oracle / tests /test_ingest.py
spacedout-bits's picture
Add torchvision (Qwen2.5-VL needs it); stop leaking internals to users
f8d827b verified
Raw History Blame Contribute Delete
10.1 kB
from datetime import date
import pytest
from finbot.extract import ExtractedDocument
from finbot.ingest import Ingestor, _coerce_amount, _draft_from_obj, _parse_any_date
from finbot.llm import LLMUnavailable
TODAY = date(2026, 8, 15)
class StubLLM:
"""Stands in for LLMClient without touching the network."""
def __init__(self, payload=None, enabled=True, raises=None):
self.payload = payload
self.enabled = enabled
self.raises = raises
self.calls = []
def chat_json(self, system, user, **kwargs):
self.calls.append(("chat_json", system, user))
if self.raises:
raise self.raises
return self.payload
def vision_json(self, system, user, image_bytes, mime_type, **kwargs):
self.calls.append(("vision_json", system, user))
if self.raises:
raise self.raises
return self.payload
class TestHelpers:
@pytest.mark.parametrize(
"raw,expected",
[(10, 10.0), (-10, 10.0), ("1,234.56", 1234.56), ("₹450", 450.0), ("", None), (None, None)],
)
def test_coerce_amount(self, raw, expected):
assert _coerce_amount(raw) == expected
@pytest.mark.parametrize(
"raw,expected",
[
("2026-08-01", date(2026, 8, 1)),
("01/08/2026", date(2026, 8, 1)), # day-first
("", TODAY),
("garbage", TODAY),
(None, TODAY),
],
)
def test_parse_any_date(self, raw, expected):
assert _parse_any_date(raw, TODAY) == expected
def test_draft_from_obj(self):
draft = _draft_from_obj(
{
"amount": 450,
"currency": "inr",
"description": "Swiggy order",
"date": "2026-08-01",
"category": "food_dining",
},
"USD",
TODAY,
)
assert draft.amount_minor == 45000
assert draft.currency == "INR"
assert draft.category == "food_dining"
def test_draft_rejects_zero_and_missing_amounts(self):
assert _draft_from_obj({"amount": 0}, "INR", TODAY) is None
assert _draft_from_obj({}, "INR", TODAY) is None
assert _draft_from_obj("not a dict", "INR", TODAY) is None
def test_draft_falls_back_to_keyword_category(self):
# Model gave an unusable category, but the text is obviously transport.
draft = _draft_from_obj(
{"amount": 100, "description": "uber ride", "category": "???"}, "INR", TODAY
)
assert draft.category == "transport"
def test_draft_defaults_currency(self):
draft = _draft_from_obj({"amount": 10}, "SGD", TODAY)
assert draft.currency == "SGD"
class TestFromText:
def test_deterministic_path_does_not_call_the_model(self):
llm = StubLLM()
draft = Ingestor(llm, "INR").from_text("coffee 250", TODAY)
assert draft.amount_minor == 25000
assert llm.calls == [] # the whole point of the fast path
def test_falls_back_to_the_model_without_an_amount(self):
llm = StubLLM(payload={"amount": 12, "currency": "USD", "description": "a coffee"})
draft = Ingestor(llm, "INR").from_text("bought a coffee for twelve dollars", TODAY)
assert draft is not None
assert draft.amount_minor == 1200
assert len(llm.calls) == 1
def test_returns_none_when_the_model_is_disabled(self):
llm = StubLLM(enabled=False)
assert Ingestor(llm, "INR").from_text("no numbers here", TODAY) is None
def test_model_failure_degrades_quietly(self):
llm = StubLLM(raises=LLMUnavailable("down"))
assert Ingestor(llm, "INR").from_text("no numbers here", TODAY) is None
class TestFromReceipt:
def test_reads_a_total(self):
llm = StubLLM(
payload={
"merchant": "Blue Tokai",
"amount": 480,
"currency": "INR",
"date": "2026-08-14",
"category": "food_dining",
"confidence": 0.9,
}
)
draft, error = Ingestor(llm, "INR").from_receipt(b"jpeg", "image/jpeg", TODAY)
assert error == ""
assert draft.amount_minor == 48000
assert draft.merchant == "Blue Tokai"
def test_explains_when_disabled(self):
llm = StubLLM(enabled=False)
draft, error = Ingestor(llm, "INR").from_receipt(b"x", "image/jpeg", TODAY)
assert draft is None
assert "HF_TOKEN" in error
def test_explains_an_unreadable_image(self):
llm = StubLLM(payload={"amount": 0})
draft, error = Ingestor(llm, "INR").from_receipt(b"x", "image/jpeg", TODAY)
assert draft is None
assert "couldn't find a total" in error
class TestFromDocument:
def _doc(self):
return ExtractedDocument(kind="csv", rows=[["Date", "Desc", "Amt"], ["01/08/2026", "Swiggy", "450"]])
def test_extracts_debits(self):
llm = StubLLM(
payload={
"transactions": [
{
"date": "2026-08-01",
"description": "Swiggy",
"amount": 450,
"currency": "INR",
"direction": "debit",
"category": "food_dining",
}
]
}
)
pending, error = Ingestor(llm, "INR").from_document(self._doc(), user_id=1, today=TODAY)
assert error == ""
assert len(pending.drafts) == 1
assert pending.total_by_currency() == {"INR": 45000}
def test_credits_are_counted_not_stored(self):
# Storing income alongside spend would corrupt every total.
llm = StubLLM(
payload={
"transactions": [
{"date": "2026-08-01", "description": "Swiggy", "amount": 450,
"currency": "INR", "direction": "debit"},
{"date": "2026-08-03", "description": "Salary", "amount": 85000,
"currency": "INR", "direction": "credit"},
]
}
)
pending, error = Ingestor(llm, "INR").from_document(self._doc(), user_id=1, today=TODAY)
assert len(pending.drafts) == 1
assert "credits skipped" in pending.label
def test_counts_unreadable_rows(self):
llm = StubLLM(
payload={
"transactions": [
{"date": "2026-08-01", "description": "Swiggy", "amount": 450,
"currency": "INR", "direction": "debit"},
{"description": "junk"}, # no amount
]
}
)
pending, _ = Ingestor(llm, "INR").from_document(self._doc(), user_id=1, today=TODAY)
assert len(pending.drafts) == 1
assert pending.skipped == 1
def test_accepts_a_bare_array(self):
llm = StubLLM(
payload=[{"date": "2026-08-01", "description": "Swiggy", "amount": 450,
"currency": "INR", "direction": "debit"}]
)
pending, error = Ingestor(llm, "INR").from_document(self._doc(), user_id=1, today=TODAY)
assert error == ""
assert len(pending.drafts) == 1
def test_reports_when_nothing_spendable_is_found(self):
llm = StubLLM(payload={"transactions": []})
pending, error = Ingestor(llm, "INR").from_document(self._doc(), user_id=1, today=TODAY)
assert pending is None
assert "no spending transactions" in error
def test_unsupported_document_short_circuits(self):
llm = StubLLM()
doc = ExtractedDocument(kind="unsupported", note="nope")
pending, error = Ingestor(llm, "INR").from_document(doc, user_id=1, today=TODAY)
assert pending is None
assert error == "nope"
assert llm.calls == [] # never spend a call on a file we cannot read
def test_flags_truncation(self):
llm = StubLLM(
payload={"transactions": [{"date": "2026-08-01", "description": "x",
"amount": 1, "currency": "INR", "direction": "debit"}]}
)
doc = self._doc()
doc.truncated = True
pending, _ = Ingestor(llm, "INR").from_document(doc, user_id=1, today=TODAY)
assert "truncated" in pending.label
def test_disabled_model_explains_itself(self):
llm = StubLLM(enabled=False)
pending, error = Ingestor(llm, "INR").from_document(self._doc(), user_id=1, today=TODAY)
assert pending is None
assert "HF_TOKEN" in error
class TestFailuresDoNotLeakInternals:
"""An infra failure is not the user's fault and must not surface tracebacks.
Regression: a missing torchvision made every receipt reply
"I couldn't read that receipt (local vision generation failed: 'ImportError').
Try a clearer photo?" -- which both leaked internals and blamed the photo.
"""
def _doc(self):
return ExtractedDocument(kind="csv", rows=[["Date", "Amt"], ["01/08/2026", "450"]])
def test_receipt_infra_failure_message(self):
llm = StubLLM(raises=LLMUnavailable("local vision generation failed: 'ImportError'"))
draft, error = Ingestor(llm, "INR").from_receipt(b"jpeg", "image/jpeg", TODAY)
assert draft is None
assert "ImportError" not in error
assert "clearer photo" not in error
assert "type the amount" in error
def test_statement_infra_failure_message(self):
llm = StubLLM(raises=LLMUnavailable("CUDA out of memory"))
pending, error = Ingestor(llm, "INR").from_document(self._doc(), user_id=1, today=TODAY)
assert pending is None
assert "CUDA" not in error
def test_unreadable_image_still_blames_the_image_not_the_bot(self):
# Model ran fine and found no total: here it IS about the picture.
llm = StubLLM(payload={"amount": 0})
draft, error = Ingestor(llm, "INR").from_receipt(b"jpeg", "image/jpeg", TODAY)
assert draft is None
assert "couldn't find a total" in error