read.markets/tests/test_csv_import.py
Giorgio Gilestro f326b41a08 sync: encrypted cloud backup for portfolios + settings UX rework
Adds opt-in client-side-encrypted portfolio sync (paid). Browser
PBKDF2(PIN) → AES-GCM, server HKDF(pepper, user_id) outer wrap;
server stores opaque bytes only. Sliding-window rate limit on GET.

  - new portfolio_sync table (migration 0015)
  - POST/GET/DELETE /api/portfolio/sync + /status
  - app/services/portfolio_sync.py crypto + rate limit
  - app/routers/sync.py paid-gated
  - app/static/js/portfolio-sync.js WebCrypto wrapper
  - settings page: enable/disable + PIN modal
  - PORTFOLIO_SYNC_PEPPER setting (warn on startup if missing)

Settings + import rework:

  - /upload merged into /settings#import (legacy route 302s)
  - drop CSV → auto-parse → preview → Import only / Import & sync
  - nav slimmed to Dashboard / News / Log
  - Settings + Logout moved to a user dropdown
  - brand logo links to /

Collateral fixes:

  - settings 500: re-fetch User in current session before mutating
    referral_code (assign_code_if_missing was refreshing a User
    loaded in the auth dep's now-closed session)
  - csv_import: distinct error for unfunded T212 pies (all qty=0)
  - db.py: drop pool_pre_ping (aiomysql 0.3.2 incompat); pin
    isolation_level=READ COMMITTED to avoid gap-lock deadlocks
  - alembic env: disable_existing_loggers=False so in-process
    migrations don't silence uvicorn's loggers
  - docker-compose.override.yml: dev-only volume mount + --reload

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:15:54 +02:00

196 lines
6.4 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for app.services.csv_import.
Uses the real T212 pie-export sample at tests/fixtures/t212_pie_export.csv
for the happy path, then synthetic fixtures for edge cases.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from app.services.csv_import import (
CSVImportError,
ParsedPie,
ParsedPosition,
parse_t212_csv,
)
FIXTURE = Path(__file__).parent / "fixtures" / "t212_pie_export.csv"
# --- Happy path: real T212 export ------------------------------------------
def test_parses_real_t212_export():
pie = parse_t212_csv(FIXTURE.read_bytes())
assert isinstance(pie, ParsedPie)
assert pie.name == "Defensive Ex-US 2026"
assert pie.invested == pytest.approx(11616.75)
assert pie.value == pytest.approx(11619.33)
assert pie.result == pytest.approx(2.58)
assert len(pie.positions) == 13
def test_first_position_fields_resolved():
pie = parse_t212_csv(FIXTURE.read_bytes())
sgln = next(p for p in pie.positions if p.slice == "SGLN")
assert sgln.name == "iShares Physical Gold"
assert sgln.invested_value == pytest.approx(2325)
assert sgln.current_value == pytest.approx(2324.3)
assert sgln.result == pytest.approx(-0.7)
assert sgln.quantity == pytest.approx(35.12084592)
assert sgln.dividends_gained is None # 'N/A' in source
assert sgln.average_price == pytest.approx(2325 / 35.12084592)
assert sgln.current_price == pytest.approx(2324.3 / 35.12084592)
def test_total_row_excluded_from_positions():
pie = parse_t212_csv(FIXTURE.read_bytes())
assert not any(p.slice.lower() == "total" for p in pie.positions)
# --- Edge cases: defensive parsing -----------------------------------------
def test_empty_file_raises():
with pytest.raises(CSVImportError, match="Empty"):
parse_t212_csv("")
def test_missing_required_column_raises():
# Only Name, no Slice or quantity
csv = '"Name","Value"\n"iShares","100"\n'
with pytest.raises(CSVImportError, match="missing required column"):
parse_t212_csv(csv)
def test_no_position_rows_raises():
# Headers present but only the Total aggregate row.
csv = (
'"Slice","Name","Invested value","Value","Result","Owned quantity"\n'
'"Total","Empty Pie",0,0,0,"-"\n'
)
with pytest.raises(CSVImportError, match="no parseable position"):
parse_t212_csv(csv)
def test_unfunded_pie_raises_specific_message():
"""A pie exported before it holds any shares has every slice at
quantity 0. That must not look like a format error — the message
has to say the pie is empty so the user re-exports a funded one."""
csv = (
'"Slice","Name","Invested value","Value","Result","Owned quantity"\n'
'"SGLN","iShares Physical Gold",0,0,0,"0"\n'
'"SHEL","Shell",0,0,0,"0"\n'
'"Total","Empty Pie",0,0,0,"-"\n'
)
with pytest.raises(CSVImportError, match="all 2 slice"):
parse_t212_csv(csv)
def test_reordered_columns():
"""T212 sometimes re-orders columns between exports. Header-name matching
has to make that a non-issue."""
csv = (
'"Name","Owned quantity","Slice","Value","Invested value","Result"\n'
'"Shell","10.5","SHEL","350","340","10"\n'
'"Total","-","Total","350","340","10"\n'
)
pie = parse_t212_csv(csv)
assert len(pie.positions) == 1
p = pie.positions[0]
assert p.slice == "SHEL"
assert p.name == "Shell"
assert p.quantity == pytest.approx(10.5)
assert p.invested_value == pytest.approx(340)
def test_unknown_columns_silently_ignored():
csv = (
'"Slice","Name","Owned quantity","Invested value",'
'"Something T212 added later","Value"\n'
'"SHEL","Shell","10","100","ignore me","105"\n'
'"Total","-","-","100","-","105"\n'
)
pie = parse_t212_csv(csv)
assert pie.positions[0].slice == "SHEL"
assert pie.positions[0].current_value == pytest.approx(105)
def test_handles_utf8_bom():
"""Excel-saved CSVs often have a UTF-8 BOM. Should not break header
matching for the first column."""
csv = (
""
'"Slice","Name","Invested value","Value","Result","Owned quantity"\n'
'"SHEL","Shell",100,105,5,"10"\n'
'"Total","Test",100,105,5,"-"\n'
).encode("utf-8")
pie = parse_t212_csv(csv)
assert pie.positions[0].slice == "SHEL"
assert pie.name == "Test"
def test_dash_and_na_become_none():
csv = (
'"Slice","Name","Invested value","Value","Result","Owned quantity",'
'"Dividends gained","Dividends cash","Dividends reinvested"\n'
'"SHEL","Shell",100,"-",,"10","N/A","","n/a"\n'
'"Total","P",100,100,0,"-","0","0","0"\n'
)
pie = parse_t212_csv(csv)
p = pie.positions[0]
assert p.current_value is None
assert p.result is None
assert p.dividends_gained is None
assert p.dividends_cash is None
assert p.dividends_reinvested is None
def test_thousand_separator_commas_tolerated():
csv = (
'"Slice","Name","Invested value","Value","Result","Owned quantity"\n'
'"BIG","Mega Holding","1,234,567.89","1,250,000",15432.11,"100"\n'
'"Total","Big Pie","1,234,567.89","1,250,000",15432.11,"-"\n'
)
pie = parse_t212_csv(csv)
assert pie.positions[0].invested_value == pytest.approx(1234567.89)
assert pie.invested == pytest.approx(1234567.89)
def test_blank_rows_skipped():
csv = (
'"Slice","Name","Invested value","Value","Result","Owned quantity"\n'
'\n'
'"SHEL","Shell",100,105,5,"10"\n'
' \n'
'"Total","P",100,105,5,"-"\n'
)
pie = parse_t212_csv(csv)
assert len(pie.positions) == 1
def test_zero_quantity_skipped():
"""A position row with quantity=0 isn't a real holding — likely a stub
left over from a fully-sold position. Don't fail; skip."""
csv = (
'"Slice","Name","Invested value","Value","Result","Owned quantity"\n'
'"GONE","Sold Out",0,0,0,"0"\n'
'"SHEL","Shell",100,105,5,"10"\n'
'"Total","P",100,105,5,"-"\n'
)
pie = parse_t212_csv(csv)
assert [p.slice for p in pie.positions] == ["SHEL"]
def test_position_without_total_row_still_parses():
csv = (
'"Slice","Name","Invested value","Value","Result","Owned quantity"\n'
'"SHEL","Shell",100,105,5,"10"\n'
)
pie = parse_t212_csv(csv)
assert len(pie.positions) == 1
assert pie.name is None
assert pie.invested is None