broker-sync/tests/providers/parsers/test_invest_engine.py
Viktor Barzin 87526898e6 Pin InvestEngine parser failure modes — empty-on-junk + partial-match
Context: The port's graceful-failure contract was implicit in the way
each strategy returns None/[] on malformed input, but without tests it
was an accidental property that could regress silently. Codify it.

Two invariants, each backed by a fixture:

1. Junk email → empty list, never raise.
   `unparseable.eml` is a pure-marketing IE newsletter with no order
   data. All three strategies try and fail; parse_invest_engine_email
   returns []. No exception leaks.

2. Partial HTML email → intact orders only.
   `html_partial_match.eml` has two nested summary tables: one with a
   valid VUAG order, one that is missing both the ticker and "Bought N
   @ £P" rows (simulates IE dropping content mid-render). The parser
   returns just the VUAG order.

No implementation change needed — the behaviour existed as a side
effect of _try_html_summary_table returning None on missing fields.
These tests lock it down so future refactors can't quietly break it.

Test plan:
  poetry run pytest tests/providers/parsers/ -q   →  8 passed in 0.19s
  poetry run mypy broker_sync/providers/parsers/invest_engine.py tests/providers/parsers/test_invest_engine.py   →  clean
  poetry run ruff check broker_sync/providers/parsers/invest_engine.py tests/providers/parsers/test_invest_engine.py   →  All checks passed!
  poetry run yapf --diff   →  clean (no diff)

Manual verification:
- Load unparseable.eml → parse returns [].
- Load html_partial_match.eml → parse returns exactly 1 activity (VUAG).
2026-04-17 22:02:48 +00:00

108 lines
3.6 KiB
Python

from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from broker_sync.models import AccountType, ActivityType
from broker_sync.providers.parsers.invest_engine import parse_invest_engine_email
_FIXTURES = Path(__file__).parent.parent.parent / "fixtures" / "invest_engine"
def _load(name: str) -> bytes:
return (_FIXTURES / name).read_bytes()
# -- RFC 2822 body (v2-style, single BUY) --
def test_rfc2822_single_buy_parses_to_one_activity() -> None:
activities = parse_invest_engine_email(_load("rfc2822_v2_single_buy.eml"))
assert len(activities) == 1
a = activities[0]
assert a.activity_type is ActivityType.BUY
assert a.symbol == "VUAG"
assert a.quantity == Decimal("59.539562")
assert a.unit_price == Decimal("60.46")
assert a.currency == "GBP"
assert a.date == datetime(2023, 1, 17)
assert a.account_id == "invest-engine-primary"
assert a.account_type is AccountType.ISA
def test_rfc2822_external_id_is_deterministic() -> None:
a1 = parse_invest_engine_email(_load("rfc2822_v2_single_buy.eml"))[0]
a2 = parse_invest_engine_email(_load("rfc2822_v2_single_buy.eml"))[0]
assert a1.external_id == a2.external_id
assert a1.external_id.startswith("invest-engine:")
def test_rfc2822_notes_record_parse_strategy() -> None:
a = parse_invest_engine_email(_load("rfc2822_v2_single_buy.eml"))[0]
assert a.notes is not None
assert "rfc2822" in a.notes
# -- HTML table body (multipart/alternative, two orders) --
def test_html_body_parses_both_orders() -> None:
activities = parse_invest_engine_email(_load("html_two_orders.eml"))
assert len(activities) == 2
a, b = activities
assert a.symbol == "VUAG"
assert a.quantity == Decimal("10.5")
assert a.unit_price == Decimal("62.10")
assert a.date == datetime(2026, 4, 1)
assert a.account_id == "invest-engine-primary"
assert a.account_type is AccountType.ISA
assert a.activity_type is ActivityType.BUY
assert b.symbol == "SWDA"
assert b.quantity == Decimal("2.25")
assert b.unit_price == Decimal("85.40")
assert b.date == datetime(2026, 4, 1)
def test_html_notes_record_html_strategy() -> None:
a = parse_invest_engine_email(_load("html_two_orders.eml"))[0]
assert a.notes is not None
assert "html" in a.notes
# -- CSV attachment body --
def test_csv_attachment_parses_all_rows() -> None:
activities = parse_invest_engine_email(_load("csv_attachment.eml"))
assert len(activities) == 3
by_symbol = {a.symbol: a for a in activities}
assert by_symbol["VUAG"].quantity == Decimal("12.5")
assert by_symbol["VUAG"].unit_price == Decimal("63.21")
assert by_symbol["VUAG"].date == datetime(2025, 4, 2)
assert by_symbol["SWDA"].quantity == Decimal("4.75")
assert by_symbol["VUSA"].date == datetime(2025, 4, 4)
for a in activities:
assert a.activity_type is ActivityType.BUY
assert a.currency == "GBP"
assert a.account_id == "invest-engine-primary"
assert a.account_type is AccountType.ISA
assert a.notes is not None
assert "csv" in a.notes
# -- graceful failure modes --
def test_unparseable_email_returns_empty_list() -> None:
assert parse_invest_engine_email(_load("unparseable.eml")) == []
def test_html_partial_match_returns_only_parseable_orders() -> None:
activities = parse_invest_engine_email(_load("html_partial_match.eml"))
assert len(activities) == 1
a = activities[0]
assert a.symbol == "VUAG"
assert a.quantity == Decimal("3.0")
assert a.unit_price == Decimal("61.25")
assert a.date == datetime(2026, 4, 15)