aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-15 11:02:34 +0200
committerDanilo M. <danix@danix.xyz>2026-10-15 11:02:34 +0200
commit07f4134d38e53bf6c9235f3ad18dd5b33e89d9ff (patch)
tree65eed6420426560a7e8127baa3ea7a703d1223a0 /tests
parent50cb852130986249cacec411e23392a8610e12f1 (diff)
downloadfirefly-cli-master.tar.gz
firefly-cli-master.zip
feat(piggy): piggy list/add/remove (v0.6.0)HEADv0.6.0master
New `piggy` group: - `piggy list` lists piggy banks with saved/target amounts and linked accounts. - `piggy add|remove <name|id> <amount> [--account NAME]` changes the saved amount on a linked account via PUT /piggy-banks/{id}. Firefly syncs the whole account list on that PUT, so every linked account is resent (others without current_amount, which keeps their value) or it would be unlinked. Removing below zero is rejected before any write, and the saved amount is read back so a refused add fails loudly instead of returning 200 unchanged. add/remove currently hit an upstream bug on Firefly III <= 6.7.9: PiggyBankFactory::linkToAccountIds passes the pivot current_amount (an int from the DB) to bcsub() uncast, so any PUT with "accounts" 500s (firefly-iii/firefly-iii#13063). Documented in SKILL.md/README; the live integration test skips on that error and runs once upstream is fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'tests')
-rw-r--r--tests/integration/test_live.py43
-rw-r--r--tests/unit/test_commands_piggy.py80
-rw-r--r--tests/unit/test_resolver.py11
3 files changed, 134 insertions, 0 deletions
diff --git a/tests/integration/test_live.py b/tests/integration/test_live.py
index a815b88..ec900d7 100644
--- a/tests/integration/test_live.py
+++ b/tests/integration/test_live.py
@@ -54,3 +54,46 @@ class TestLive(unittest.TestCase):
self.assertEqual(got["data"]["id"], tx_id)
finally:
self.client.request("DELETE", f"/api/v1/transactions/{tx_id}")
+
+ def test_piggy_add_remove_roundtrip(self):
+ # Create a piggy bank on the TEST account, drive it through the CLI
+ # handlers, then delete it.
+ import io, contextlib, json
+ from types import SimpleNamespace
+ from firefly_cli.commands import piggy
+ from firefly_cli.errors import FireflyError
+ accs = unwrap(self.client.request("GET", "/api/v1/accounts",
+ params={"type": "asset", "limit": 50}))
+ acc = next((a for a in accs if float(a.get("current_balance") or 0) >= 10), None)
+ if acc is None:
+ self.skipTest("test account has no asset account with >= 10 balance")
+ created = self.client.request("POST", "/api/v1/piggy-banks", body={
+ "name": "firefly-cli integration piggy", "target_amount": "100",
+ "transaction_currency_code": acc.get("currency_code") or "EUR",
+ "start_date": "2026-01-01",
+ "accounts": [{"account_id": acc["id"], "current_amount": "0"}]})
+ pb_id = created["data"]["id"]
+ ctx = SimpleNamespace(client=self.client, resolver=self.resolver, human=False)
+
+ def run(fn, amount):
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ fn(SimpleNamespace(ref=pb_id, amount=amount, account=None), ctx)
+ return json.loads(buf.getvalue())["accounts"][0]["current_amount"]
+ try:
+ from firefly_cli.errors import ApiError
+ try:
+ first = run(piggy.cmd_add, "2.09")
+ except ApiError as e:
+ if "bcsub()" in str(e):
+ self.skipTest("firefly-iii/firefly-iii#13063: piggy PUT with accounts 500s")
+ raise
+ self.assertEqual(float(first), 2.09)
+ self.assertEqual(float(run(piggy.cmd_add, "0.91")), 3.00)
+ self.assertEqual(float(run(piggy.cmd_remove, "1")), 2.00)
+ with self.assertRaises(FireflyError): # more than the account holds
+ run(piggy.cmd_add, "99999999")
+ linked = unwrap(self.client.request("GET", f"/api/v1/piggy-banks/{pb_id}"))["accounts"]
+ self.assertEqual(len(linked), 1) # PUT did not unlink the account
+ finally:
+ self.client.request("DELETE", f"/api/v1/piggy-banks/{pb_id}")
diff --git a/tests/unit/test_commands_piggy.py b/tests/unit/test_commands_piggy.py
new file mode 100644
index 0000000..24565ac
--- /dev/null
+++ b/tests/unit/test_commands_piggy.py
@@ -0,0 +1,80 @@
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only
+import unittest
+from unittest.mock import MagicMock
+from types import SimpleNamespace
+import firefly_cli.commands.piggy as piggy
+from firefly_cli.errors import FireflyError
+
+
+def _pb(accounts):
+ return {"id": "3", "name": "Round-ups", "accounts": accounts}
+
+
+def _ctx(pb, put_accounts=None):
+ """Resolver returns `pb`; the PUT echoes `put_accounts` (what Firefly saved)."""
+ resolver = MagicMock()
+ resolver.piggy.return_value = pb
+ client = MagicMock()
+ client.request.return_value = {"data": {"id": "3", "attributes": {
+ "name": "Round-ups", "accounts": put_accounts or []}}}
+ return SimpleNamespace(client=client, resolver=resolver, human=False)
+
+
+ONE = [{"account_id": "6", "name": "BBVA", "current_amount": "1.50"}]
+TWO = ONE + [{"account_id": "8", "name": "Fineco", "current_amount": "0.00"}]
+
+
+class TestPiggyAddRemove(unittest.TestCase):
+ def test_add_single_account_puts_new_total(self):
+ ctx = _ctx(_pb(ONE), [{"account_id": "6", "current_amount": "3.59"}])
+ rc = piggy.cmd_add(SimpleNamespace(ref="Round-ups", amount="2.09", account=None), ctx)
+ self.assertEqual(rc, 0)
+ method, path = ctx.client.request.call_args[0][:2]
+ self.assertEqual((method, path), ("PUT", "/api/v1/piggy-banks/3"))
+ body = ctx.client.request.call_args[1]["body"]
+ self.assertEqual(body, {"accounts": [{"account_id": "6", "current_amount": "3.59"}]})
+
+ def test_remove_subtracts(self):
+ ctx = _ctx(_pb(ONE), [{"account_id": "6", "current_amount": "1.00"}])
+ piggy.cmd_remove(SimpleNamespace(ref="Round-ups", amount="0.50", account=None), ctx)
+ body = ctx.client.request.call_args[1]["body"]
+ self.assertEqual(body["accounts"][0]["current_amount"], "1.00")
+
+ def test_remove_below_zero_is_error_without_write(self):
+ ctx = _ctx(_pb(ONE))
+ with self.assertRaises(FireflyError):
+ piggy.cmd_remove(SimpleNamespace(ref="Round-ups", amount="5", account=None), ctx)
+ ctx.client.request.assert_not_called()
+
+ def test_multi_account_needs_account_flag(self):
+ ctx = _ctx(_pb(TWO))
+ with self.assertRaises(FireflyError):
+ piggy.cmd_add(SimpleNamespace(ref="Round-ups", amount="1", account=None), ctx)
+
+ def test_multi_account_resends_others_without_amount(self):
+ # Firefly syncs the account list: omitting an account would unlink it.
+ ctx = _ctx(_pb(TWO), [{"account_id": "8", "current_amount": "1.00"}])
+ piggy.cmd_add(SimpleNamespace(ref="Round-ups", amount="1", account="fineco"), ctx)
+ body = ctx.client.request.call_args[1]["body"]
+ self.assertEqual(body["accounts"], [{"account_id": "6"},
+ {"account_id": "8", "current_amount": "1.00"}])
+
+ def test_unknown_account_is_error(self):
+ ctx = _ctx(_pb(ONE))
+ with self.assertRaises(FireflyError):
+ piggy.cmd_add(SimpleNamespace(ref="Round-ups", amount="1", account="Nope"), ctx)
+
+ def test_silent_refusal_is_detected(self):
+ # Firefly skips an add it can't afford but still returns 200.
+ ctx = _ctx(_pb(ONE), [{"account_id": "6", "current_amount": "1.50"}])
+ with self.assertRaises(FireflyError):
+ piggy.cmd_add(SimpleNamespace(ref="Round-ups", amount="2", account=None), ctx)
+
+ def test_bad_amount(self):
+ for bad in ("0", "-1", "abc"):
+ with self.assertRaises(FireflyError):
+ piggy.cmd_add(SimpleNamespace(ref="x", amount=bad, account=None), _ctx(_pb(ONE)))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/test_resolver.py b/tests/unit/test_resolver.py
index 8c93a84..d9a1dcd 100644
--- a/tests/unit/test_resolver.py
+++ b/tests/unit/test_resolver.py
@@ -84,3 +84,14 @@ class TestResolverBudget(unittest.TestCase):
self.assertEqual(got["name"], "Fun")
# Must hit the show endpoint, not list.
client.request.assert_called_with("GET", "/api/v1/budgets/7")
+
+class TestPiggyResolver(unittest.TestCase):
+ def test_piggy_by_name(self):
+ c = client_returning([{"id": "1", "attrs": {"name": "BBVA risparmi"}}])
+ self.assertEqual(Resolver(c).piggy("bbva risparmi")["id"], "1")
+
+ def test_piggy_by_id_hits_show_endpoint(self):
+ c = MagicMock()
+ c.request.return_value = {"data": {"id": "1", "attributes": {"name": "X"}}}
+ Resolver(c).piggy("1")
+ self.assertEqual(c.request.call_args[0][1], "/api/v1/piggy-banks/1")