broker-sync/tests/test_cli.py

78 lines
2.1 KiB
Python
Raw Normal View History

Add broker-sync invest-engine CLI subcommand Context: Phase 2b wiring — hand the bearer-token InvestEngineProvider into the existing sync pipeline (sync_provider_to_wealthfolio), mirroring the trading212 subcommand. Environment contract: WF_BASE_URL, WF_USERNAME, WF_PASSWORD, WF_SESSION_PATH (shared with trading212) IE_BEARER_TOKEN (devtools-pasted) IE_TOKEN_EXPIRES_AT (ISO-8601; Viktor sets on paste) BROKER_SYNC_DATA_DIR (sync.db + checkpoint state) Exit codes: 0 = clean run 1 = some rows failed to import (mirrors trading212 behaviour) 2 = token already expired per IE_TOKEN_EXPIRES_AT, or malformed ISO timestamp, or live 401 response from IE (InvestEngineTokenExpiredError), or unknown --mode flag The pre-request expiry check is deliberate: a CronJob that runs during the refresh window would otherwise waste a request on a dead token and get the same 401 that we already know about from the clock. Exit 2 from the clock-only path also separates "token is old" from "wealthfolio rejected a batch" in the CronJob alert pipeline. Mode defaults: --mode steady → since = now - 30d (bigger window than T212's 7d because the IE sync only runs once a month in steady state; 30d guarantees no gap even after a missed run) --mode backfill → since = None (full history) This change: - `invest-engine` subcommand added to broker_sync/cli.py - Token-expiry pre-check (clock), IE_TOKEN_EXPIRES_AT ISO parsing with a UTC default for naive timestamps, and graceful handling of InvestEngineTokenExpiredError surfaced during pipeline run - 3 new tests in tests/test_cli.py covering the 3 exit-2 paths ## Automated poetry run pytest tests/test_cli.py -v ======================== 4 passed in 0.28s ========================= poetry run pytest -q 98 passed, 1 skipped in 0.85s poetry run mypy --strict . Success: no issues found in 34 source files poetry run ruff check . All checks passed! ## Manual Verification 1. Populate Vault keys per the docstring in broker_sync/providers/invest_engine.py (Viktor pastes token + sets expires_at to the Monday morning of next month). 2. Set env: export WF_BASE_URL=https://wealthfolio.viktorbarzin.me export WF_USERNAME=viktor export WF_PASSWORD=<from Vault> export IE_BEARER_TOKEN=<from Vault> export IE_TOKEN_EXPIRES_AT=<from Vault> export BROKER_SYNC_DATA_DIR=/tmp/ie-smoke 3. poetry run broker-sync invest-engine --mode backfill Expected: single line "invest-engine: fetched=N new=M imported=M failed=0" on success; exit 2 with "InvestEngine token expired..." if the clock or server disagrees; exit 2 with "IE_TOKEN_EXPIRES_AT not a valid ISO-8601 timestamp..." if the env var is malformed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:59:31 +00:00
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typer.testing import CliRunner
from broker_sync import __version__
from broker_sync.cli import app
runner = CliRunner()
def test_version_prints_package_version() -> None:
result = runner.invoke(app, ["version"])
assert result.exit_code == 0
assert __version__ in result.stdout
Add broker-sync invest-engine CLI subcommand Context: Phase 2b wiring — hand the bearer-token InvestEngineProvider into the existing sync pipeline (sync_provider_to_wealthfolio), mirroring the trading212 subcommand. Environment contract: WF_BASE_URL, WF_USERNAME, WF_PASSWORD, WF_SESSION_PATH (shared with trading212) IE_BEARER_TOKEN (devtools-pasted) IE_TOKEN_EXPIRES_AT (ISO-8601; Viktor sets on paste) BROKER_SYNC_DATA_DIR (sync.db + checkpoint state) Exit codes: 0 = clean run 1 = some rows failed to import (mirrors trading212 behaviour) 2 = token already expired per IE_TOKEN_EXPIRES_AT, or malformed ISO timestamp, or live 401 response from IE (InvestEngineTokenExpiredError), or unknown --mode flag The pre-request expiry check is deliberate: a CronJob that runs during the refresh window would otherwise waste a request on a dead token and get the same 401 that we already know about from the clock. Exit 2 from the clock-only path also separates "token is old" from "wealthfolio rejected a batch" in the CronJob alert pipeline. Mode defaults: --mode steady → since = now - 30d (bigger window than T212's 7d because the IE sync only runs once a month in steady state; 30d guarantees no gap even after a missed run) --mode backfill → since = None (full history) This change: - `invest-engine` subcommand added to broker_sync/cli.py - Token-expiry pre-check (clock), IE_TOKEN_EXPIRES_AT ISO parsing with a UTC default for naive timestamps, and graceful handling of InvestEngineTokenExpiredError surfaced during pipeline run - 3 new tests in tests/test_cli.py covering the 3 exit-2 paths ## Automated poetry run pytest tests/test_cli.py -v ======================== 4 passed in 0.28s ========================= poetry run pytest -q 98 passed, 1 skipped in 0.85s poetry run mypy --strict . Success: no issues found in 34 source files poetry run ruff check . All checks passed! ## Manual Verification 1. Populate Vault keys per the docstring in broker_sync/providers/invest_engine.py (Viktor pastes token + sets expires_at to the Monday morning of next month). 2. Set env: export WF_BASE_URL=https://wealthfolio.viktorbarzin.me export WF_USERNAME=viktor export WF_PASSWORD=<from Vault> export IE_BEARER_TOKEN=<from Vault> export IE_TOKEN_EXPIRES_AT=<from Vault> export BROKER_SYNC_DATA_DIR=/tmp/ie-smoke 3. poetry run broker-sync invest-engine --mode backfill Expected: single line "invest-engine: fetched=N new=M imported=M failed=0" on success; exit 2 with "InvestEngine token expired..." if the clock or server disagrees; exit 2 with "IE_TOKEN_EXPIRES_AT not a valid ISO-8601 timestamp..." if the env var is malformed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:59:31 +00:00
# -- invest-engine CLI --
def _future_iso() -> str:
return (datetime.now(UTC) + timedelta(days=30)).isoformat()
def _past_iso() -> str:
return (datetime.now(UTC) - timedelta(days=1)).isoformat()
def test_invest_engine_expired_token_exits_2() -> None:
"""Guard against burning a request on a token the user already knows is dead."""
result = runner.invoke(
app,
["invest-engine"],
env={
"WF_BASE_URL": "https://wf.example.com",
"WF_USERNAME": "u",
"WF_PASSWORD": "p",
"IE_BEARER_TOKEN": "anything",
"IE_TOKEN_EXPIRES_AT": _past_iso(),
"BROKER_SYNC_DATA_DIR": "/tmp",
},
)
assert result.exit_code == 2, result.output
assert "expired" in result.output.lower() or "token" in result.output.lower()
def test_invest_engine_unknown_mode_exits_2() -> None:
result = runner.invoke(
app,
["invest-engine", "--mode", "nonsense"],
env={
"WF_BASE_URL": "https://wf.example.com",
"WF_USERNAME": "u",
"WF_PASSWORD": "p",
"IE_BEARER_TOKEN": "t",
"IE_TOKEN_EXPIRES_AT": _future_iso(),
"BROKER_SYNC_DATA_DIR": "/tmp",
},
)
assert result.exit_code == 2
def test_invest_engine_malformed_expires_exits_2() -> None:
result = runner.invoke(
app,
["invest-engine"],
env={
"WF_BASE_URL": "https://wf.example.com",
"WF_USERNAME": "u",
"WF_PASSWORD": "p",
"IE_BEARER_TOKEN": "t",
"IE_TOKEN_EXPIRES_AT": "not-an-iso-date",
"BROKER_SYNC_DATA_DIR": "/tmp",
},
)
assert result.exit_code == 2