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).
108 lines
3.6 KiB
Python
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)
|