From fea1fd4a25f7cc111cc0a983e9e38d4250d50043 Mon Sep 17 00:00:00 2001 From: Mykhaylo Berdar Date: Sat, 29 Aug 2026 08:40:36 +0300 Subject: [PATCH] eops-370-fix(cli): add patch command so partial updates stop clearing fields `update` sends PUT, which is a full replace, so any field missing from the JSON file was silently blanked. The natural CLI workflow - write a small file, apply it - destroyed data. Twelve /public/v2 resources expose PATCH and the API already merges correctly under test, so add a `patch` command rather than having `update` fetch and merge client-side; reimplementing the merge in the CLI would be both weaker and racy. - add a `patch` command on the twelve resources whose API exposes PATCH - `update` keeps PUT semantics but now reads the record first and warns which populated fields the payload would clear, confirming when a terminal is attached. --yes skips the warning and the read it needs, so batch runs do not pay an extra GET per record - warn when omitting record_status would post a draft: the update schema defaults record_status to posted, so a partial PUT silently promoted drafts - replace the shared IJE-shaped `post` whitelist with a per-resource PostWritableFields, so enabling post elsewhere cannot wipe fields outside a list written for intercompany journal entries - cover both paths end to end by running the built CLI against a stub v2 API over a real socket, which also reproduces the original PUT data loss --- CHANGELOG.md | 10 + README.md | 16 ++ src/dualentry_cli/commands/__init__.py | 129 ++++++++++-- src/dualentry_cli/commands/ije_extras.py | 9 + src/dualentry_cli/main.py | 35 +-- tests/test_e2e_partial_update.py | 181 ++++++++++++++++ tests/test_partial_updates.py | 258 +++++++++++++++++++++++ 7 files changed, 612 insertions(+), 26 deletions(-) create mode 100644 tests/test_e2e_partial_update.py create mode 100644 tests/test_partial_updates.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f23241..11380d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [Unreleased] + +- Added `patch` for the twelve resources whose API exposes PATCH, so a partial + JSON file changes only the fields it contains +- `update` now warns which populated fields its PUT would clear, and confirms + when run from a terminal +- `update` warns when omitting `record_status` would post a draft record +- `post` field lists are declared per resource instead of being shared, so the + intercompany-journal-entry shape can no longer be applied to another resource + ## [0.1.17] - 2026-04-15 diff --git a/README.md b/README.md index 853b248..9024d97 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,22 @@ dualentry bills list --status posted --format json All resources support `list`, `get`, `create`, and `update` operations. +### Changing part of a record + +`update` sends PUT, which replaces the record: any field missing from your JSON +file is cleared. To change a few fields and leave the rest alone, use `patch`. + +```bash +# Only memo and reference_number change; every other field is untouched. +echo '{"memo": "Q2 true-up", "reference_number": "REF-10"}' > change.json +dualentry invoices patch 1001 --file change.json +``` + +`patch` is available on the resources whose API exposes it: invoices, bills, +customer payments, customer credits, customer prepayments, customer prepayment +applications, customers, vendors, items, classifications, fixed assets and +contracts. Elsewhere `update` warns before it clears anything. + ## Output Formats ```bash diff --git a/src/dualentry_cli/commands/__init__.py b/src/dualentry_cli/commands/__init__.py index 41f6db1..cb9becd 100644 --- a/src/dualentry_cli/commands/__init__.py +++ b/src/dualentry_cli/commands/__init__.py @@ -3,12 +3,15 @@ from __future__ import annotations import json +import sys from collections.abc import Callable from pathlib import Path +from typing import NamedTuple import typer from dualentry_cli.cli import HelpfulGroup +from dualentry_cli.client import APIError from dualentry_cli.output import _RECORD_PREFIX, format_output # ── Shared option defaults ────────────────────────────────────────── @@ -102,17 +105,93 @@ def _load_json_file(file: Path) -> dict: # ── Post command helpers ─────────────────────────────────────────── -_WRITABLE_FIELDS = {"date", "transaction_date", "memo", "currency_iso_4217_code", "exchange_rate", "record_status", "items", "attachments"} -_WRITABLE_ITEM_FIELDS = {"id", "company_id", "account_number", "debit", "credit", "memo", "position", "classifications", "customer_id", "vendor_id", "currency", "eliminate"} +class PostWritableFields(NamedTuple): + """Fields a resource's `post` may send back. Shapes differ per resource, so each declares its own.""" -def _strip_to_writable(data: dict) -> dict: - payload = {k: v for k, v in data.items() if k in _WRITABLE_FIELDS} - if "items" in payload: - payload["items"] = [{k: v for k, v in item.items() if k in _WRITABLE_ITEM_FIELDS} for item in payload["items"]] + record: frozenset[str] + line: frozenset[str] = frozenset() + line_key: str = "items" + + +def _strip_to_writable(data: dict, writable: PostWritableFields) -> dict: + payload = {k: v for k, v in data.items() if k in writable.record} + lines = payload.get(writable.line_key) + if isinstance(lines, list): + payload[writable.line_key] = [{k: v for k, v in line.items() if k in writable.line} for line in lines] return payload +# ── Update command helpers ───────────────────────────────────────── + +# Assigned or derived by the API: absent from an update file by design, so warning about them would cry wolf. +_SERVER_MANAGED_FIELDS = frozenset( + { + "id", + "internal_id", + "number", + "record_type", + "organization_id", + "created_at", + "updated_at", + "created_by", + "updated_by", + "total", + "subtotal", + "balance", + "amount_due", + "amount_paid", + "tax_total", + "discount_amount", + "net_amount", + } +) + + +def _is_interactive() -> bool: + """True when there is a terminal attached to answer a prompt.""" + try: + return sys.stdin.isatty() + except (AttributeError, ValueError): + return False + + +def _holds_data(value) -> bool: + """True when a field currently carries something a PUT would destroy.""" + if value is None: + return False + if isinstance(value, (str, list, dict, tuple)): + return len(value) > 0 + return True + + +def _fields_cleared_by_put(current: dict, payload: dict) -> list[str]: + """Populated fields on the record that the update file omits.""" + return sorted(key for key, value in current.items() if key not in payload and key not in _SERVER_MANAGED_FIELDS and _holds_data(value)) + + +def _warn_before_put(current: dict, payload: dict, *, resource: str, patch_available: bool) -> None: + """Warn that PUT replaces the record; confirm only when a terminal can answer.""" + cleared = _fields_cleared_by_put(current, payload) + posts_draft = current.get("record_status") == "draft" and "record_status" not in payload + if not cleared and not posts_draft: + return + + typer.secho(f" ! 'update' sends PUT, which replaces the whole {resource}.", fg=typer.colors.YELLOW, err=True) + if cleared: + typer.secho(" Your file omits these populated fields, so they will be cleared:", fg=typer.colors.YELLOW, err=True) + typer.secho(f" {', '.join(cleared)}", fg=typer.colors.YELLOW, err=True) + if posts_draft: + typer.secho(" record_status is 'draft' and your file omits it, so this will post the record.", fg=typer.colors.YELLOW, err=True) + if patch_available: + typer.secho(" Use 'patch' instead to change only the fields in your file.", fg=typer.colors.YELLOW, err=True) + + if not _is_interactive(): + return + if not typer.confirm(" Continue?", default=False): + raise typer.Abort + + # ── Factory ───────────────────────────────────────────────────────── @@ -123,9 +202,10 @@ def make_resource_app( *, has_create: bool = True, has_update: bool = True, + has_patch: bool = False, has_delete: bool = False, has_number: bool = False, - has_post: bool = False, + post_writable: PostWritableFields | None = None, filters: set[str] | None = None, template: dict | None = None, checks: list[Callable] | None = None, @@ -188,7 +268,6 @@ def get_cmd_auto( output: str = Format, ): """Try by number first, fall back to ID lookup on 404.""" - from dualentry_cli.client import APIError from dualentry_cli.main import get_client client = get_client() @@ -223,7 +302,6 @@ def get_cmd_by_id( record_id: str = typer.Argument(help="Record ID (e.g. JE-1619031 or 1619031)"), output: str = Format, ): - from dualentry_cli.client import APIError from dualentry_cli.main import get_client client = get_client() @@ -271,16 +349,43 @@ def create_cmd( def update_cmd( record_id: str = typer.Argument(help="Record ID"), file: Path = typer.Option(..., "--file", "-f", help="JSON file with update data"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the warning and its confirmation, and the read it needs"), output: str = Format, ): from dualentry_cli.main import get_client payload = _load_json_file(file) client = get_client() + # The warning costs a read, so --yes skips it outright for batch runs. + current = None + if not yes: + try: + current = client.get(f"/{path}/{record_id}/") + except Exception: + current = None # Advisory only; the PUT reports the real problem. + if current: + _warn_before_put(current, payload, resource=resource, patch_available=has_patch) data = client.put(f"/{path}/{record_id}/", json=payload) format_output(data, resource=resource, fmt=output) - update_cmd.__doc__ = f"Update a {resource}." + update_cmd.__doc__ = f"Replace a {resource} (PUT). Fields missing from the file are cleared." + + if has_patch: + + @app.command("patch") + def patch_cmd( + record_id: str = typer.Argument(help="Record ID"), + file: Path = typer.Option(..., "--file", "-f", help="JSON file with only the fields to change"), + output: str = Format, + ): + from dualentry_cli.main import get_client + + payload = _load_json_file(file) + client = get_client() + data = client.patch(f"/{path}/{record_id}/", json=payload) + format_output(data, resource=resource, fmt=output) + + patch_cmd.__doc__ = f"Update only the supplied fields of a {resource}. Anything not in the file is left alone." if has_delete: @@ -325,7 +430,7 @@ def validate_cmd( validate_cmd.__doc__ = f"Validate a {resource} payload." - if has_post: + if post_writable is not None: @app.command("post") def post_cmd( @@ -343,7 +448,7 @@ def post_cmd( typer.secho(f" \u2717 Cannot post: record is '{current_status}', only draft records can be posted.", fg=typer.colors.RED, err=True) raise typer.Exit(code=1) - payload = _strip_to_writable(data) + payload = _strip_to_writable(data, post_writable) payload["record_status"] = "posted" result = client.put(f"/{path}/{stripped}/", json=payload) format_output(result, resource=resource, fmt=output) diff --git a/src/dualentry_cli/commands/ije_extras.py b/src/dualentry_cli/commands/ije_extras.py index 9be74e9..5e668cb 100644 --- a/src/dualentry_cli/commands/ije_extras.py +++ b/src/dualentry_cli/commands/ije_extras.py @@ -4,6 +4,8 @@ from decimal import Decimal, InvalidOperation +from dualentry_cli.commands import PostWritableFields + IJE_TEMPLATE = { "date": "2026-01-01", "memo": "Intercompany transfer", @@ -114,3 +116,10 @@ def check_company_access(payload: dict, client=None) -> list[str]: IJE_CHECKS = IJE_OFFLINE_CHECKS IJE_ONLINE_EXTRA_CHECKS = IJE_ONLINE_CHECKS + +# Fields the IJE write schema accepts; the read response also carries derived ones it rejects. +IJE_POST_WRITABLE = PostWritableFields( + record=frozenset({"date", "transaction_date", "memo", "currency_iso_4217_code", "exchange_rate", "record_status", "items", "attachments"}), + line=frozenset({"id", "company_id", "account_number", "debit", "credit", "memo", "position", "classifications", "customer_id", "vendor_id", "currency", "eliminate"}), + line_key="items", +) diff --git a/src/dualentry_cli/main.py b/src/dualentry_cli/main.py index f50302f..b053f64 100644 --- a/src/dualentry_cli/main.py +++ b/src/dualentry_cli/main.py @@ -8,7 +8,7 @@ from dualentry_cli.cli import HelpfulGroup from dualentry_cli.commands import make_resource_app from dualentry_cli.commands.accounts import app as accounts_app -from dualentry_cli.commands.ije_extras import IJE_CHECKS, IJE_ONLINE_EXTRA_CHECKS, IJE_TEMPLATE +from dualentry_cli.commands.ije_extras import IJE_CHECKS, IJE_ONLINE_EXTRA_CHECKS, IJE_POST_WRITABLE, IJE_TEMPLATE from dualentry_cli.config import Config app = typer.Typer(name="dualentry", help="DualEntry accounting CLI", no_args_is_help=True, cls=HelpfulGroup) @@ -18,19 +18,26 @@ app.add_typer(config_app, name="config") # 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_patch=True, has_number=True, filters={"customer", "company"}), name="invoices") +app.add_typer(make_resource_app("bills", "bill", "bills", has_patch=True, has_number=True, filters={"vendor", "company"}), 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" + make_resource_app("customer payments", "customer-payment", "customer-payments", has_patch=True, has_number=True, filters={"customer", "company"}), name="customer-payments" ) 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 credits", "customer-credit", "customer-credits", has_patch=True, has_number=True, filters={"customer", "company"}), name="customer-credits" +) +app.add_typer( + make_resource_app("customer prepayments", "customer-prepayment", "customer-prepayments", has_patch=True, has_number=True, filters={"customer", "company"}), + name="customer-prepayments", +) +app.add_typer( + make_resource_app( + "customer prepayment applications", "customer-prepayment-application", "customer-prepayment-applications", has_patch=True, has_number=True, filters={"customer", "company"} + ), 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") @@ -52,15 +59,15 @@ # 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("fixed assets", "fixed-asset", "fixed-assets", has_patch=True, has_number=True), name="fixed-assets") app.add_typer(make_resource_app("depreciation books", "depreciation-book", "depreciation-books"), 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("customers", "customer", "customers", has_patch=True), name="customers") +app.add_typer(make_resource_app("vendors", "vendor", "vendors", has_patch=True), name="vendors") +app.add_typer(make_resource_app("items", "item", "items", has_patch=True), 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("classifications", "classification", "classifications", has_patch=True), name="classifications") # Recurring recurring_app = typer.Typer(help="Manage recurring records", no_args_is_help=True, cls=HelpfulGroup) @@ -70,7 +77,7 @@ 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("contracts", "contract", "contracts", has_patch=True), 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") app.add_typer( @@ -79,7 +86,7 @@ "intercompany-journal-entry", "intercompany-journal-entries", has_number=True, - has_post=True, + post_writable=IJE_POST_WRITABLE, filters={"company"}, template=IJE_TEMPLATE, checks=IJE_CHECKS, diff --git a/tests/test_e2e_partial_update.py b/tests/test_e2e_partial_update.py new file mode 100644 index 0000000..7231024 --- /dev/null +++ b/tests/test_e2e_partial_update.py @@ -0,0 +1,181 @@ +""" +End-to-end: run the real CLI binary over a real socket against a stub v2 API. + +Covers the whole stack the unit tests mock out - argv parsing, config/auth from +the environment, httpx, and response formatting - so `patch` is proven to +preserve omitted fields as the shipped command, not as an in-process call. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest + +RECORD_PATH = "/public/v2/invoices/1001/" + +POPULATED = { + "internal_id": 42, + "number": 1001, + "customer_id": 7, + "company_id": 3, + "due_date": "2026-03-01", + "currency_iso_4217_code": "USD", + "exchange_rate": "1.00000000", + "memo": "Original memo", + "reference_number": "REF-9", + "term_id": 5, + "ar_account_id": 11, + "sales_order_id": 88, + "contracted": True, + "record_status": "draft", + "items": [{"id": 1, "item_id": 2, "quantity": "3.0", "rate": "50.00", "position": 1, "memo": "line"}], +} + +# What PublicInvoiceSchemaUpdateIn (= CreateIn) fills in for fields a PUT omits. +PUT_DEFAULTS = { + "customer_id": None, + "company_id": None, + "due_date": None, + "memo": None, + "term_id": None, + "ar_account_id": None, + "sales_order_id": None, + "items": None, + "reference_number": "", + "contracted": False, + "record_status": "posted", +} +SERVER_MANAGED = ("internal_id", "number") + + +class _Handler(BaseHTTPRequestHandler): + record: dict = {} + + def log_message(self, *args): + pass + + def _send(self, payload, status=200): + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _body(self) -> dict: + return json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}") + + def do_GET(self): + if self.path != RECORD_PATH: + self._send({"errors": {"detail": ["not found"]}}, 404) + return + self._send(type(self).record) + + def do_PATCH(self): + if self.path != RECORD_PATH: + self._send({"errors": {"detail": ["not found"]}}, 404) + return + type(self).record = {**type(self).record, **self._body()} + self._send(type(self).record) + + def do_PUT(self): + if self.path != RECORD_PATH: + self._send({"errors": {"detail": ["not found"]}}, 404) + return + sent = self._body() + replaced = {k: type(self).record[k] for k in SERVER_MANAGED} + replaced.update({field: sent.get(field, default) for field, default in PUT_DEFAULTS.items()}) + replaced.update({k: v for k, v in sent.items() if k not in replaced}) + type(self).record = replaced + self._send(type(self).record) + + +@pytest.fixture +def api(): + _Handler.record = json.loads(json.dumps(POPULATED)) + server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + yield f"http://127.0.0.1:{server.server_port}" + server.shutdown() + server.server_close() + + +def _cli(api_url, *args, stdin=""): + binary = Path(sys.executable).with_name("dualentry") + cmd = [str(binary), *args] if binary.exists() else [sys.executable, "-c", "from dualentry_cli.main import main_entrypoint; main_entrypoint()", *args] + env = { + **os.environ, + "DUALENTRY_API_URL": api_url, + "X_API_KEY": "test_key", + "NO_COLOR": "1", + } + return subprocess.run(cmd, check=False, capture_output=True, text=True, input=stdin, env=env, timeout=60) # noqa: S603 + + +def _fetch(api_url): + done = _cli(api_url, "invoices", "get", "1001", "-o", "json") + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_patch_changes_two_fields_and_leaves_the_rest_alone(api, tmp_path): + change = {"memo": "Patched memo", "reference_number": "REF-10"} + payload_file = tmp_path / "change.json" + payload_file.write_text(json.dumps(change)) + + done = _cli(api, "invoices", "patch", "1001", "--file", str(payload_file), "-o", "json") + assert done.returncode == 0, done.stderr + + after = _fetch(api) + assert after["memo"] == "Patched memo" + assert after["reference_number"] == "REF-10" + for field, original in POPULATED.items(): + if field not in change: + assert after[field] == original, f"{field} changed" + + +def test_update_clears_the_fields_the_file_omits(api, tmp_path): + payload_file = tmp_path / "change.json" + payload_file.write_text(json.dumps({"memo": "Replaced memo"})) + + done = _cli(api, "invoices", "update", "1001", "--file", str(payload_file), "--yes", "-o", "json") + assert done.returncode == 0, done.stderr + + after = _fetch(api) + assert after["memo"] == "Replaced memo" + assert after["customer_id"] is None + assert after["items"] is None + assert after["reference_number"] == "" + assert after["contracted"] is False + # The bug this ticket is about, reproduced against a real request. + assert after["record_status"] == "posted" + + +def test_update_warns_on_stderr_before_replacing(api, tmp_path): + payload_file = tmp_path / "change.json" + payload_file.write_text(json.dumps({"memo": "Replaced memo"})) + + done = _cli(api, "invoices", "update", "1001", "--file", str(payload_file)) + + assert done.returncode == 0, done.stderr + assert "replaces the whole invoice" in done.stderr + assert "customer_id" in done.stderr + assert "this will post the record" in done.stderr + assert "Use 'patch' instead" in done.stderr + + +def test_patch_warns_about_nothing(api, tmp_path): + payload_file = tmp_path / "change.json" + payload_file.write_text(json.dumps({"memo": "Patched memo"})) + + done = _cli(api, "invoices", "patch", "1001", "--file", str(payload_file)) + + assert done.returncode == 0, done.stderr + assert "replaces the whole" not in done.stderr diff --git a/tests/test_partial_updates.py b/tests/test_partial_updates.py new file mode 100644 index 0000000..6daa70d --- /dev/null +++ b/tests/test_partial_updates.py @@ -0,0 +1,258 @@ +"""Partial-update behaviour: `patch` merges, `update` warns before replacing.""" + +import json +from unittest.mock import MagicMock, patch + +import httpx +import pytest +import respx +from typer.testing import CliRunner + +from dualentry_cli.client import APIError +from dualentry_cli.commands import PostWritableFields, _fields_cleared_by_put, _strip_to_writable +from dualentry_cli.main import app + +runner = CliRunner() + +POPULATED_INVOICE = { + "internal_id": 42, + "number": 1001, + "created_at": "2026-01-01T00:00:00Z", + "customer_id": 7, + "company_id": 3, + "due_date": "2026-03-01", + "currency_iso_4217_code": "USD", + "exchange_rate": "1.00000000", + "memo": "Original memo", + "reference_number": "REF-9", + "term_id": 5, + "ar_account_id": 11, + "sales_order_id": 88, + "contract_id": 4, + "contracted": True, + "record_status": "posted", + "attachments": [{"id": 1}], + "items": [{"id": 1, "item_id": 2, "quantity": "3.0", "rate": "50.00", "position": 1, "memo": "line"}], +} + + +@pytest.fixture +def mock_get_client(): + mock_client = MagicMock() + with patch("dualentry_cli.main.get_client", return_value=mock_client): + yield mock_client + + +def _write(tmp_path, payload): + data_file = tmp_path / "payload.json" + data_file.write_text(json.dumps(payload)) + return str(data_file) + + +class TestPatchCommand: + def test_patch_sends_only_supplied_fields(self, mock_get_client, tmp_path): + payload = {"memo": "Patched memo", "reference_number": "REF-10"} + mock_get_client.patch.return_value = {**POPULATED_INVOICE, **payload} + + result = runner.invoke(app, ["invoices", "patch", "1001", "--file", _write(tmp_path, payload)]) + + assert result.exit_code == 0 + mock_get_client.patch.assert_called_once_with("/invoices/1001/", json=payload) + mock_get_client.put.assert_not_called() + + def test_patch_available_on_every_v2_patch_resource(self): + for resource in ( + "invoices", + "bills", + "customer-prepayments", + "customer-prepayment-applications", + "customer-credits", + "customer-payments", + "vendors", + "classifications", + "customers", + "items", + "fixed-assets", + "contracts", + ): + result = runner.invoke(app, [resource, "patch", "--help"]) + assert result.exit_code == 0, f"{resource} has no patch command" + + def test_no_patch_command_where_api_lacks_patch(self): + result = runner.invoke(app, ["journal-entries", "patch", "--help"]) + assert result.exit_code != 0 + + +class TestPatchLeavesOmittedFieldsAlone: + """AC: apply a two-field change to a fully populated record, everything else survives.""" + + @respx.mock + def test_two_field_patch_preserves_every_other_field(self, tmp_path, monkeypatch): + change = {"memo": "Patched memo", "reference_number": "REF-10"} + + def merge(request): + sent = json.loads(request.content) + assert sent == change, "CLI must forward only the supplied fields" + return httpx.Response(200, json={**POPULATED_INVOICE, **sent}) + + respx.patch("https://api.dualentry.com/public/v2/invoices/1001/").mock(side_effect=merge) + + monkeypatch.setenv("DUALENTRY_API_URL", "https://api.dualentry.com") + monkeypatch.setenv("X_API_KEY", "test_key") + result = runner.invoke(app, ["invoices", "patch", "1001", "--file", _write(tmp_path, change), "-o", "json"]) + + assert result.exit_code == 0, result.output + returned = json.loads(result.output) + assert returned["memo"] == "Patched memo" + assert returned["reference_number"] == "REF-10" + untouched = {k: v for k, v in POPULATED_INVOICE.items() if k not in change} + for field, original in untouched.items(): + assert returned[field] == original, f"{field} changed" + + +class TestUpdateWarnsBeforeReplacing: + def test_lists_populated_fields_the_file_omits(self, mock_get_client, tmp_path): + mock_get_client.get.return_value = POPULATED_INVOICE + mock_get_client.put.return_value = POPULATED_INVOICE + + result = runner.invoke(app, ["invoices", "update", "1001", "--file", _write(tmp_path, {"memo": "only memo"})]) + + assert result.exit_code == 0 + assert "replaces the whole invoice" in result.output + for field in ("reference_number", "term_id", "customer_id", "items"): + assert field in result.output + assert "Use 'patch' instead" in result.output + + def test_does_not_warn_about_server_managed_fields(self, mock_get_client, tmp_path): + mock_get_client.get.return_value = POPULATED_INVOICE + mock_get_client.put.return_value = POPULATED_INVOICE + full_payload = {k: v for k, v in POPULATED_INVOICE.items() if k not in {"internal_id", "number", "created_at"}} + + result = runner.invoke(app, ["invoices", "update", "1001", "--file", _write(tmp_path, full_payload)]) + + assert result.exit_code == 0 + assert "replaces the whole" not in result.output + + def test_warns_that_omitting_record_status_posts_a_draft(self, mock_get_client, tmp_path): + draft = {**POPULATED_INVOICE, "record_status": "draft"} + mock_get_client.get.return_value = draft + mock_get_client.put.return_value = draft + payload = {k: v for k, v in draft.items() if k not in {"internal_id", "number", "created_at", "record_status"}} + + result = runner.invoke(app, ["invoices", "update", "1001", "--file", _write(tmp_path, payload)]) + + assert result.exit_code == 0 + assert "this will post the record" in result.output + + def test_still_sends_the_put(self, mock_get_client, tmp_path): + payload = {"memo": "only memo"} + mock_get_client.get.return_value = POPULATED_INVOICE + mock_get_client.put.return_value = POPULATED_INVOICE + + result = runner.invoke(app, ["invoices", "update", "1001", "--file", _write(tmp_path, payload)]) + + assert result.exit_code == 0 + mock_get_client.put.assert_called_once_with("/invoices/1001/", json=payload) + + @pytest.mark.parametrize( + "failure", + [ + APIError(403, "no read access"), + httpx.ConnectError("connection refused"), + httpx.ReadTimeout("timed out"), + ], + ids=["api_error", "connect_error", "timeout"], + ) + def test_update_proceeds_when_the_record_cannot_be_read(self, mock_get_client, tmp_path, failure): + payload = {"memo": "only memo"} + mock_get_client.get.side_effect = failure + mock_get_client.put.return_value = POPULATED_INVOICE + + result = runner.invoke(app, ["invoices", "update", "1001", "--file", _write(tmp_path, payload)]) + + assert result.exit_code == 0 + mock_get_client.put.assert_called_once_with("/invoices/1001/", json=payload) + + +class TestUpdateConfirmation: + def test_aborts_when_the_user_declines_at_a_terminal(self, mock_get_client, tmp_path, monkeypatch): + mock_get_client.get.return_value = POPULATED_INVOICE + monkeypatch.setattr("dualentry_cli.commands._is_interactive", lambda: True) + + result = runner.invoke(app, ["invoices", "update", "1001", "--file", _write(tmp_path, {"memo": "x"})], input="n\n") + + assert result.exit_code != 0 + mock_get_client.put.assert_not_called() + + def test_proceeds_when_the_user_accepts(self, mock_get_client, tmp_path, monkeypatch): + mock_get_client.get.return_value = POPULATED_INVOICE + mock_get_client.put.return_value = POPULATED_INVOICE + monkeypatch.setattr("dualentry_cli.commands._is_interactive", lambda: True) + + result = runner.invoke(app, ["invoices", "update", "1001", "--file", _write(tmp_path, {"memo": "x"})], input="y\n") + + assert result.exit_code == 0 + mock_get_client.put.assert_called_once() + + def test_yes_skips_the_warning_and_its_extra_read(self, mock_get_client, tmp_path, monkeypatch): + mock_get_client.put.return_value = POPULATED_INVOICE + monkeypatch.setattr("dualentry_cli.commands._is_interactive", lambda: True) + + result = runner.invoke(app, ["invoices", "update", "1001", "--file", _write(tmp_path, {"memo": "x"}), "--yes"]) + + assert result.exit_code == 0 + mock_get_client.get.assert_not_called() + assert "replaces the whole invoice" not in result.output + mock_get_client.put.assert_called_once() + + def test_non_interactive_run_warns_and_continues(self, mock_get_client, tmp_path): + mock_get_client.get.return_value = POPULATED_INVOICE + mock_get_client.put.return_value = POPULATED_INVOICE + + result = runner.invoke(app, ["invoices", "update", "1001", "--file", _write(tmp_path, {"memo": "x"})]) + + assert result.exit_code == 0 + assert "replaces the whole invoice" in result.output + mock_get_client.put.assert_called_once() + + +class TestFieldsClearedByPut: + def test_reports_populated_omitted_fields_only(self): + current = {"memo": "keep", "reference_number": "", "term_id": None, "items": [], "customer_id": 7} + assert _fields_cleared_by_put(current, {"memo": "new"}) == ["customer_id"] + + def test_ignores_fields_present_in_the_payload(self): + current = {"memo": "keep", "customer_id": 7} + assert _fields_cleared_by_put(current, {"memo": "new", "customer_id": 7}) == [] + + def test_zero_and_false_count_as_data(self): + current = {"exchange_rate": 0, "contracted": False} + assert _fields_cleared_by_put(current, {}) == ["contracted", "exchange_rate"] + + +class TestStripToWritableIsResourceAware: + def test_uses_the_shape_it_is_given(self): + writable = PostWritableFields(record=frozenset({"memo", "items"}), line=frozenset({"id", "debit"})) + data = {"memo": "m", "internal_id": 9, "items": [{"id": 1, "debit": "5.00", "account_name": "Cash"}]} + + assert _strip_to_writable(data, writable) == {"memo": "m", "items": [{"id": 1, "debit": "5.00"}]} + + def test_a_different_resource_keeps_its_own_fields(self): + writable = PostWritableFields(record=frozenset({"customer_id", "lines"}), line=frozenset({"item_id", "quantity", "rate"}), line_key="lines") + data = {"customer_id": 7, "memo": "dropped", "lines": [{"item_id": 2, "quantity": "3", "rate": "50", "total": "150"}]} + + assert _strip_to_writable(data, writable) == {"customer_id": 7, "lines": [{"item_id": 2, "quantity": "3", "rate": "50"}]} + + def test_leaves_a_missing_line_collection_alone(self): + writable = PostWritableFields(record=frozenset({"memo", "items"}), line=frozenset({"id"})) + assert _strip_to_writable({"memo": "m"}, writable) == {"memo": "m"} + + +class TestPostStaysOptIn: + def test_post_is_absent_without_a_declared_shape(self): + result = runner.invoke(app, ["invoices", "post", "--help"]) + assert result.exit_code != 0 + + def test_intercompany_journal_entries_still_post(self): + result = runner.invoke(app, ["intercompany-journal-entries", "post", "--help"]) + assert result.exit_code == 0