diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe986b..aad2910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +- Add `webhooks` commands (`topics`, `list`, `get`, `create`, `update`, `delete`, `deliveries`) to manage webhooks and read their delivery history. `create` shows the signing secret once, and warns when a webhook has no topics +- 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..e17a46f 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,20 @@ dualentry export-jobs create --format json dualentry export-jobs wait 42 --interval 10 --timeout 1800 ``` +### Send events to your endpoint with a webhook + +```bash +# See what you can subscribe to +dualentry webhooks topics + +# Register an endpoint; save the signing secret it prints, it is shown only once +dualentry webhooks create --url https://hooks.example.com/dualentry -t invoice.created -t bill.posted + +# Check what was delivered, and re-enable a webhook that was auto-disabled after failures +dualentry webhooks deliveries 3f2b8c1e-9a4d-4e6f-8b7a-1c2d3e4f5a6b +dualentry webhooks update 3f2b8c1e-9a4d-4e6f-8b7a-1c2d3e4f5a6b --active +``` + ## Available Resources | Category | Resources | @@ -89,11 +103,11 @@ 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 | -| **Developers** | Export Jobs (destination data refresh) | +| **Developers** | Export Jobs (destination data refresh), Webhooks | Most resources support `list`, `get`, `create`, and `update`; some (e.g. Bank Connections, Bank Match) expose a different verb set — see `--help`. Export jobs support `create`, `list`, `get`, and `wait` (poll until finished). diff --git a/src/dualentry_cli/commands/__init__.py b/src/dualentry_cli/commands/__init__.py index 69c3ad5..e5264c6 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,65 @@ 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: Mapping[str, object], + flags: Mapping[str, ResumeFlag] = _FILTER_CLI_FLAGS, + resume: str | None = None, +) -> None: + """ + Tell the user --all stopped early and how to continue. + + Args: + path: The API path, used to rebuild the command when `resume` is not given. + fetched_through: How many items the run reached. + total: The API's total count. + 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. + resume: A ready-made resume command, for commands whose CLI shape is not + ` list` (e.g. `webhooks deliveries `). -def _warn_all_truncated(path: str, *, fetched_through: int, total: int, next_offset: int, filters: dict) -> None: - """Tell the user --all stopped early and how to continue.""" - cmd = _resume_all_command(path, next_offset, filters) + """ + cmd = resume or _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 +242,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 +272,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/commands/webhooks.py b/src/dualentry_cli/commands/webhooks.py new file mode 100644 index 0000000..dfc4dc4 --- /dev/null +++ b/src/dualentry_cli/commands/webhooks.py @@ -0,0 +1,196 @@ +""" +Webhook commands: register endpoints, choose topics, and read delivery history. + +Webhooks are addressed by UUID, not by a record number, and `/topics/` returns a +bare list rather than a page, so these commands are hand-written instead of +coming from the resource factory. +""" + +from __future__ import annotations + +import json +import shlex +from collections.abc import Sequence +from typing import Any, cast +from uuid import UUID + +import typer +from typer._click.exceptions import UsageError + +from dualentry_cli.cli import HelpfulGroup +from dualentry_cli.commands import AllPages, Format, Limit, Offset, ResumeFlag, Search, _warn_all_truncated +from dualentry_cli.commands.actions import run_get, run_list +from dualentry_cli.output import WebhookTopic, format_output + +app = typer.Typer(help="Manage webhooks that send DualEntry events to your endpoint", no_args_is_help=True, cls=HelpfulGroup) + +WebhookId = typer.Argument(help="Webhook UUID, from `dualentry webhooks list`") +Topics = typer.Option( + None, + "--topic", + "-t", + help="Topic to subscribe to; repeat for several. See `dualentry webhooks topics`.", +) + +# Every `list` filter, so the --all resume hint repeats all of them. +_LIST_RESUME_FLAGS: dict[str, ResumeFlag] = { + "search": "--search", + "is_active": ("--active", "--inactive"), +} + + +def _warn_if_no_topics(topics: Sequence[str]) -> None: + """Deliveries only go to webhooks subscribed to the event's topic, so none means silence.""" + if not topics: + typer.secho( + "Warning: this webhook has no topics, so it will receive no events. Add some with `dualentry webhooks update --topic`.", + fg=typer.colors.YELLOW, + err=True, + ) + + +@app.command("topics") +def topics_cmd(output: str = Format) -> None: + """List the topics a webhook can subscribe to, one per line.""" + from dualentry_cli.main import get_client + + # A bare list, not a page, though get() is annotated for the usual dict. + names = cast("list[WebhookTopic]", get_client().get("/webhooks/topics/")) + if output == "json": + # format_output is typed for dicts; print the API's list as-is instead. + print(json.dumps(names, indent=2)) + return + for topic in names: + typer.echo(topic["name"]) + + +@app.command("list") +def list_cmd( + limit: int = Limit, + offset: int = Offset, + all_pages: bool = AllPages, + search: str | None = Search, + active: bool | None = typer.Option(None, "--active/--inactive", help="Only active or only inactive webhooks [default: both]"), + output: str = Format, +) -> None: + """List webhooks, newest first.""" + run_list( + "webhooks", + resource="webhook", + limit=limit, + offset=offset, + all_pages=all_pages, + output=output, + resume_flags=_LIST_RESUME_FLAGS, + search=search, + is_active=active, + ) + + +@app.command("get") +def get_cmd(webhook_id: UUID = WebhookId, output: str = Format) -> None: + """Get one webhook, including whether it was auto-disabled and why.""" + run_get(f"/webhooks/{webhook_id}/", resource="webhook", output=output) + + +@app.command("create") +def create_cmd( + url: str = typer.Option(..., "--url", help="Public http(s) URL that will receive the events; use https in production"), + topics: list[str] | None = Topics, + output: str = Format, +) -> None: + """ + Register a webhook endpoint. + + The response includes the signing secret. It is shown once and never again. + """ + from dualentry_cli.main import get_client + + chosen = topics or [] + data = get_client().post("/webhooks/", json={"url": url, "topics": chosen}) + format_output(data, resource="webhook", fmt=output) + _warn_if_no_topics(chosen) + + +@app.command("update") +def update_cmd( + webhook_id: UUID = WebhookId, + url: str | None = typer.Option(None, "--url", help="New endpoint URL"), + topics: list[str] | None = Topics, + clear_topics: bool = typer.Option(False, "--clear-topics", help="Unsubscribe from every topic"), + active: bool | None = typer.Option( + None, + "--active/--inactive", + help="Turn delivery on or off. --active also clears an auto-disable after repeated failures.", + ), + output: str = Format, +) -> None: + """ + Change a webhook. Only the options you pass are changed. + + --topic replaces the whole subscription list; it does not add to it. + """ + from dualentry_cli.main import get_client + + if topics and clear_topics: + raise typer.BadParameter("pass --topic or --clear-topics, not both", param_hint="'--clear-topics'") + + body: dict[str, Any] = {} + if url is not None: + body["url"] = url + if topics: + body["topics"] = topics + elif clear_topics: + body["topics"] = [] + if active is not None: + body["is_active"] = active + if not body: + raise UsageError("nothing to change; pass --url, --topic, --clear-topics, --active, or --inactive") + + # PATCH: the API's PUT takes the same optional fields, so it adds nothing here. + data = get_client().patch(f"/webhooks/{webhook_id}/", json=body) + format_output(data, resource="webhook", fmt=output) + if "topics" in body: + _warn_if_no_topics(body["topics"]) + + +@app.command("delete") +def delete_cmd(webhook_id: UUID = WebhookId) -> None: + """Delete a webhook. Its delivery history, including pending deliveries, is deleted too.""" + from dualentry_cli.main import get_client + + get_client().delete(f"/webhooks/{webhook_id}/") + typer.echo(f"Webhook {webhook_id} deleted, with its delivery history.") + + +@app.command("deliveries") +def deliveries_cmd( + webhook_id: UUID = WebhookId, + limit: int = Limit, + offset: int = Offset, + all_pages: bool = AllPages, + output: str = Format, +) -> None: + """List a webhook's delivery attempts, newest first, with status and last error.""" + from dualentry_cli.main import get_client + + path = f"/webhooks/{webhook_id}/deliveries/" + client = get_client() + if not all_pages: + format_output(client.get(path, params={"limit": limit, "offset": offset}), resource="webhook-delivery", fmt=output) + return + + data = client.paginate(path, start_offset=offset) + next_offset = data.pop("next_offset", None) + format_output(data, resource="webhook-delivery", fmt=output) + if next_offset is not None: + # The shared hint rebuilds the command from the API path, which would give + # `webhooks deliveries list`; this command's shape needs its own. + _warn_all_truncated( + path, + fetched_through=next_offset, + total=data["count"], + next_offset=next_offset, + filters={}, + resume=shlex.join(["dualentry", "webhooks", "deliveries", str(webhook_id), "--all", "--offset", str(next_offset)]), + ) diff --git a/src/dualentry_cli/main.py b/src/dualentry_cli/main.py index 66f0cb9..57fc7d7 100644 --- a/src/dualentry_cli/main.py +++ b/src/dualentry_cli/main.py @@ -10,9 +10,11 @@ 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 +from dualentry_cli.commands.webhooks import app as webhooks_app from dualentry_cli.config import Config app = typer.Typer(name="dualentry", help="DualEntry accounting CLI", no_args_is_help=True, cls=HelpfulGroup) @@ -74,6 +76,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( @@ -119,6 +122,7 @@ app.add_typer(bank_match_app, name="bank-match") app.add_typer(bank_connections_app, name="bank-connections") app.add_typer(export_jobs_app, name="export-jobs") +app.add_typer(webhooks_app, name="webhooks") def version_callback(value: bool): diff --git a/src/dualentry_cli/output.py b/src/dualentry_cli/output.py index 7162c53..7fb9568 100644 --- a/src/dualentry_cli/output.py +++ b/src/dualentry_cli/output.py @@ -3,6 +3,8 @@ from __future__ import annotations import json +from datetime import datetime +from typing import NotRequired, TypedDict from rich.console import Console from rich.panel import Panel @@ -1128,6 +1130,163 @@ 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) + + +# ── Webhook ────────────────────────────────────────────────────────── + + +class Webhook(TypedDict): + """One webhook as `/webhooks/` returns it. `secret` is only present on create.""" + + uuid: str + url: str + topics: list[str] + is_active: bool + disabled_at: str | None + disabled_reason: str + created_at: str + last_modified_at: str + secret: NotRequired[str] + + +class WebhookTopic(TypedDict): + """One entry from `/webhooks/topics/`.""" + + name: str + + +class WebhookDelivery(TypedDict): + """One delivery attempt from `/webhooks/{uuid}/deliveries/`.""" + + event_id: str + topic: str + status: str + attempts: int + response_status: int | None + last_error: str + created_at: str + delivered_at: str | None + + +def _webhook_state(r: Webhook, *, verbose: bool = False) -> str: + """ + Active, inactive, or auto-disabled. + + The verbose form adds when and why it was auto-disabled, which explains missing + events; the list keeps one word so the UUID and URL keep their width. + """ + if r["disabled_at"]: + if not verbose: + return "[red]disabled[/red]" + reason = f": {r['disabled_reason']}" if r["disabled_reason"] else "" + return f"[red]disabled {r['disabled_at'][:10]}{reason}[/red]" + return "[green]active[/green]" if r["is_active"] else "[yellow]inactive[/yellow]" + + +def _webhook_list(items: list[Webhook]) -> None: + table = Table(title="Webhooks", show_lines=False) + table.add_column("UUID", style="dim", no_wrap=True) + table.add_column("URL", style="bold", overflow="fold") + table.add_column("Topics", justify="right") + table.add_column("State", no_wrap=True) + + for r in items: + table.add_row(r["uuid"], r["url"], str(len(r["topics"])), _webhook_state(r)) + + console.print(table) + + +def _webhook_detail(r: Webhook) -> None: + header = Text() + header.append("WEBHOOK", style="bold") + header.append(f" {r['url']}", style="bold cyan") + console.print(Panel(header, expand=False)) + + details = Table.grid(padding=(0, 2)) + details.add_column(style="dim", min_width=16) + details.add_column() + details.add_row("UUID:", r["uuid"]) + details.add_row("State:", _webhook_state(r, verbose=True)) + details.add_row("Topics:", "\n".join(r["topics"]) or "[yellow]none: receives no events[/yellow]") + details.add_row("Created:", _local_minute(r["created_at"])) + details.add_row("Modified:", _local_minute(r["last_modified_at"])) + console.print(details) + + # Create is the only response that carries the secret, and the API never returns it again. + if secret := r.get("secret"): + console.print( + Panel( + f"[bold]{secret}[/bold]\n\nSave this signing secret now. DualEntry will not show it again.", + title="Signing secret", + border_style="yellow", + expand=False, + ) + ) + + +def _local_minute(iso: str) -> str: + """ + Show an API timestamp in the viewer's timezone, to the minute. + + The API returns UTC (`...+00:00`); cutting the string would drop the offset and + leave a UTC time that reads as local. A naive timestamp is shown as given. + """ + moment = datetime.fromisoformat(iso) + if moment.tzinfo is not None: + moment = moment.astimezone() + return moment.strftime("%Y-%m-%d %H:%M") + + +def _webhook_delivery_list(items: list[WebhookDelivery]) -> None: + table = Table(title="Webhook Deliveries", show_lines=False) + table.add_column("Created", style="dim", no_wrap=True) + table.add_column("Topic", style="bold", overflow="fold") + table.add_column("Status") + table.add_column("Tries", justify="right") + table.add_column("HTTP", justify="right") + table.add_column("Error", max_width=40) + + for r in items: + http = str(r["response_status"]) if r["response_status"] is not None else "-" + table.add_row(_local_minute(r["created_at"]), r["topic"], r["status"], str(r["attempts"]), http, r["last_error"] or "-") + + console.print(table) + + +_register("webhook", _webhook_list, _webhook_detail) +_register("webhook-delivery", _webhook_delivery_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"} diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..6f5c1f8 --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,264 @@ +"""Tests for webhooks commands.""" + +from __future__ import annotations + +import json +import re +import time +from unittest.mock import MagicMock, patch + +import pytest +import typer.main +from typer.testing import CliRunner + +from dualentry_cli.main import app + +runner = CliRunner() + +UUID = "3f2b8c1e-9a4d-4e6f-8b7a-1c2d3e4f5a6b" + +_WEBHOOK = { + "uuid": UUID, + "url": "https://hooks.example.com/dualentry", + "topics": ["invoice.created", "bill.posted"], + "is_active": True, + "disabled_at": None, + "disabled_reason": "", + "created_at": "2026-10-01T09:00:00+00:00", + "last_modified_at": "2026-10-02T09:00:00+00:00", +} + +_DISABLED = {**_WEBHOOK, "is_active": False, "disabled_at": "2026-10-05T00:00:00+00:00", "disabled_reason": "too many failures"} + +_DELIVERY = { + "event_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", + "topic": "invoice.created", + "status": "failed", + "attempts": 3, + "response_status": 500, + "last_error": "upstream timeout", + "created_at": "2026-10-03T10:11:12+00:00", + "delivered_at": None, +} + + +def _flat(output: str) -> str: + """Strip ANSI codes and rich box borders, and collapse wrapping, so messages match in CI too.""" + plain = re.sub(r"\x1b\[[0-9;]*m", "", output) + return " ".join(plain.replace("│", " ").split()) + + +@pytest.fixture +def client(): + mock = MagicMock() + with patch("dualentry_cli.main.get_client", return_value=mock): + yield mock + + +def test_registers_every_webhook_operation(): + commands = set(typer.main.get_command(app).commands["webhooks"].commands) + assert commands == {"topics", "list", "get", "create", "update", "delete", "deliveries"} + + +class TestTopics: + def test_human_output_is_one_name_per_line(self, client): + client.get.return_value = [{"name": "bill.posted"}, {"name": "invoice.created"}] + result = runner.invoke(app, ["webhooks", "topics"]) + assert result.exit_code == 0, result.output + client.get.assert_called_once_with("/webhooks/topics/") + assert result.output.splitlines()[-2:] == ["bill.posted", "invoice.created"] + + def test_json_output_is_the_api_list(self, client): + client.get.return_value = [{"name": "bill.posted"}] + result = runner.invoke(app, ["webhooks", "topics", "-o", "json"]) + assert result.exit_code == 0, result.output + assert json.loads(result.output) == [{"name": "bill.posted"}] + + +class TestList: + def test_no_filters(self, client): + client.get.return_value = {"items": [_WEBHOOK], "count": 1} + result = runner.invoke(app, ["webhooks", "list"]) + assert result.exit_code == 0, result.output + client.get.assert_called_once_with("/webhooks/", params={"limit": 20, "offset": 0}) + out = _flat(result.output) + assert UUID in out + assert "https://hooks" in out + assert "active" in out + + @pytest.mark.parametrize(("flag", "expected"), [("--active", True), ("--inactive", False)]) + def test_active_filter_is_sent_including_false(self, flag, expected, client): + client.get.return_value = {"items": [], "count": 0} + result = runner.invoke(app, ["webhooks", "list", flag, "--search", "hooks"]) + assert result.exit_code == 0, result.output + client.get.assert_called_once_with("/webhooks/", params={"search": "hooks", "is_active": expected, "limit": 20, "offset": 0}) + + @pytest.mark.parametrize(("flag", "expected"), [("--active", "--active"), ("--inactive", "--inactive")]) + def test_truncated_all_hint_repeats_every_filter(self, flag, expected, client): + client.paginate.return_value = {"items": [_WEBHOOK], "count": 250000, "next_offset": 100000} + result = runner.invoke(app, ["webhooks", "list", "--all", flag, "--search", "acme corp"]) + assert result.exit_code == 0, result.output + hint = result.output.splitlines()[-1].strip() + assert hint == f"dualentry webhooks list --all --offset 100000 --search 'acme corp' {expected}" + + def test_auto_disabled_webhook_is_marked_disabled(self, client): + client.get.return_value = {"items": [_DISABLED], "count": 1} + result = runner.invoke(app, ["webhooks", "list"]) + assert "disabled" in _flat(result.output) + + +class TestGet: + def test_gets_by_uuid(self, client): + client.get.return_value = _WEBHOOK + result = runner.invoke(app, ["webhooks", "get", UUID]) + assert result.exit_code == 0, result.output + client.get.assert_called_once_with(f"/webhooks/{UUID}/", params=None) + assert "invoice.created" in result.output + + def test_auto_disabled_webhook_says_when_and_why(self, client): + client.get.return_value = _DISABLED + result = runner.invoke(app, ["webhooks", "get", UUID]) + assert "disabled 2026-10-05: too many failures" in _flat(result.output) + + @pytest.mark.parametrize("command", [["get"], ["delete"], ["deliveries"], ["update", "--inactive"]]) + def test_malformed_uuid_is_rejected_before_any_request(self, command, client): + result = runner.invoke(app, ["webhooks", command[0], "not-a-uuid", *command[1:]]) + assert result.exit_code == 2 + assert "not-a-uuid" in _flat(result.output) + client.get.assert_not_called() + client.patch.assert_not_called() + client.delete.assert_not_called() + + +class TestCreate: + def test_sends_url_and_topics_and_shows_secret_once_warning(self, client): + client.post.return_value = {**_WEBHOOK, "secret": "whsec_example_only"} + result = runner.invoke(app, ["webhooks", "create", "--url", _WEBHOOK["url"], "-t", "invoice.created", "-t", "bill.posted"]) + assert result.exit_code == 0, result.output + client.post.assert_called_once_with("/webhooks/", json={"url": _WEBHOOK["url"], "topics": ["invoice.created", "bill.posted"]}) + out = _flat(result.output) + assert "whsec_example_only" in out + assert "will not show it again" in out + assert "receive no events" not in out + + def test_json_output_keeps_the_secret(self, client): + client.post.return_value = {**_WEBHOOK, "secret": "whsec_example_only"} + result = runner.invoke(app, ["webhooks", "create", "--url", _WEBHOOK["url"], "-t", "invoice.created", "-o", "json"]) + assert result.exit_code == 0, result.output + assert json.loads(result.output)["secret"] == "whsec_example_only" + + def test_no_topics_is_allowed_but_warned(self, client): + client.post.return_value = {**_WEBHOOK, "topics": [], "secret": "whsec_example_only"} + result = runner.invoke(app, ["webhooks", "create", "--url", _WEBHOOK["url"]]) + assert result.exit_code == 0, result.output + client.post.assert_called_once_with("/webhooks/", json={"url": _WEBHOOK["url"], "topics": []}) + assert "will receive no events" in _flat(result.output) + + def test_url_is_required(self, client): + result = runner.invoke(app, ["webhooks", "create", "-t", "invoice.created"]) + assert result.exit_code == 2 + client.post.assert_not_called() + + +class TestUpdate: + @pytest.mark.parametrize( + ("args", "body"), + [ + (["--url", "https://new.example.com"], {"url": "https://new.example.com"}), + (["-t", "bill.posted", "-t", "invoice.created"], {"topics": ["bill.posted", "invoice.created"]}), + (["--clear-topics"], {"topics": []}), + (["--active"], {"is_active": True}), + (["--inactive"], {"is_active": False}), + ], + ) + def test_patches_only_what_was_passed(self, args, body, client): + client.patch.return_value = _WEBHOOK + result = runner.invoke(app, ["webhooks", "update", UUID, *args]) + assert result.exit_code == 0, result.output + client.patch.assert_called_once_with(f"/webhooks/{UUID}/", json=body) + + def test_clearing_topics_warns(self, client): + client.patch.return_value = {**_WEBHOOK, "topics": []} + result = runner.invoke(app, ["webhooks", "update", UUID, "--clear-topics"]) + assert "will receive no events" in _flat(result.output) + + def test_topic_and_clear_topics_together_are_rejected(self, client): + result = runner.invoke(app, ["webhooks", "update", UUID, "-t", "bill.posted", "--clear-topics"]) + assert result.exit_code == 2 + assert "not both" in _flat(result.output) + client.patch.assert_not_called() + + def test_nothing_to_change_is_rejected(self, client): + result = runner.invoke(app, ["webhooks", "update", UUID]) + assert result.exit_code == 2 + out = _flat(result.output) + assert "nothing to change" in out + assert "Invalid value" not in out + client.patch.assert_not_called() + + +class TestDelete: + def test_deletes_by_uuid_and_says_history_went_too(self, client): + client.delete.return_value = {"success": True, "errors": {}} + result = runner.invoke(app, ["webhooks", "delete", UUID]) + assert result.exit_code == 0, result.output + client.delete.assert_called_once_with(f"/webhooks/{UUID}/") + assert "with its delivery history" in result.output + + +class TestDeliveries: + def test_one_page(self, client): + client.get.return_value = {"items": [_DELIVERY], "count": 1} + result = runner.invoke(app, ["webhooks", "deliveries", UUID, "--limit", "5"]) + assert result.exit_code == 0, result.output + client.get.assert_called_once_with(f"/webhooks/{UUID}/deliveries/", params={"limit": 5, "offset": 0}) + out = _flat(result.output) + assert "failed" in out + assert "upstream timeout" in out + + def test_all_pages_from_offset(self, client): + client.paginate.return_value = {"items": [_DELIVERY], "count": 1} + result = runner.invoke(app, ["webhooks", "deliveries", UUID, "--all", "--offset", "40"]) + assert result.exit_code == 0, result.output + client.paginate.assert_called_once_with(f"/webhooks/{UUID}/deliveries/", start_offset=40) + assert "Warning" not in result.output + + def test_truncated_all_prints_a_resume_command_for_this_command(self, client): + client.paginate.return_value = {"items": [_DELIVERY], "count": 250000, "next_offset": 100000} + result = runner.invoke(app, ["webhooks", "deliveries", UUID, "--all"]) + assert result.exit_code == 0, result.output + assert result.output.splitlines()[-1].strip() == f"dualentry webhooks deliveries {UUID} --all --offset 100000" + + +@pytest.fixture +def new_york_time(monkeypatch): + """Pin the process timezone, so local-time rendering is deterministic.""" + if not hasattr(time, "tzset"): + pytest.skip("time.tzset is not available on this platform") + monkeypatch.setenv("TZ", "America/New_York") + time.tzset() + yield + monkeypatch.undo() + time.tzset() + + +@pytest.mark.usefixtures("new_york_time") +class TestDeliveryTimes: + def test_utc_timestamps_are_shown_in_local_time(self, client): + client.get.return_value = {"items": [_DELIVERY], "count": 1} + result = runner.invoke(app, ["webhooks", "deliveries", UUID]) + out = _flat(result.output) + assert "2026-10-03 06:11" in out + assert "10:11" not in out + + def test_webhook_detail_times_are_local_too(self, client): + client.get.return_value = _WEBHOOK + result = runner.invoke(app, ["webhooks", "get", UUID]) + out = _flat(result.output) + assert "2026-10-01 05:00" in out + assert "+00:00" not in out + + def test_naive_timestamp_is_shown_as_given(self): + from dualentry_cli.output import _local_minute + + assert _local_minute("2026-10-03T10:11:12") == "2026-10-03 10:11"