# Copyright (C) 2026 Danilo M. GPL-2.0-only import unittest from unittest.mock import MagicMock from types import SimpleNamespace import firefly_cli.commands.budget as budget from firefly_cli.errors import FireflyError def _ctx(client=None, resolver=None): return SimpleNamespace(client=client or MagicMock(), resolver=resolver or MagicMock(), human=False) class TestBudgetList(unittest.TestCase): def test_list_defaults_to_current_month(self): client = MagicMock() client.request.return_value = {"data": []} args = SimpleNamespace(start=None, end=None) rc = budget.cmd_list(args, _ctx(client=client)) self.assertEqual(rc, 0) _, kwargs = client.request.call_args params = kwargs["params"] self.assertTrue(params["start"].endswith("-01")) self.assertEqual(params["start"][:7], params["end"][:7]) def test_list_explicit_range(self): client = MagicMock() client.request.return_value = {"data": []} args = SimpleNamespace(start="2026-01-01", end="2026-01-31") budget.cmd_list(args, _ctx(client=client)) _, kwargs = client.request.call_args self.assertEqual(kwargs["params"], {"start": "2026-01-01", "end": "2026-01-31"}) class TestBudgetCreate(unittest.TestCase): def test_create_minimal_active(self): client = MagicMock() client.request.return_value = {"data": {"id": "9", "attributes": {"name": "Fun"}}} args = SimpleNamespace(name="Fun", active=True, currency=None, auto_budget_amount=None, auto_budget_period=None, auto_budget_type=None) rc = budget.cmd_create(args, _ctx(client=client)) self.assertEqual(rc, 0) method, path = client.request.call_args[0][:2] body = client.request.call_args[1]["body"] self.assertEqual((method, path), ("POST", "/api/v1/budgets")) self.assertEqual(body["name"], "Fun") self.assertTrue(body["active"]) def test_create_inactive_with_auto_budget(self): client = MagicMock() client.request.return_value = {"data": {"id": "9", "attributes": {}}} args = SimpleNamespace(name="Cap", active=False, currency="EUR", auto_budget_amount="500", auto_budget_period="monthly", auto_budget_type="reset") budget.cmd_create(args, _ctx(client=client)) body = client.request.call_args[1]["body"] self.assertFalse(body["active"]) self.assertEqual(body["auto_budget_amount"], "500") self.assertEqual(body["auto_budget_type"], "reset") self.assertEqual(body["currency_code"], "EUR") class TestBudgetSpent(unittest.TestCase): def test_human_mode_replaces_spent_with_scalar(self): client = MagicMock() client.request.return_value = {"data": [ {"id": "1", "attributes": {"name": "Food", "spent": [{"sum": "-12.50"}, {"sum": "-2.50"}]}}]} ctx = _ctx(client=client) ctx.human = True captured = {} import firefly_cli.output as output orig = output.emit def spy(data, human=False, stream=None): captured["rows"] = data output.emit = spy try: budget.cmd_list(SimpleNamespace(start=None, end=None), ctx) finally: output.emit = orig self.assertEqual(captured["rows"][0]["spent"], "-15.00") class TestBudgetLifecycle(unittest.TestCase): def _resolver(self): r = MagicMock() r.budget.return_value = {"id": "5", "name": "Rent"} return r def test_delete_requires_yes(self): args = SimpleNamespace(ref="Rent", yes=False) with self.assertRaises(FireflyError): budget.cmd_delete(args, _ctx(resolver=self._resolver())) def test_delete_with_yes_calls_delete(self): client = MagicMock() args = SimpleNamespace(ref="Rent", yes=True) rc = budget.cmd_delete(args, _ctx(client=client, resolver=self._resolver())) self.assertEqual(rc, 0) client.request.assert_called_with("DELETE", "/api/v1/budgets/5") def test_enable_sets_active_true(self): client = MagicMock() client.request.return_value = {"data": {"id": "5", "attributes": {}}} args = SimpleNamespace(ref="Rent") budget.cmd_enable(args, _ctx(client=client, resolver=self._resolver())) method, path = client.request.call_args[0][:2] body = client.request.call_args[1]["body"] self.assertEqual((method, path), ("PUT", "/api/v1/budgets/5")) self.assertTrue(body["active"]) def test_disable_sets_active_false(self): client = MagicMock() client.request.return_value = {"data": {"id": "5", "attributes": {}}} args = SimpleNamespace(ref="Rent") budget.cmd_disable(args, _ctx(client=client, resolver=self._resolver())) self.assertFalse(client.request.call_args[1]["body"]["active"]) class TestBudgetUpdate(unittest.TestCase): def _resolver(self): r = MagicMock() r.budget.return_value = {"id": "5", "name": "Rent"} return r def _args(self, **kw): base = dict(ref="Rent", name=None, currency=None, auto_budget_amount=None, auto_budget_period=None, auto_budget_type=None) base.update(kw) return SimpleNamespace(**base) def test_rename_puts_name(self): client = MagicMock() client.request.return_value = {"data": {"id": "5", "attributes": {}}} budget.cmd_update(self._args(name="Housing"), _ctx(client=client, resolver=self._resolver())) method, path = client.request.call_args[0][:2] body = client.request.call_args[1]["body"] self.assertEqual((method, path), ("PUT", "/api/v1/budgets/5")) self.assertEqual(body, {"name": "Housing"}) def test_auto_budget_fields(self): client = MagicMock() client.request.return_value = {"data": {"id": "5", "attributes": {}}} budget.cmd_update( self._args(auto_budget_amount=500, auto_budget_period="monthly", auto_budget_type="reset", currency="EUR"), _ctx(client=client, resolver=self._resolver())) body = client.request.call_args[1]["body"] self.assertEqual(body["auto_budget_amount"], "500") self.assertEqual(body["auto_budget_period"], "monthly") self.assertEqual(body["auto_budget_type"], "reset") self.assertEqual(body["currency_code"], "EUR") self.assertNotIn("name", body) def test_no_fields_errors(self): with self.assertRaises(FireflyError): budget.cmd_update(self._args(), _ctx(resolver=self._resolver())) class TestBudgetLimit(unittest.TestCase): def _resolver(self): r = MagicMock() r.budget.return_value = {"id": "5", "name": "Rent"} return r def test_limit_list(self): client = MagicMock() client.request.return_value = {"data": []} args = SimpleNamespace(ref="Rent") rc = budget.cmd_limit_list(args, _ctx(client=client, resolver=self._resolver())) self.assertEqual(rc, 0) client.request.assert_called_with("GET", "/api/v1/budgets/5/limits") def test_limit_set_default_month(self): client = MagicMock() client.request.return_value = {"data": {"id": "1", "attributes": {}}} args = SimpleNamespace(ref="Rent", amount="800", start=None, end=None, currency=None) budget.cmd_limit_set(args, _ctx(client=client, resolver=self._resolver())) method, path = client.request.call_args[0][:2] body = client.request.call_args[1]["body"] self.assertEqual((method, path), ("POST", "/api/v1/budgets/5/limits")) self.assertEqual(body["amount"], "800") self.assertTrue(body["start"].endswith("-01")) self.assertEqual(body["start"][:7], body["end"][:7]) def test_limit_set_explicit_range_and_currency(self): client = MagicMock() client.request.return_value = {"data": {"id": "1", "attributes": {}}} args = SimpleNamespace(ref="Rent", amount="800", start="2026-02-01", end="2026-02-28", currency="EUR") budget.cmd_limit_set(args, _ctx(client=client, resolver=self._resolver())) body = client.request.call_args[1]["body"] self.assertEqual(body["start"], "2026-02-01") self.assertEqual(body["end"], "2026-02-28") self.assertEqual(body["currency_code"], "EUR") class TestBudgetRegistration(unittest.TestCase): # Guards the v0.3.7 class of bug: unit tests call cmd_* directly and bypass # the registry, so a decorator bound to the wrong function is invisible to # them. Assert each budget command is registered AND bound to its handler. def test_each_budget_command_binds_to_its_handler(self): from firefly_cli import registry import firefly_cli.commands # noqa: F401 ensure all modules registered expected = { "budget list": budget.cmd_list, "budget create": budget.cmd_create, "budget update": budget.cmd_update, "budget delete": budget.cmd_delete, "budget enable": budget.cmd_enable, "budget disable": budget.cmd_disable, "budget limit-list": budget.cmd_limit_list, "budget limit-set": budget.cmd_limit_set, } by_name = {c.name: c.handler for c in registry.all_commands()} for name, fn in expected.items(): self.assertIn(name, by_name, f"{name} not registered") self.assertIs(by_name[name], fn, f"{name} bound to wrong handler") if __name__ == "__main__": unittest.main()