From 39c9bbac17a49ba4d8044ee241223ee56ed4b50e Mon Sep 17 00:00:00 2001 From: Andy Woods <22278911+awoods187@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:09:09 -0400 Subject: [PATCH 1/2] Add classifications-lines list command and fix CSV ID filters Add `dualentry classifications-lines list`, a read-only list of classification values across every classification. It filters by --classification, --id, --name, and --active/--inactive, and prints each line with its parent classification. Fix comma-separated ID filters, shared with statistical-journals: - Values were sent to the API as a nested list (`number=[1, 2]`), so multi-value filters never matched. They are now sent flat and the flag can be repeated (`--id 1,2 --id 3`). - An empty value (`--id ""`) is now a usage error instead of silently dropping the filter and listing every row. Fix the `list --all` resume hint for commands with their own filters: it now repeats every filter, spells boolean filters as on/off flags, and shell-quotes values containing spaces. --- CHANGELOG.md | 4 + README.md | 2 +- src/dualentry_cli/commands/__init__.py | 139 ++++++++++++++++-- src/dualentry_cli/commands/actions.py | 27 +++- .../commands/classifications_lines.py | 63 ++++++++ .../commands/statistical_journals.py | 50 ++++--- src/dualentry_cli/main.py | 2 + src/dualentry_cli/output.py | 33 +++++ tests/test_commands.py | 96 ++++++++++++ tests/test_integration.py | 10 ++ tests/test_output.py | 20 +++ tests/test_stale_commands.py | 4 + 12 files changed, 411 insertions(+), 39 deletions(-) create mode 100644 src/dualentry_cli/commands/classifications_lines.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe986b..ac3f517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +- Add `classifications-lines list` to list classification values across all classifications, filterable by `--classification`, `--id`, `--name`, and `--active/--inactive` +- Fix `statistical-journals list` sending comma-separated ID filters (`--number`, `--account-id`, `--line-account-id`) in a form the API could not read +- Comma-separated ID filters now reject an empty value instead of silently listing every row, and can be repeated (`--id 1,2 --id 3`) +- The `list --all` resume hint now repeats every filter of commands with their own filters, and shell-quotes values with spaces - Line-item tables label the quantity column Quantity - Update the CLI greeting logo to DualEntry - Add `export-jobs` commands (`create`, `list`, `get`, `wait`) for on-demand destination data refreshes via the public API diff --git a/README.md b/README.md index 66f75e6..68efffe 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ dualentry export-jobs wait 42 --interval 10 --timeout 1800 | **Receivables** | Invoices, Sales Orders, Customer Payments, Credits, Deposits | | **Payables** | Bills, Purchase Orders, Vendor Payments, Credits, Refunds | | **Accounting** | Journal Entries, Bank Transfers, Fixed Assets, Depreciation | -| **Master Data** | Customers, Vendors, Items, Accounts, Classifications | +| **Master Data** | Customers, Vendors, Items, Accounts, Classifications, Classification Lines | | **Automation** | Recurring Invoices, Recurring Bills, Workflows, Contracts | | **Close Management** | Bank Match | | **Bank Feeds** | Bank Connections | diff --git a/src/dualentry_cli/commands/__init__.py b/src/dualentry_cli/commands/__init__.py index 69c3ad5..8dcc68b 100644 --- a/src/dualentry_cli/commands/__init__.py +++ b/src/dualentry_cli/commands/__init__.py @@ -3,8 +3,10 @@ from __future__ import annotations import json -from collections.abc import Callable +import shlex +from collections.abc import Callable, Mapping, Sequence from pathlib import Path +from typing import TYPE_CHECKING import typer @@ -12,6 +14,9 @@ from dualentry_cli.client import _MAX_PAGES from dualentry_cli.output import _RECORD_PREFIX, format_output +if TYPE_CHECKING: + from typer.models import OptionInfo + # Filter flags `list` can offer, with every option spelling each one accepts. FILTER_OPTIONS = { "search": ("--search", "-s"), @@ -69,6 +74,68 @@ def _supplied(value) -> str | None: return value if isinstance(value, str) else None +def _parse_csv_ints(value: str) -> list[int]: + """ + Parse one occurrence of a comma-separated integer filter, e.g. `3,4`. + + Args: + value: The raw option value as typed on the command line. + + Returns: + The integers in the order given. + + Raises: + typer.BadParameter: If the value holds no integers, or any token is not one. + An empty value is rejected, not ignored: dropping the filter would + widen the query to every row. + + """ + tokens = [token.strip() for token in value.split(",") if token.strip()] + if not tokens: + raise typer.BadParameter("expected comma-separated integers, got an empty value") + ints: list[int] = [] + for token in tokens: + try: + ints.append(int(token)) + except ValueError as e: + raise typer.BadParameter(f"expected comma-separated integers, got {token!r}") from e + return ints + + +def _flatten_csv_ints(chunks: Sequence[list[int]] | None) -> list[int] | None: + """ + Merge every occurrence of a repeatable CSV option into one flat list. + + Args: + chunks: One parsed list per occurrence of the flag, or None when it was not passed. + + Returns: + The merged integers, or None when the flag was not passed, so the filter is omitted. + + """ + merged = [n for chunk in chunks or () for n in chunk] + return merged or None + + +def csv_ints_option(flag: str, *, help_text: str) -> OptionInfo: + """ + Build a `--flag 1,2,3` option that typer hands to the command as `list[int] | None`. + + A `list[int]` annotation makes typer treat the option as repeatable and run + `parser` once per occurrence, which yields `[[1, 2, 3]]`. The callback flattens + that, so `--flag 1,2 --flag 3` also works. + + Args: + flag: The option spelling, e.g. `"--id"`. + help_text: The `--help` description. + + Returns: + The option, for use inside `Annotated[list[int] | None, ...]`. + + """ + return typer.Option(flag, parser=_parse_csv_ints, callback=_flatten_csv_ints, metavar="", help=help_text) + + def _build_filter_params( search: str | None = None, status: str | None = None, @@ -91,8 +158,14 @@ def _build_filter_params( return params -# Map _do_list filter kwargs to CLI flags for the --all resume hint. -_FILTER_CLI_FLAGS = { +# How a filter kwarg is spelled in the --all resume hint: a value-taking flag, or an +# (on, off) pair for a `--x/--no-x` boolean. +ResumeFlag = str | tuple[str, str] + +# Map _do_list filter kwargs to CLI flags for the --all resume hint. Commands with +# their own filters pass a mapping of their own; a filter left out of it would be +# missing from the hint, and the "same filters" command would return other rows. +_FILTER_CLI_FLAGS: dict[str, ResumeFlag] = { "search": "--search", "status": "--status", "start_date": "--start-date", @@ -103,19 +176,51 @@ def _build_filter_params( } -def _resume_all_command(path: str, next_offset: int, filters: dict) -> str: - """Build a copy-paste dualentry list --all command that continues from next_offset.""" +def _resume_all_command( + path: str, + next_offset: int, + filters: Mapping[str, object], + flags: Mapping[str, ResumeFlag] = _FILTER_CLI_FLAGS, +) -> str: + """ + Build a copy-paste dualentry list --all command that continues from next_offset. + + Args: + path: The API path, whose segments are also the CLI command path. + next_offset: Where the next run should start. + filters: The filter kwargs the truncated run used. + flags: How each filter kwarg is spelled on the command line. + + Returns: + A shell-quoted command line. + + """ parts = ["dualentry", *path.split("/"), "list", "--all", "--offset", str(next_offset)] - for key, flag in _FILTER_CLI_FLAGS.items(): + for key, flag in flags.items(): value = filters.get(key) - if value is not None: + if value is None: + continue + if isinstance(flag, tuple): + on, off = flag + parts.append(on if value else off) + elif isinstance(value, list): + parts.extend([flag, ",".join(map(str, value))]) + else: parts.extend([flag, str(value)]) - return " ".join(parts) + return shlex.join(parts) -def _warn_all_truncated(path: str, *, fetched_through: int, total: int, next_offset: int, filters: dict) -> None: +def _warn_all_truncated( + path: str, + *, + fetched_through: int, + total: int, + next_offset: int, + filters: Mapping[str, object], + flags: Mapping[str, ResumeFlag] = _FILTER_CLI_FLAGS, +) -> None: """Tell the user --all stopped early and how to continue.""" - cmd = _resume_all_command(path, next_offset, filters) + cmd = _resume_all_command(path, next_offset, filters, flags) typer.secho( f"Warning: reached {fetched_through} of {total} items; stopped at the {_MAX_PAGES}-page limit.\nTo continue, re-run with the same filters:\n {cmd}", fg=typer.colors.YELLOW, @@ -123,7 +228,18 @@ def _warn_all_truncated(path: str, *, fetched_through: int, total: int, next_off ) -def _do_list(client, path: str, resource: str, *, limit: int, offset: int, all_pages: bool, output: str, **filters): +def _do_list( + client, + path: str, + resource: str, + *, + limit: int, + offset: int, + all_pages: bool, + output: str, + resume_flags: Mapping[str, ResumeFlag] = _FILTER_CLI_FLAGS, + **filters, +) -> None: """Shared list logic for all resources.""" params = _build_filter_params(**filters) next_offset = None @@ -142,6 +258,7 @@ def _do_list(client, path: str, resource: str, *, limit: int, offset: int, all_p total=data.get("count", next_offset), next_offset=next_offset, filters=filters, + flags=resume_flags, ) diff --git a/src/dualentry_cli/commands/actions.py b/src/dualentry_cli/commands/actions.py index 1601be0..dfcdbc6 100644 --- a/src/dualentry_cli/commands/actions.py +++ b/src/dualentry_cli/commands/actions.py @@ -2,12 +2,13 @@ from __future__ import annotations +from collections.abc import Mapping from pathlib import Path import typer from dualentry_cli.cli import HelpfulGroup -from dualentry_cli.commands import Format, _do_list, _load_json_file +from dualentry_cli.commands import _FILTER_CLI_FLAGS, Format, ResumeFlag, _do_list, _load_json_file from dualentry_cli.output import format_output @@ -22,10 +23,30 @@ def run_get(path: str, *, resource: str, output: str, params: dict | None = None format_output(data, resource=resource, fmt=output) -def run_list(path: str, *, resource: str, limit: int, offset: int, all_pages: bool, output: str, **filters) -> None: +def run_list( + path: str, + *, + resource: str, + limit: int, + offset: int, + all_pages: bool, + output: str, + resume_flags: Mapping[str, ResumeFlag] = _FILTER_CLI_FLAGS, + **filters, +) -> None: from dualentry_cli.main import get_client - _do_list(get_client(), path.strip("/"), resource, limit=limit, offset=offset, all_pages=all_pages, output=output, **filters) + _do_list( + get_client(), + path.strip("/"), + resource, + limit=limit, + offset=offset, + all_pages=all_pages, + output=output, + resume_flags=resume_flags, + **filters, + ) def run_post(path: str, *, resource: str, output: str, body: dict | None = None) -> None: diff --git a/src/dualentry_cli/commands/classifications_lines.py b/src/dualentry_cli/commands/classifications_lines.py new file mode 100644 index 0000000..f00515a --- /dev/null +++ b/src/dualentry_cli/commands/classifications_lines.py @@ -0,0 +1,63 @@ +""" +Classification line commands (read-only list across every classification). + +`/classifications-lines/` has no detail, create, or update route, so `list` is the +only command. It gets its own module, not the resource factory, because its filters +(IDs, a name match, an active flag) don't fit the factory's shared flag set. +""" + +from __future__ import annotations + +from typing import Annotated + +import typer + +from dualentry_cli.cli import HelpfulGroup +from dualentry_cli.commands import AllPages, Format, Limit, Offset, ResumeFlag, Search, csv_ints_option +from dualentry_cli.commands.actions import run_list + +app = typer.Typer(help="List classification lines (the values each classification offers)", no_args_is_help=True, cls=HelpfulGroup) + +# Every list filter, so the --all resume hint repeats all of them. +_RESUME_FLAGS: dict[str, ResumeFlag] = { + "search": "--search", + "classification_id": "--classification", + "id": "--id", + "name": "--name", + "is_active": ("--active", "--inactive"), +} + + +@app.command("list") +def list_cmd( + limit: int = Limit, + offset: int = Offset, + all_pages: bool = AllPages, + search: str | None = Search, + classification: Annotated[ + list[int] | None, + csv_ints_option("--classification", help_text="Filter by classification ID(s), comma-separated"), + ] = None, + line_id: Annotated[ + list[int] | None, + csv_ints_option("--id", help_text="Filter by classification line ID(s), comma-separated"), + ] = None, + name: str | None = typer.Option(None, "--name", help="Filter by line name (case-insensitive substring)"), + active: bool | None = typer.Option(None, "--active/--inactive", help="Only active or only inactive lines [default: both]"), + output: str = Format, +) -> None: + """List classification lines across all classifications.""" + run_list( + "classifications-lines", + resource="classification-line", + resume_flags=_RESUME_FLAGS, + limit=limit, + offset=offset, + all_pages=all_pages, + output=output, + search=search, + classification_id=classification, + id=line_id, + name=name or None, # `extra` filters are only dropped when None, so "" must not reach the API + is_active=active, + ) diff --git a/src/dualentry_cli/commands/statistical_journals.py b/src/dualentry_cli/commands/statistical_journals.py index 664c4d0..2818440 100644 --- a/src/dualentry_cli/commands/statistical_journals.py +++ b/src/dualentry_cli/commands/statistical_journals.py @@ -13,10 +13,12 @@ Format, Limit, Offset, + ResumeFlag, Search, Status, _load_json_file, _strip_record_prefix, + csv_ints_option, ) from dualentry_cli.commands.actions import run_list from dualentry_cli.output import format_output @@ -24,33 +26,32 @@ app = typer.Typer(help="Manage statistical journals", no_args_is_help=True, cls=HelpfulGroup) -def _parse_csv_ints(value: str) -> list[int] | None: - """Parse a comma-separated list of integers for list filters.""" - raw = value.strip() - if not raw: - return None - result: list[int] = [] - for part in raw.split(","): - token = part.strip() - if not token: - continue - try: - result.append(int(token)) - except ValueError: - raise typer.BadParameter(f"expected comma-separated integers, got {token!r}") from None - return result or None - - -def _csv_ints_option(flag: str, *, help: str) -> typer.Option: - return typer.Option(flag, parser=_parse_csv_ints, help=help) - - def _csv_strings(value: str | None) -> list[str] | None: if not value: return None return [part.strip() for part in value.split(",") if part.strip()] +# Every list filter, so the --all resume hint repeats all of them. +_RESUME_FLAGS: dict[str, ResumeFlag] = { + "search": "--search", + "status": "--status", + "company_id": "--company", + "date_start": "--date-start", + "date_end": "--date-end", + "period_start": "--period-start", + "period_end": "--period-end", + "number": "--number", + "account_id": "--account-id", + "account_number": "--account-number", + "line_account_id": "--line-account-id", + "line_account_number": "--line-account-number", + "updated_after": "--updated-after", + "updated_before": "--updated-before", + "ordering": "--ordering", +} + + @app.command("list") def list_cmd( limit: int = Limit, @@ -65,16 +66,16 @@ def list_cmd( period_end: str | None = typer.Option(None, "--period-end", help="Filter to period end (YYYY-MM-DD)"), number: Annotated[ list[int] | None, - _csv_ints_option("--number", help="Filter by record number(s), comma-separated"), + csv_ints_option("--number", help_text="Filter by record number(s), comma-separated"), ] = None, account_id: Annotated[ list[int] | None, - _csv_ints_option("--account-id", help="Filter by account ID(s), comma-separated"), + csv_ints_option("--account-id", help_text="Filter by account ID(s), comma-separated"), ] = None, account_number: str | None = typer.Option(None, "--account-number", help="Filter by account number(s), comma-separated"), line_account_id: Annotated[ list[int] | None, - _csv_ints_option("--line-account-id", help="Filter by line account ID(s), comma-separated"), + csv_ints_option("--line-account-id", help_text="Filter by line account ID(s), comma-separated"), ] = None, line_account_number: str | None = typer.Option(None, "--line-account-number", help="Filter by line account number(s), comma-separated"), updated_after: str | None = typer.Option(None, "--updated-after", help="Updated after timestamp (ISO 8601)"), @@ -86,6 +87,7 @@ def list_cmd( run_list( "statistical-journals", resource="statistical-journal", + resume_flags=_RESUME_FLAGS, limit=limit, offset=offset, all_pages=all_pages, diff --git a/src/dualentry_cli/main.py b/src/dualentry_cli/main.py index 66f0cb9..ecba507 100644 --- a/src/dualentry_cli/main.py +++ b/src/dualentry_cli/main.py @@ -10,6 +10,7 @@ from dualentry_cli.commands.accounts import app as accounts_app from dualentry_cli.commands.bank_connections import app as bank_connections_app from dualentry_cli.commands.bank_match import app as bank_match_app +from dualentry_cli.commands.classifications_lines import app as classifications_lines_app from dualentry_cli.commands.export_jobs import app as export_jobs_app from dualentry_cli.commands.ije_extras import IJE_CHECKS, IJE_ONLINE_EXTRA_CHECKS, IJE_TEMPLATE from dualentry_cli.commands.statistical_journals import app as statistical_journals_app @@ -74,6 +75,7 @@ app.add_typer(make_resource_app("items", "item", "items", filters={"search", "status"}), name="items") app.add_typer(make_resource_app("companies", "company", "companies", has_create=False, has_update=False, filters={"search"}), name="companies") app.add_typer(make_resource_app("classifications", "classification", "classifications", filters={"search"}), name="classifications") +app.add_typer(classifications_lines_app, name="classifications-lines") # List-only, with line-specific filters app.add_typer(make_resource_app("custom fields", "custom-field", "custom-fields", has_delete=True, filters={"search"}), name="custom-fields") app.add_typer(make_resource_app("product tax codes", "product-tax-code", "product-tax-codes", has_delete=True, filters={"search"}), name="product-tax-codes") app.add_typer( diff --git a/src/dualentry_cli/output.py b/src/dualentry_cli/output.py index 7162c53..da88800 100644 --- a/src/dualentry_cli/output.py +++ b/src/dualentry_cli/output.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from typing import TypedDict from rich.console import Console from rich.panel import Panel @@ -1128,6 +1129,38 @@ def _classification_detail(r): _register("classification", _classification_list, _classification_detail) +class ClassificationLine(TypedDict): + """One row of `GET /classifications-lines/` (the public v2 line schema).""" + + id: int + name: str + is_active: bool + classification_id: int + classification_name: str + + +def _classification_line_list(items: list[ClassificationLine]) -> None: + """Render lines with their parent classification, since the list spans all of them.""" + table = Table(title="Classification Lines", show_lines=False) + table.add_column("ID", style="dim", justify="right") + table.add_column("Name", min_width=24, style="bold") + table.add_column("Classification", min_width=16) + table.add_column("Active") + + for r in items: + table.add_row( + str(r["id"]), + r["name"], + r["classification_name"], + "yes" if r["is_active"] else "no", + ) + + console.print(table) + + +_register("classification-line", _classification_line_list, None) + + # ── Depreciation Book ──────────────────────────────────────────────── diff --git a/tests/test_commands.py b/tests/test_commands.py index df51c56..22dc0d6 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -245,6 +245,15 @@ def test_statistical_journal_add_attachments(self, mock_get_client, tmp_path): assert kwargs["files"][0][0] == "files" assert kwargs["files"][0][1][0] == "note.txt" + def test_statistical_journal_csv_ids_are_sent_as_a_flat_list(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke(app, ["statistical-journals", "list", "--number", "1,2", "--number", "3", "--account-id", "5"]) + assert result.exit_code == 0 + mock_get_client.get.assert_called_once_with( + "/statistical-journals/", + params={"number": [1, 2, 3], "account_id": [5], "limit": 20, "offset": 0}, + ) + def test_statistical_journal_list_rejects_non_integer_csv(self): result = runner.invoke(app, ["statistical-journals", "list", "--number", "1,abc"]) assert result.exit_code == 2 @@ -252,6 +261,74 @@ def test_statistical_journal_list_rejects_non_integer_csv(self): assert "abc" in result.output +class TestClassificationsLinesCommands: + def test_list_with_no_filters(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke(app, ["classifications-lines", "list"]) + assert result.exit_code == 0 + mock_get_client.get.assert_called_once_with("/classifications-lines/", params={"limit": 20, "offset": 0}) + + def test_every_filter_maps_to_its_api_name(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke( + app, + ["classifications-lines", "list", "--search", "east", "--classification", "3,4", "--id", "7", "--name", "Eas", "--active"], + ) + assert result.exit_code == 0 + mock_get_client.get.assert_called_once_with( + "/classifications-lines/", + params={ + "search": "east", + "classification_id": [3, 4], + "id": [7], + "name": "Eas", + "is_active": True, + "limit": 20, + "offset": 0, + }, + ) + + def test_inactive_sends_false_rather_than_dropping_the_filter(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke(app, ["classifications-lines", "list", "--inactive"]) + assert result.exit_code == 0 + mock_get_client.get.assert_called_once_with("/classifications-lines/", params={"is_active": False, "limit": 20, "offset": 0}) + + @pytest.mark.parametrize("flag", ["--classification", "--id"]) + @pytest.mark.parametrize("value", ["3,abc", "", ",", " , "]) + def test_id_filters_reject_bad_or_empty_input_before_calling_the_api(self, flag, value, mock_get_client): + result = runner.invoke(app, ["classifications-lines", "list", flag, value]) + assert result.exit_code == 2 + assert "comma-separated integers" in result.output + mock_get_client.get.assert_not_called() + + def test_repeated_id_flags_merge(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke(app, ["classifications-lines", "list", "--id", "1,2", "--id", "3"]) + assert result.exit_code == 0 + mock_get_client.get.assert_called_once_with("/classifications-lines/", params={"id": [1, 2, 3], "limit": 20, "offset": 0}) + + def test_empty_name_is_not_sent(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke(app, ["classifications-lines", "list", "--name", ""]) + assert result.exit_code == 0 + mock_get_client.get.assert_called_once_with("/classifications-lines/", params={"limit": 20, "offset": 0}) + + def test_all_truncation_hint_repeats_every_filter(self, mock_get_client): + mock_get_client.paginate.return_value = { + "items": [{"id": 1, "name": "East", "is_active": False, "classification_id": 3, "classification_name": "Region"}], + "count": 250000, + "next_offset": 100000, + } + result = runner.invoke( + app, + ["classifications-lines", "list", "--all", "--search", "north east", "--classification", "3,4", "--id", "7", "--name", "Eas", "--inactive"], + ) + assert result.exit_code == 0 + hint = result.output.splitlines()[-1].strip() + assert hint == ("dualentry classifications-lines list --all --offset 100000 --search 'north east' --classification 3,4 --id 7 --name Eas --inactive") + + class TestUnknownCommandSuggestions: """HelpfulGroup must catch typer's UsageError, not click's (see typer >= 0.26).""" @@ -310,6 +387,25 @@ def test_resume_command_helper_for_nested_path(self): cmd = _resume_all_command("recurring/invoices", 200, {"status": "posted"}) assert cmd == "dualentry recurring invoices list --all --offset 200 --status posted" + @pytest.mark.parametrize(("value", "expected"), [(True, "--active"), (False, "--inactive")]) + def test_resume_command_spells_boolean_filters_as_on_off_flags(self, value, expected): + from dualentry_cli.commands import _resume_all_command + + cmd = _resume_all_command("classifications-lines", 10, {"is_active": value}, {"is_active": ("--active", "--inactive")}) + assert cmd == f"dualentry classifications-lines list --all --offset 10 {expected}" + + def test_resume_command_quotes_values_with_spaces(self): + from dualentry_cli.commands import _resume_all_command + + cmd = _resume_all_command("invoices", 10, {"search": "acme corp"}) + assert cmd == "dualentry invoices list --all --offset 10 --search 'acme corp'" + + def test_statistical_journals_hint_keeps_their_own_filters(self, mock_get_client): + mock_get_client.paginate.return_value = {"items": [], "count": 250000, "next_offset": 100000} + result = runner.invoke(app, ["statistical-journals", "list", "--all", "--number", "1,2", "--date-start", "2025-01-01"]) + assert result.exit_code == 0 + assert "--date-start 2025-01-01 --number 1,2" in result.output + def test_complete_all_does_not_warn(self, mock_get_client): mock_get_client.paginate.return_value = { "items": [{"internal_id": 1, "number": 1}], diff --git a/tests/test_integration.py b/tests/test_integration.py index a829cf7..4a422a3 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -149,6 +149,16 @@ def test_id_resource_get(client: DualEntryClient, path: str, id_field: str, deta assert_record(detail, detail_keys) +# ── Classification lines (list-only) ──────────────────────────────── + + +def test_classifications_lines_list(client: DualEntryClient): + data = client.get("/classifications-lines/", params={"limit": 2}) + assert_list(data) + if data["items"]: + assert {"id", "name", "classification_id", "classification_name"} <= data["items"][0].keys() + + # ── Accounts (uses account number in URL) ─────────────────────────── diff --git a/tests/test_output.py b/tests/test_output.py index c53a5f9..4f9dac4 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -120,3 +120,23 @@ def test_json_keeps_flags_and_skips_warning(self, capsys): assert payload["lines_count"] == 250 assert "Warning:" not in captured.out assert captured.err == "" + + +class TestClassificationLineList: + def test_shows_parent_classification_and_active_flag(self, capsys): + from dualentry_cli.output import format_output + + data = { + "items": [ + {"id": 7, "name": "East", "is_active": True, "classification_id": 3, "classification_name": "Region"}, + {"id": 8, "name": "Legacy", "is_active": False, "classification_id": 3, "classification_name": "Region"}, + ], + "count": 2, + } + format_output(data, resource="classification-line") + out = capsys.readouterr().out + assert "Classification Lines" in out + assert "East" in out + assert "Region" in out + assert "yes" in out + assert "no" in out diff --git a/tests/test_stale_commands.py b/tests/test_stale_commands.py index 9493fa0..8435d42 100644 --- a/tests/test_stale_commands.py +++ b/tests/test_stale_commands.py @@ -40,6 +40,10 @@ def test_inbox_keeps_only_list(): assert _commands("inbox") == {"list"} +def test_classifications_lines_keeps_only_list(): + assert _commands("classifications-lines") == {"list"} + + def test_paper_checks_has_no_number_lookups(): assert not _commands("paper-checks") & {"get-number", "get-id"} From ac068df309fa2ad9868ca73b35c75efe5f490033 Mon Sep 17 00:00:00 2001 From: Andy Woods <22278911+awoods187@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:10:28 -0400 Subject: [PATCH 2/2] Render statistical journal account numbers sent as integers The API returns account_number as an int, which rich refuses to render, so statistical-journals list crashed on any real row. The existing tests only ever returned empty lists. --- src/dualentry_cli/output.py | 2 +- tests/test_commands.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/dualentry_cli/output.py b/src/dualentry_cli/output.py index da88800..ed0d718 100644 --- a/src/dualentry_cli/output.py +++ b/src/dualentry_cli/output.py @@ -1370,7 +1370,7 @@ def _statistical_journal_list(items): _fmt_id(r.get("number"), "statistical-journal"), str(r.get("date", "-")), str(r.get("period", "-")), - r.get("account_number", "-"), + str(r.get("account_number") or "-"), _status_badge(r.get("record_status", "")), (r.get("memo") or "-")[:40], ) diff --git a/tests/test_commands.py b/tests/test_commands.py index 22dc0d6..c29181e 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -260,6 +260,25 @@ def test_statistical_journal_list_rejects_non_integer_csv(self): assert "comma-separated integers" in result.output assert "abc" in result.output + def test_statistical_journal_list_renders_an_integer_account_number(self, mock_get_client): + # The API sends account_number as an int; rich refuses to render a bare int. + mock_get_client.get.return_value = { + "items": [ + { + "number": 1, + "date": "2026-08-12", + "period": "2026-08-01", + "account_number": 90100, + "record_status": "posted", + "memo": "", + }, + ], + "count": 1, + } + result = runner.invoke(app, ["statistical-journals", "list"]) + assert result.exit_code == 0, result.output + assert "90100" in result.output + class TestClassificationsLinesCommands: def test_list_with_no_filters(self, mock_get_client):