diff --git a/src/dualentry_cli/cli.py b/src/dualentry_cli/cli.py index 8cc9695..bcf13d7 100644 --- a/src/dualentry_cli/cli.py +++ b/src/dualentry_cli/cli.py @@ -4,9 +4,9 @@ import typer -# typer >= 0.26 vendors click; TyperGroup raises the vendored UsageError. -from typer._click.exceptions import UsageError -from typer.core import TyperGroup +# typer >= 0.26 vendors click; the parser raises the vendored exceptions. +from typer._click.exceptions import NoSuchOption, UsageError +from typer.core import TyperCommand, TyperGroup LOGO = r""" /$$$$$$$ /$$ /$$ @@ -23,6 +23,23 @@ """ +def make_list_command_cls(name: str, offered_options: list[str], gated_options: set[str]) -> type[TyperCommand]: + """Build a list-command class that names the resource when a gated filter flag is passed.""" + offered = ", ".join(offered_options) or "none" + + class ResourceListCommand(TyperCommand): + def parse_args(self, ctx, args): + try: + return super().parse_args(ctx, args) + except NoSuchOption as exc: + if exc.option_name not in gated_options: + raise + message = f"{name} does not support {exc.option_name}. Supported filters: {offered}." + raise UsageError(message, ctx=ctx) from None + + return ResourceListCommand + + class HelpfulGroup(TyperGroup): """Typer group that shows help + suggestions instead of 'No such command'.""" diff --git a/src/dualentry_cli/commands/__init__.py b/src/dualentry_cli/commands/__init__.py index cd4c215..e6e43f7 100644 --- a/src/dualentry_cli/commands/__init__.py +++ b/src/dualentry_cli/commands/__init__.py @@ -8,10 +8,22 @@ import typer -from dualentry_cli.cli import HelpfulGroup +from dualentry_cli.cli import HelpfulGroup, make_list_command_cls from dualentry_cli.client import _MAX_PAGES from dualentry_cli.output import _RECORD_PREFIX, format_output +# Filter flags `list` can offer, with every option spelling each one accepts. +FILTER_OPTIONS = { + "search": ("--search", "-s"), + "status": ("--status",), + "start_date": ("--start-date",), + "end_date": ("--end-date",), + "company": ("--company", "-c"), + "customer": ("--customer",), + "vendor": ("--vendor",), +} +ALL_FILTERS = frozenset(FILTER_OPTIONS) + # ── Shared option defaults ────────────────────────────────────────── Limit = typer.Option(20, "--limit", "-l", help="Max items to return") @@ -52,11 +64,17 @@ def _strip_record_prefix(number: str) -> str: return number +def _supplied(value) -> str | None: + """Return a flag's value, or None when a stripped option left its OptionInfo sentinel unbound.""" + return value if isinstance(value, str) else None + + def _build_filter_params( search: str | None = None, status: str | None = None, start_date: str | None = None, end_date: str | None = None, + status_param: str = "record_status", **extra, ) -> dict: """Build filter query params, omitting None values.""" @@ -64,7 +82,7 @@ def _build_filter_params( if search: params["search"] = search if status: - params["record_status"] = status + params[status_param] = status if start_date: params["start_date"] = start_date if end_date: @@ -170,18 +188,31 @@ def make_resource_app( has_delete: bool = False, has_number: bool = False, has_post: bool = False, - filters: set[str] | None = None, + filters: set[str], + status_param: str = "record_status", template: dict | None = None, checks: list[Callable] | None = None, online_checks: list[Callable] | None = None, ) -> typer.Typer: - """Create a Typer app for a standard CRUD resource.""" + """ + Create a Typer app for a standard CRUD resource. + + `filters` lists the flags this resource's v2 list endpoint declares; the rest + are removed from `list` so the API cannot silently drop them. + """ import inspect app = typer.Typer(help=f"Manage {name}", no_args_is_help=True, cls=HelpfulGroup) - enabled_filters = filters or set() + unknown = filters - ALL_FILTERS + if unknown: + msg = f"{name}: unknown filter flags {sorted(unknown)}" + raise ValueError(msg) + remove = ALL_FILTERS - filters + + gated_options = {option for flag in remove for option in FILTER_OPTIONS[flag]} + offered_options = sorted(FILTER_OPTIONS[flag][0] for flag in filters) - @app.command("list") + @app.command("list", cls=make_list_command_cls(name, offered_options, gated_options)) def list_cmd( *, limit: int = Limit, @@ -207,19 +238,18 @@ def list_cmd( offset=offset, all_pages=all_pages, output=output, - search=search, - status=status, - start_date=start_date, - end_date=end_date, - company_id=company if isinstance(company, str) else None, - customer_id=customer if isinstance(customer, str) else None, - vendor_id=vendor if isinstance(vendor, str) else None, + search=_supplied(search), + status=_supplied(status), + start_date=_supplied(start_date), + end_date=_supplied(end_date), + status_param=status_param, + company_id=_supplied(company), + customer_id=_supplied(customer), + vendor_id=_supplied(vendor), ) list_cmd.__doc__ = f"List {name}." - all_filters = {"company", "customer", "vendor"} - remove = all_filters - enabled_filters if remove: sig = inspect.signature(list_cmd) list_cmd.__signature__ = sig.replace(parameters=[p for p in sig.parameters.values() if p.name not in remove]) diff --git a/src/dualentry_cli/main.py b/src/dualentry_cli/main.py index f50302f..912bdb1 100644 --- a/src/dualentry_cli/main.py +++ b/src/dualentry_cli/main.py @@ -17,62 +17,78 @@ app.add_typer(auth_app, name="auth") app.add_typer(config_app, name="config") +# Filter flags each resource's /public/v2 list endpoint declares (verified against +# the v2 OpenAPI schema); a flag left out here is one the API would silently ignore. +TXN = {"search", "status", "start_date", "end_date", "company"} +TXN_CUSTOMER = TXN | {"customer"} +TXN_VENDOR = TXN | {"vendor"} +TXN_ALL_PARTIES = TXN | {"customer", "vendor"} + # Custom-formatted resources (use factory - output.py handles formatting via resource name) -app.add_typer(make_resource_app("invoices", "invoice", "invoices", has_number=True, filters={"customer", "company"}), name="invoices") -app.add_typer(make_resource_app("bills", "bill", "bills", has_number=True, filters={"vendor", "company"}), name="bills") +app.add_typer(make_resource_app("invoices", "invoice", "invoices", has_number=True, filters=TXN_CUSTOMER), name="invoices") +app.add_typer(make_resource_app("bills", "bill", "bills", has_number=True, filters=TXN_VENDOR), name="bills") app.add_typer(accounts_app, name="accounts") # Accounts has custom filtering (no status/date filters) # Money-in -app.add_typer(make_resource_app("sales orders", "sales-order", "sales-orders", has_number=True, filters={"customer", "company"}), name="sales-orders") -app.add_typer(make_resource_app("customer payments", "customer-payment", "customer-payments", has_number=True, filters={"customer", "company"}), name="customer-payments") -app.add_typer(make_resource_app("customer credits", "customer-credit", "customer-credits", has_number=True, filters={"customer", "company"}), name="customer-credits") -app.add_typer( - make_resource_app("customer prepayments", "customer-prepayment", "customer-prepayments", has_number=True, filters={"customer", "company"}), name="customer-prepayments" -) +app.add_typer(make_resource_app("sales orders", "sales-order", "sales-orders", has_number=True, filters=TXN_CUSTOMER), name="sales-orders") +app.add_typer(make_resource_app("customer payments", "customer-payment", "customer-payments", has_number=True, filters=TXN_CUSTOMER), name="customer-payments") +app.add_typer(make_resource_app("customer credits", "customer-credit", "customer-credits", has_number=True, filters=TXN_CUSTOMER), name="customer-credits") +app.add_typer(make_resource_app("customer prepayments", "customer-prepayment", "customer-prepayments", has_number=True, filters=TXN_CUSTOMER), name="customer-prepayments") app.add_typer( - make_resource_app("customer prepayment applications", "customer-prepayment-application", "customer-prepayment-applications", has_number=True, filters={"customer", "company"}), + make_resource_app("customer prepayment applications", "customer-prepayment-application", "customer-prepayment-applications", has_number=True, filters=TXN_CUSTOMER), name="customer-prepayment-applications", ) -app.add_typer(make_resource_app("customer deposits", "customer-deposit", "customer-deposits", has_number=True, filters={"customer", "company"}), name="customer-deposits") -app.add_typer(make_resource_app("customer refunds", "customer-refund", "customer-refunds", has_number=True, filters={"customer", "company"}), name="customer-refunds") -app.add_typer(make_resource_app("cash sales", "cash-sale", "cash-sales", has_number=True, filters={"customer", "company"}), name="cash-sales") +app.add_typer(make_resource_app("customer deposits", "customer-deposit", "customer-deposits", has_number=True, filters=TXN_CUSTOMER), name="customer-deposits") +app.add_typer(make_resource_app("customer refunds", "customer-refund", "customer-refunds", has_number=True, filters=TXN_CUSTOMER), name="customer-refunds") +app.add_typer(make_resource_app("cash sales", "cash-sale", "cash-sales", has_number=True, filters=TXN_CUSTOMER), name="cash-sales") # Money-out -app.add_typer(make_resource_app("purchase orders", "purchase-order", "purchase-orders", has_number=True, filters={"vendor", "company"}), name="purchase-orders") -app.add_typer(make_resource_app("vendor payments", "vendor-payment", "vendor-payments", has_number=True, filters={"vendor", "company"}), name="vendor-payments") -app.add_typer(make_resource_app("vendor credits", "vendor-credit", "vendor-credits", has_number=True, filters={"vendor", "company"}), name="vendor-credits") -app.add_typer(make_resource_app("vendor prepayments", "vendor-prepayment", "vendor-prepayments", has_number=True, filters={"vendor", "company"}), name="vendor-prepayments") +app.add_typer(make_resource_app("purchase orders", "purchase-order", "purchase-orders", has_number=True, filters=TXN_VENDOR), name="purchase-orders") +app.add_typer(make_resource_app("vendor payments", "vendor-payment", "vendor-payments", has_number=True, filters=TXN_VENDOR), name="vendor-payments") +app.add_typer(make_resource_app("vendor credits", "vendor-credit", "vendor-credits", has_number=True, filters=TXN_VENDOR), name="vendor-credits") +app.add_typer(make_resource_app("vendor prepayments", "vendor-prepayment", "vendor-prepayments", has_number=True, filters=TXN_VENDOR), name="vendor-prepayments") app.add_typer( - make_resource_app("vendor prepayment applications", "vendor-prepayment-application", "vendor-prepayment-applications", has_number=True, filters={"vendor", "company"}), + make_resource_app("vendor prepayment applications", "vendor-prepayment-application", "vendor-prepayment-applications", has_number=True, filters=TXN_VENDOR), name="vendor-prepayment-applications", ) -app.add_typer(make_resource_app("vendor refunds", "vendor-refund", "vendor-refunds", has_number=True, filters={"vendor", "company"}), name="vendor-refunds") -app.add_typer(make_resource_app("direct expenses", "direct-expense", "direct-expenses", has_number=True, filters={"vendor", "company"}), name="direct-expenses") +app.add_typer(make_resource_app("vendor refunds", "vendor-refund", "vendor-refunds", has_number=True, filters=TXN_VENDOR), name="vendor-refunds") +app.add_typer(make_resource_app("direct expenses", "direct-expense", "direct-expenses", has_number=True, filters=TXN_VENDOR), name="direct-expenses") # Accounting -app.add_typer(make_resource_app("journal entries", "journal-entry", "journal-entries", has_number=True), name="journal-entries") -app.add_typer(make_resource_app("bank transfers", "bank-transfer", "bank-transfers", has_number=True), name="bank-transfers") -app.add_typer(make_resource_app("fixed assets", "fixed-asset", "fixed-assets", has_number=True), name="fixed-assets") -app.add_typer(make_resource_app("depreciation books", "depreciation-book", "depreciation-books"), name="depreciation-books") +app.add_typer(make_resource_app("journal entries", "journal-entry", "journal-entries", has_number=True, filters=TXN_ALL_PARTIES), name="journal-entries") +app.add_typer(make_resource_app("bank transfers", "bank-transfer", "bank-transfers", has_number=True, filters=TXN), name="bank-transfers") +app.add_typer( + make_resource_app("fixed assets", "fixed-asset", "fixed-assets", has_number=True, filters={"search", "status", "company", "customer", "vendor"}), + name="fixed-assets", +) +app.add_typer(make_resource_app("depreciation books", "depreciation-book", "depreciation-books", filters=set()), name="depreciation-books") # Entities -app.add_typer(make_resource_app("customers", "customer", "customers"), name="customers") -app.add_typer(make_resource_app("vendors", "vendor", "vendors"), name="vendors") -app.add_typer(make_resource_app("items", "item", "items"), name="items") -app.add_typer(make_resource_app("companies", "company", "companies"), name="companies") -app.add_typer(make_resource_app("classifications", "classification", "classifications"), name="classifications") +app.add_typer(make_resource_app("customers", "customer", "customers", filters={"search", "status", "company"}), name="customers") +app.add_typer(make_resource_app("vendors", "vendor", "vendors", filters=TXN), name="vendors") +app.add_typer(make_resource_app("items", "item", "items", filters={"search", "status"}), name="items") +app.add_typer(make_resource_app("companies", "company", "companies", filters={"search"}), name="companies") +app.add_typer(make_resource_app("classifications", "classification", "classifications", filters={"search"}), name="classifications") # Recurring recurring_app = typer.Typer(help="Manage recurring records", no_args_is_help=True, cls=HelpfulGroup) -recurring_app.add_typer(make_resource_app("recurring invoices", "recurring-invoice", "recurring/invoices", has_delete=True), name="invoices") -recurring_app.add_typer(make_resource_app("recurring bills", "recurring-bill", "recurring/bills", has_delete=True), name="bills") -recurring_app.add_typer(make_resource_app("recurring journal entries", "recurring-journal-entry", "recurring/journal-entries", has_delete=True), name="journal-entries") +RECURRING_FILTERS = {"search", "company"} +recurring_app.add_typer(make_resource_app("recurring invoices", "recurring-invoice", "recurring/invoices", has_delete=True, filters=RECURRING_FILTERS), name="invoices") +recurring_app.add_typer(make_resource_app("recurring bills", "recurring-bill", "recurring/bills", has_delete=True, filters=RECURRING_FILTERS), name="bills") +recurring_app.add_typer( + make_resource_app("recurring journal entries", "recurring-journal-entry", "recurring/journal-entries", has_delete=True, filters=RECURRING_FILTERS), + name="journal-entries", +) app.add_typer(recurring_app, name="recurring") # Other -app.add_typer(make_resource_app("contracts", "contract", "contracts"), name="contracts") -app.add_typer(make_resource_app("budgets", "budget", "budgets"), name="budgets") -app.add_typer(make_resource_app("workflows", "workflow", "workflows", has_create=False, has_update=False), name="workflows") +# Contracts name their status filter `status`, not `record_status`. +app.add_typer(make_resource_app("contracts", "contract", "contracts", filters=TXN_CUSTOMER, status_param="status"), name="contracts") +app.add_typer(make_resource_app("budgets", "budget", "budgets", filters={"search", "status", "company"}), name="budgets") +app.add_typer( + make_resource_app("workflows", "workflow", "workflows", has_create=False, has_update=False, filters={"search", "company"}), + name="workflows", +) app.add_typer( make_resource_app( "intercompany journal entries", @@ -80,15 +96,15 @@ "intercompany-journal-entries", has_number=True, has_post=True, - filters={"company"}, + filters=TXN, template=IJE_TEMPLATE, checks=IJE_CHECKS, online_checks=IJE_ONLINE_EXTRA_CHECKS, ), name="intercompany-journal-entries", ) -app.add_typer(make_resource_app("paper checks", "paper-check", "paper-checks", has_number=True), name="paper-checks") -app.add_typer(make_resource_app("inbox items", "inbox-item", "inbox", has_create=False, has_update=False), name="inbox") +app.add_typer(make_resource_app("paper checks", "paper-check", "paper-checks", has_number=True, filters=TXN_ALL_PARTIES), name="paper-checks") +app.add_typer(make_resource_app("inbox items", "inbox-item", "inbox", has_create=False, has_update=False, filters={"search"}), name="inbox") def version_callback(value: bool): diff --git a/tests/test_filter_gating.py b/tests/test_filter_gating.py new file mode 100644 index 0000000..d3973c7 --- /dev/null +++ b/tests/test_filter_gating.py @@ -0,0 +1,185 @@ +"""Each resource may only offer the filter flags its /public/v2 list endpoint declares.""" + +import re +from unittest.mock import MagicMock, patch + +import pytest +import typer.main +from typer.testing import CliRunner + +from dualentry_cli.main import app + +runner = CliRunner() + +TXN = {"search", "status", "start-date", "end-date", "company"} +TXN_CUSTOMER = TXN | {"customer"} +TXN_VENDOR = TXN | {"vendor"} +TXN_ALL_PARTIES = TXN | {"customer", "vendor"} + +EXPECTED_FILTERS = { + "invoices": TXN_CUSTOMER, + "bills": TXN_VENDOR, + "sales-orders": TXN_CUSTOMER, + "customer-payments": TXN_CUSTOMER, + "customer-credits": TXN_CUSTOMER, + "customer-prepayments": TXN_CUSTOMER, + "customer-prepayment-applications": TXN_CUSTOMER, + "customer-deposits": TXN_CUSTOMER, + "customer-refunds": TXN_CUSTOMER, + "cash-sales": TXN_CUSTOMER, + "purchase-orders": TXN_VENDOR, + "vendor-payments": TXN_VENDOR, + "vendor-credits": TXN_VENDOR, + "vendor-prepayments": TXN_VENDOR, + "vendor-prepayment-applications": TXN_VENDOR, + "vendor-refunds": TXN_VENDOR, + "direct-expenses": TXN_VENDOR, + "journal-entries": TXN_ALL_PARTIES, + "bank-transfers": TXN, + "fixed-assets": {"search", "status", "company", "customer", "vendor"}, + "depreciation-books": set(), + "customers": {"search", "status", "company"}, + "vendors": TXN, + "items": {"search", "status"}, + "companies": {"search"}, + "classifications": {"search"}, + "recurring/invoices": {"search", "company"}, + "recurring/bills": {"search", "company"}, + "recurring/journal-entries": {"search", "company"}, + "contracts": TXN_CUSTOMER, + "budgets": {"search", "status", "company"}, + "workflows": {"search", "company"}, + "intercompany-journal-entries": TXN, + "paper-checks": TXN_ALL_PARTIES, + "inbox": {"search"}, +} + +FILTER_FLAGS = {"search", "status", "start-date", "end-date", "company", "customer", "vendor"} + + +def _plain(text: str) -> str: + """Strip the ANSI styling rich injects mid-token, so `--start-date` is greppable.""" + return re.sub(r"\x1b\[[0-9;]*m", "", text) + + +@pytest.fixture(autouse=True) +def mock_get_client(): + mock_client = MagicMock() + mock_client.get.return_value = {"items": [], "count": 0} + with patch("dualentry_cli.main.get_client", return_value=mock_client): + yield mock_client + + +def _offered_filters(path: str) -> set[str]: + """Filter flags the `list` command of a resource actually exposes.""" + cmd = typer.main.get_command(app) + for part in path.split("/"): + cmd = cmd.commands[part] + list_cmd = cmd.commands["list"] + return {opt[2:] for p in list_cmd.params for opt in p.opts if opt.startswith("--") and opt[2:] in FILTER_FLAGS} + + +@pytest.mark.parametrize("path", sorted(EXPECTED_FILTERS)) +def test_offers_only_supported_filters(path): + assert _offered_filters(path) == EXPECTED_FILTERS[path] + + +class TestUnsupportedFlagsRejected: + @pytest.mark.parametrize( + ("path", "flag"), + [ + ("customers", "--start-date"), + ("customers", "--end-date"), + ("items", "--start-date"), + ("items", "--end-date"), + ("companies", "--start-date"), + ("companies", "--status"), + ("budgets", "--start-date"), + ("budgets", "--end-date"), + ("depreciation-books", "--search"), + ("classifications", "--status"), + ("workflows", "--end-date"), + ("inbox", "--status"), + ], + ) + def test_exits_non_zero_naming_flag_and_resource(self, path, flag, mock_get_client): + result = runner.invoke(app, [path, "list", flag, "2025-01-01"]) + assert result.exit_code != 0 + output = _plain(result.output) + assert f"does not support {flag}" in output + assert f"dualentry {path.replace('/', ' ')} list" in output + mock_get_client.get.assert_not_called() + + @pytest.mark.parametrize(("path", "alias"), [("companies", "-c"), ("depreciation-books", "-s"), ("items", "-c")]) + def test_short_aliases_are_gated_too(self, path, alias, mock_get_client): + result = runner.invoke(app, [path, "list", alias, "x"]) + assert result.exit_code != 0 + assert f"does not support {alias}" in _plain(result.output) + mock_get_client.get.assert_not_called() + + def test_recurring_rejects_status(self, mock_get_client): + result = runner.invoke(app, ["recurring", "invoices", "list", "--status", "posted"]) + assert result.exit_code != 0 + assert "--status" in _plain(result.output) + mock_get_client.get.assert_not_called() + + +class TestSupportedFiltersStillForwarded: + def test_customers_status_is_sent(self, mock_get_client): + result = runner.invoke(app, ["customers", "list", "--status", "posted"]) + assert result.exit_code == 0 + mock_get_client.get.assert_called_once_with("/customers/", params={"record_status": "posted", "limit": 20, "offset": 0}) + + def test_items_search_is_sent(self, mock_get_client): + result = runner.invoke(app, ["items", "list", "--search", "widget"]) + assert result.exit_code == 0 + mock_get_client.get.assert_called_once_with("/items/", params={"search": "widget", "limit": 20, "offset": 0}) + + def test_invoices_accept_the_full_generic_set(self, mock_get_client): + result = runner.invoke( + app, + ["invoices", "list", "--search", "acme", "--status", "posted", "--start-date", "2025-01-01", "--end-date", "2025-12-31"], + ) + assert result.exit_code == 0 + mock_get_client.get.assert_called_once_with( + "/invoices/", + params={ + "search": "acme", + "record_status": "posted", + "start_date": "2025-01-01", + "end_date": "2025-12-31", + "limit": 20, + "offset": 0, + }, + ) + + def test_customers_company_filter_is_now_offered(self, mock_get_client): + result = runner.invoke(app, ["customers", "list", "--company", "7"]) + assert result.exit_code == 0 + mock_get_client.get.assert_called_once_with("/customers/", params={"company_id": "7", "limit": 20, "offset": 0}) + + +class TestStatusParamName: + def test_contracts_status_maps_to_status_not_record_status(self, mock_get_client): + result = runner.invoke(app, ["contracts", "list", "--status", "active"]) + assert result.exit_code == 0 + mock_get_client.get.assert_called_once_with("/contracts/", params={"status": "active", "limit": 20, "offset": 0}) + + def test_fixed_assets_status_stays_record_status(self, mock_get_client): + result = runner.invoke(app, ["fixed-assets", "list", "--status", "posted"]) + assert result.exit_code == 0 + mock_get_client.get.assert_called_once_with("/fixed-assets/", params={"record_status": "posted", "limit": 20, "offset": 0}) + + +class TestFactoryGuards: + def test_unknown_filter_flag_is_rejected_at_registration(self): + from dualentry_cli.commands import make_resource_app + + with pytest.raises(ValueError, match="unknown filter flags"): + make_resource_app("widgets", "widget", "widgets", filters={"nonsense"}) + + def test_filters_argument_is_required(self): + from dualentry_cli.commands import make_resource_app + + with pytest.raises(TypeError): + make_resource_app("widgets", "widget", "widgets")