Skip to content

Commit 286e5e4

Browse files
committed
Add tests and documentation for fetch command
- Add test_cli.py with 6 tests covering: - Directory structure creation - File naming convention - JSON content (account metadata + transactions) - Duplicate account name handling - Required options validation - Update README.md with fetch command documentation
1 parent 753fe04 commit 286e5e4

2 files changed

Lines changed: 285 additions & 0 deletions

File tree

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,52 @@ cog.out(
204204
}
205205
```
206206
<!-- [[[end]]] -->
207+
208+
#### Fetch all accounts to separate files
209+
210+
`simplefin fetch --output-dir DIRECTORY [--lookback-days INTEGER]`
211+
212+
Fetches all accounts with their transactions and saves each account to a separate JSON file. Files are organized hierarchically by institution and account name:
213+
214+
```
215+
output-dir/
216+
institution-domain/
217+
account-name/
218+
account-id_YYYY-MM-DD.json
219+
```
220+
221+
This is useful for integration with tools like [beangulp](https://github.com/beancount/beangulp) that expect one file per account.
222+
223+
```
224+
❯ simplefin fetch --output-dir ./simplefin-data --lookback-days 30
225+
Found 2 accounts
226+
SimpleFIN Savings: 3 transactions -> beta-bridge.simplefin.org/SimpleFIN-Savings/Demo-Savings_2025-01-15.json
227+
SimpleFIN Checking: 2 transactions -> beta-bridge.simplefin.org/SimpleFIN-Checking/Demo-Checking_2025-01-15.json
228+
229+
Wrote 2 account files to ./simplefin-data
230+
```
231+
232+
Each JSON file contains the full account metadata plus transactions:
233+
234+
```json
235+
{
236+
"org": {
237+
"domain": "beta-bridge.simplefin.org",
238+
"name": "SimpleFIN Demo"
239+
},
240+
"id": "Demo Savings",
241+
"name": "SimpleFIN Savings",
242+
"currency": "USD",
243+
"balance": "115525.50",
244+
"balance-date": 1738368000,
245+
"transactions": [
246+
{
247+
"id": "1738382400",
248+
"posted": "2025-02-01T12:00:00+00:00",
249+
"amount": "-50.00",
250+
"description": "Fishing bait",
251+
"payee": "John's Fishin Shack"
252+
}
253+
]
254+
}
255+
```

tests/test_cli.py

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import datetime
2+
import json
3+
import os
4+
import tempfile
5+
from pathlib import Path
6+
from unittest.mock import patch
7+
8+
import pytest
9+
from click.testing import CliRunner
10+
11+
from simplefin.cli import cli
12+
13+
14+
@pytest.fixture
15+
def mock_accounts():
16+
"""Sample accounts response."""
17+
return [
18+
{
19+
"org": {
20+
"domain": "beta-bridge.simplefin.org",
21+
"name": "SimpleFIN Demo",
22+
},
23+
"id": "ACT-savings-123",
24+
"name": "SimpleFIN Savings",
25+
"currency": "USD",
26+
"balance": "1000.00",
27+
"balance-date": 1736553600,
28+
},
29+
{
30+
"org": {
31+
"domain": "beta-bridge.simplefin.org",
32+
"name": "SimpleFIN Demo",
33+
},
34+
"id": "ACT-checking-456",
35+
"name": "SimpleFIN Checking",
36+
"currency": "USD",
37+
"balance": "500.00",
38+
"balance-date": 1736553600,
39+
},
40+
]
41+
42+
43+
@pytest.fixture
44+
def mock_transactions():
45+
"""Sample transactions response."""
46+
return [
47+
{
48+
"id": "TRN-001",
49+
"posted": datetime.datetime(2025, 1, 10, 12, 0, 0),
50+
"amount": "-50.00",
51+
"description": "Test transaction",
52+
"payee": "Test Payee",
53+
},
54+
]
55+
56+
57+
class TestFetchCommand:
58+
"""Tests for the fetch CLI command."""
59+
60+
def test_fetch_creates_directory_structure(self, mock_accounts, mock_transactions):
61+
"""Test that fetch creates the correct directory structure."""
62+
runner = CliRunner()
63+
64+
with tempfile.TemporaryDirectory() as tmpdir:
65+
with patch.dict(os.environ, {"SIMPLEFIN_ACCESS_URL": "https://mock"}):
66+
with patch("simplefin.cli.SimpleFINClient") as MockClient:
67+
mock_client = MockClient.return_value
68+
mock_client.get_accounts.return_value = mock_accounts
69+
mock_client.get_transactions.return_value = mock_transactions
70+
71+
result = runner.invoke(
72+
cli,
73+
["fetch", "--output-dir", tmpdir, "--lookback-days", "7"],
74+
)
75+
76+
assert result.exit_code == 0
77+
assert "Found 2 accounts" in result.output
78+
79+
# Check directory structure
80+
inst_dir = Path(tmpdir) / "beta-bridge.simplefin.org"
81+
assert inst_dir.exists()
82+
83+
savings_dir = inst_dir / "SimpleFIN-Savings"
84+
checking_dir = inst_dir / "SimpleFIN-Checking"
85+
assert savings_dir.exists()
86+
assert checking_dir.exists()
87+
88+
def test_fetch_creates_json_files_with_correct_naming(
89+
self, mock_accounts, mock_transactions
90+
):
91+
"""Test that JSON files are created with correct naming convention."""
92+
runner = CliRunner()
93+
today = datetime.date.today().isoformat()
94+
95+
with tempfile.TemporaryDirectory() as tmpdir:
96+
with patch.dict(os.environ, {"SIMPLEFIN_ACCESS_URL": "https://mock"}):
97+
with patch("simplefin.cli.SimpleFINClient") as MockClient:
98+
mock_client = MockClient.return_value
99+
mock_client.get_accounts.return_value = mock_accounts
100+
mock_client.get_transactions.return_value = mock_transactions
101+
102+
result = runner.invoke(
103+
cli,
104+
["fetch", "--output-dir", tmpdir, "--lookback-days", "7"],
105+
)
106+
107+
assert result.exit_code == 0
108+
109+
# Check file naming
110+
savings_file = (
111+
Path(tmpdir)
112+
/ "beta-bridge.simplefin.org"
113+
/ "SimpleFIN-Savings"
114+
/ f"ACT-savings-123_{today}.json"
115+
)
116+
assert savings_file.exists()
117+
118+
def test_fetch_json_contains_account_and_transactions(
119+
self, mock_accounts, mock_transactions
120+
):
121+
"""Test that JSON files contain both account metadata and transactions."""
122+
runner = CliRunner()
123+
today = datetime.date.today().isoformat()
124+
125+
with tempfile.TemporaryDirectory() as tmpdir:
126+
with patch.dict(os.environ, {"SIMPLEFIN_ACCESS_URL": "https://mock"}):
127+
with patch("simplefin.cli.SimpleFINClient") as MockClient:
128+
mock_client = MockClient.return_value
129+
mock_client.get_accounts.return_value = mock_accounts
130+
mock_client.get_transactions.return_value = mock_transactions
131+
132+
result = runner.invoke(
133+
cli,
134+
["fetch", "--output-dir", tmpdir, "--lookback-days", "7"],
135+
)
136+
137+
assert result.exit_code == 0
138+
139+
# Read and verify JSON content
140+
savings_file = (
141+
Path(tmpdir)
142+
/ "beta-bridge.simplefin.org"
143+
/ "SimpleFIN-Savings"
144+
/ f"ACT-savings-123_{today}.json"
145+
)
146+
147+
with open(savings_file) as f:
148+
data = json.load(f)
149+
150+
# Check account metadata
151+
assert data["id"] == "ACT-savings-123"
152+
assert data["name"] == "SimpleFIN Savings"
153+
assert data["currency"] == "USD"
154+
assert data["balance"] == "1000.00"
155+
156+
# Check transactions were merged
157+
assert "transactions" in data
158+
assert len(data["transactions"]) == 1
159+
assert data["transactions"][0]["id"] == "TRN-001"
160+
161+
def test_fetch_handles_duplicate_account_names(self, mock_transactions):
162+
"""Test that accounts with the same name get unique files."""
163+
runner = CliRunner()
164+
today = datetime.date.today().isoformat()
165+
166+
# Two accounts with the same name but different IDs
167+
accounts_with_duplicates = [
168+
{
169+
"org": {"domain": "example.com", "name": "Example Bank"},
170+
"id": "ACT-111",
171+
"name": "Checking",
172+
"currency": "USD",
173+
"balance": "100.00",
174+
"balance-date": 1736553600,
175+
},
176+
{
177+
"org": {"domain": "example.com", "name": "Example Bank"},
178+
"id": "ACT-222",
179+
"name": "Checking",
180+
"currency": "USD",
181+
"balance": "200.00",
182+
"balance-date": 1736553600,
183+
},
184+
]
185+
186+
with tempfile.TemporaryDirectory() as tmpdir:
187+
with patch.dict(os.environ, {"SIMPLEFIN_ACCESS_URL": "https://mock"}):
188+
with patch("simplefin.cli.SimpleFINClient") as MockClient:
189+
mock_client = MockClient.return_value
190+
mock_client.get_accounts.return_value = accounts_with_duplicates
191+
mock_client.get_transactions.return_value = []
192+
193+
result = runner.invoke(
194+
cli,
195+
["fetch", "--output-dir", tmpdir, "--lookback-days", "7"],
196+
)
197+
198+
assert result.exit_code == 0
199+
200+
# Both files should exist in the same directory
201+
checking_dir = Path(tmpdir) / "example.com" / "Checking"
202+
files = list(checking_dir.glob("*.json"))
203+
assert len(files) == 2
204+
205+
# Files should have different account IDs in names
206+
file_names = [f.name for f in files]
207+
assert f"ACT-111_{today}.json" in file_names
208+
assert f"ACT-222_{today}.json" in file_names
209+
210+
def test_fetch_requires_output_dir(self):
211+
"""Test that --output-dir is required."""
212+
runner = CliRunner()
213+
214+
with patch.dict(os.environ, {"SIMPLEFIN_ACCESS_URL": "https://mock"}):
215+
result = runner.invoke(cli, ["fetch"])
216+
217+
assert result.exit_code != 0
218+
assert "Missing option '--output-dir'" in result.output
219+
220+
def test_fetch_requires_access_url_env(self):
221+
"""Test that SIMPLEFIN_ACCESS_URL environment variable is required."""
222+
runner = CliRunner()
223+
224+
with tempfile.TemporaryDirectory() as tmpdir:
225+
# Ensure env var is not set
226+
env = os.environ.copy()
227+
env.pop("SIMPLEFIN_ACCESS_URL", None)
228+
229+
with patch.dict(os.environ, env, clear=True):
230+
result = runner.invoke(
231+
cli,
232+
["fetch", "--output-dir", tmpdir],
233+
)
234+
235+
# Should fail because no access URL
236+
assert result.exit_code != 0

0 commit comments

Comments
 (0)