diff --git a/scripts/release.py b/scripts/release.py new file mode 100755 index 0000000..58d2433 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +""" +Release script for DualEntry CLI. + +Usage: + python scripts/release.py patch # 0.1.0 -> 0.1.1 + python scripts/release.py minor # 0.1.0 -> 0.2.0 + python scripts/release.py major # 0.1.0 -> 1.0.0 + python scripts/release.py 0.2.0 # Explicit version + +This script: +1. Bumps version in pyproject.toml and __init__.py +2. Updates CHANGELOG.md with today's date +3. Commits the changes +4. Creates a git tag +5. Pushes to GitHub +6. Creates a GitHub release (triggers CI to build binaries) +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from datetime import UTC, datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PYPROJECT = ROOT / "pyproject.toml" +INIT_FILE = ROOT / "src" / "dualentry_cli" / "__init__.py" +CHANGELOG = ROOT / "CHANGELOG.md" + + +def get_current_version() -> str: + """Read current version from __init__.py.""" + content = INIT_FILE.read_text() + match = re.search(r'__version__ = "([^"]+)"', content) + if not match: + raise ValueError("Could not find version in __init__.py") + return match.group(1) + + +def bump_version(current: str, bump_type: str) -> str: + """Calculate new version based on bump type.""" + if bump_type in ("patch", "minor", "major"): + parts = [int(p) for p in current.split(".")] + while len(parts) < 3: + parts.append(0) + + if bump_type == "patch": + parts[2] += 1 + elif bump_type == "minor": + parts[1] += 1 + parts[2] = 0 + elif bump_type == "major": + parts[0] += 1 + parts[1] = 0 + parts[2] = 0 + + return ".".join(str(p) for p in parts) + # Explicit version + if not re.match(r"^\d+\.\d+\.\d+$", bump_type): + raise ValueError(f"Invalid version format: {bump_type}") + return bump_type + + +def update_pyproject(new_version: str) -> None: + """Update version in pyproject.toml.""" + content = PYPROJECT.read_text() + content = re.sub(r'^version = "[^"]+"', f'version = "{new_version}"', content, flags=re.MULTILINE) + PYPROJECT.write_text(content) + + +def update_init(new_version: str) -> None: + """Update version in __init__.py.""" + content = INIT_FILE.read_text() + content = re.sub(r'__version__ = "[^"]+"', f'__version__ = "{new_version}"', content) + INIT_FILE.write_text(content) + + +def update_changelog(new_version: str) -> None: + """Add new version entry to CHANGELOG.md if not already present.""" + content = CHANGELOG.read_text() + today = datetime.now(UTC).strftime("%Y-%m-%d") + new_entry = f"## [{new_version}] - {today}\n\n" + + # Check if this version is already in the changelog + if f"## [{new_version}]" in content: + # Just update the date + content = re.sub( + rf"## \[{re.escape(new_version)}\] - \d{{4}}-\d{{2}}-\d{{2}}", + f"## [{new_version}] - {today}", + content, + ) + else: + # Add new entry after "# Changelog" + content = content.replace("# Changelog\n", f"# Changelog\n\n{new_entry}") + + CHANGELOG.write_text(content) + + +def run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess: + """Run a shell command.""" + print(f" $ {' '.join(cmd)}") + return subprocess.run(cmd, cwd=ROOT, check=check, capture_output=True, text=True) + + +def main() -> int: + if len(sys.argv) != 2: + print(__doc__) + return 1 + + bump_type = sys.argv[1] + current = get_current_version() + new_version = bump_version(current, bump_type) + + print(f"\n Releasing {current} -> {new_version}\n") + + # Check for uncommitted changes + result = run(["git", "status", "--porcelain"]) + if result.stdout.strip(): + print(" ERROR: Uncommitted changes detected. Commit or stash them first.") + return 1 + + # Check we're on main branch + result = run(["git", "branch", "--show-current"]) + if result.stdout.strip() != "main": + print(f" WARNING: Not on main branch (on {result.stdout.strip()})") + response = input(" Continue anyway? [y/N] ") + if response.lower() != "y": + return 1 + + # Run tests + print("\n Running tests...") + result = run(["uv", "run", "pytest", "tests/", "-q"], check=False) + if result.returncode != 0: + print(" ERROR: Tests failed") + print(result.stdout) + print(result.stderr) + return 1 + print(" Tests passed!") + + # Run linter + print("\n Running linter...") + result = run(["uv", "run", "ruff", "check", "."], check=False) + if result.returncode != 0: + print(" ERROR: Linter failed") + print(result.stdout) + return 1 + print(" Linter passed!") + + # Update versions + print("\n Updating version files...") + update_pyproject(new_version) + update_init(new_version) + update_changelog(new_version) + + # Commit + print("\n Committing changes...") + run(["git", "add", "pyproject.toml", "src/dualentry_cli/__init__.py", "CHANGELOG.md"]) + run(["git", "commit", "-m", f"chore: release v{new_version}"]) + + # Tag + print("\n Creating tag...") + run(["git", "tag", f"v{new_version}"]) + + # Push + print("\n Pushing to GitHub...") + run(["git", "push", "origin", "main"]) + run(["git", "push", "origin", f"v{new_version}"]) + + # Create GitHub release + print("\n Creating GitHub release...") + result = run( + ["gh", "release", "create", f"v{new_version}", "--title", f"v{new_version}", "--generate-notes"], + check=False, + ) + if result.returncode != 0: + print(f" WARNING: Could not create GitHub release: {result.stderr}") + print(" Create it manually at: https://github.com/dualentry/dualentry-cli/releases/new") + else: + print(f" Release created: https://github.com/dualentry/dualentry-cli/releases/tag/v{new_version}") + + print(f"\n Done! Released v{new_version}") + print(" CI will build binaries and update Homebrew tap automatically.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/dualentry_cli/auth.py b/src/dualentry_cli/auth.py index 66512a5..6512963 100644 --- a/src/dualentry_cli/auth.py +++ b/src/dualentry_cli/auth.py @@ -274,12 +274,20 @@ def run_login_flow(api_url: str) -> dict: typer.echo(f"If the browser doesn't open, visit: {auth_url}") webbrowser.open(auth_url) + # Handle multiple requests until we get the callback with code + # (browser may send preflight, favicon, or other requests first) + server.timeout = 120 # 2 minute timeout try: - server.handle_request() + while _CallbackHandler.code is None: + server.handle_request() except KeyboardInterrupt: server.server_close() typer.echo("\nLogin cancelled.") raise typer.Exit(code=1) from None + except TimeoutError: + server.server_close() + typer.echo("Login timed out. Please try again.") + raise typer.Exit(code=1) from None server.server_close() if not _CallbackHandler.code: diff --git a/src/dualentry_cli/client.py b/src/dualentry_cli/client.py index 6f979b6..71de85f 100644 --- a/src/dualentry_cli/client.py +++ b/src/dualentry_cli/client.py @@ -3,12 +3,19 @@ from __future__ import annotations import os +import sys +import time from typing import Any import httpx from dualentry_cli import USER_AGENT +# Status codes that should be retried (transient errors) +_RETRYABLE_STATUS_CODES = {429, 502, 503, 504} +_MAX_RETRIES = 3 +_RETRY_DELAYS = [1, 2, 4] # Exponential backoff: 1s, 2s, 4s + class APIError(Exception): def __init__(self, status_code: int, detail: str): @@ -18,9 +25,10 @@ def __init__(self, status_code: int, detail: str): class DualEntryClient: - def __init__(self, api_url: str, *, api_key: str): + def __init__(self, api_url: str, *, api_key: str, retry: bool = False): self._api_url = api_url.rstrip("/") self._base_url = f"{self._api_url}/public/v2" + self._retry = retry self._client = httpx.Client( base_url=self._base_url, headers={ @@ -31,27 +39,63 @@ def __init__(self, api_url: str, *, api_key: str): ) @classmethod - def from_env(cls, api_url: str) -> DualEntryClient: + def from_env(cls, api_url: str, *, retry: bool = False) -> DualEntryClient: api_key = os.environ.get("X_API_KEY", "") if not api_key: msg = "X_API_KEY environment variable is not set" raise ValueError(msg) - return cls(api_url=api_url, api_key=api_key) + return cls(api_url=api_url, api_key=api_key, retry=retry) def _handle_response(self, response: httpx.Response) -> dict: - if response.status_code == 401: + status = response.status_code + if status == 401: raise APIError(401, "API key is invalid or expired. Run: dualentry auth login") - if response.status_code == 403: + if status == 403: raise APIError(403, "API key authentication failed. Run: dualentry auth login") - if response.status_code >= 400: + if status == 404: + raise APIError(404, "Resource not found. Check the ID or number and try again.") + if status == 422: + try: + detail = response.json() + errors = detail.get("errors", detail) + except Exception: + errors = response.text + raise APIError(422, f"Validation error: {errors}") + if status == 429: + raise APIError(429, "Rate limited. Please wait and try again.") + if status >= 500: + raise APIError(status, f"Server error ({status}). The API may be temporarily unavailable.") + if status >= 400: try: detail = response.json() except Exception: detail = response.text - raise APIError(response.status_code, str(detail)) + raise APIError(status, str(detail)) return response.json() def _request(self, method: str, path: str, **kwargs) -> dict: + if not self._retry: + response = self._client.request(method, path, **kwargs) + return self._handle_response(response) + + # Retry logic with visible feedback + last_error = None + for attempt in range(_MAX_RETRIES): + try: + response = self._client.request(method, path, **kwargs) + if response.status_code not in _RETRYABLE_STATUS_CODES: + return self._handle_response(response) + # Retryable error - will retry + last_error = APIError(response.status_code, f"Temporary error ({response.status_code})") + except httpx.RequestError as e: + last_error = e + + if attempt < _MAX_RETRIES - 1: + delay = _RETRY_DELAYS[attempt] + print(f"Retrying in {delay}s... (attempt {attempt + 2}/{_MAX_RETRIES})", file=sys.stderr) + time.sleep(delay) + + # Final attempt response = self._client.request(method, path, **kwargs) return self._handle_response(response) @@ -91,3 +135,10 @@ def delete(self, path: str) -> dict: def close(self): self._client.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + return False diff --git a/src/dualentry_cli/commands/__init__.py b/src/dualentry_cli/commands/__init__.py index 7794a9d..07c1704 100644 --- a/src/dualentry_cli/commands/__init__.py +++ b/src/dualentry_cli/commands/__init__.py @@ -52,6 +52,23 @@ def _do_list(client, path: str, resource: str, limit: int, offset: int, all_page format_output(data, resource=resource, fmt=output) +def _load_json_file(file: Path) -> dict: + """Load and validate a JSON file, with helpful error messages.""" + if not file.exists(): + typer.secho(f"Error: File not found: {file}", fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) + try: + content = file.read_text() + except OSError as e: + typer.secho(f"Error: Cannot read file: {e}", fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) from None + try: + return json.loads(content) + except json.JSONDecodeError as e: + typer.secho(f"Error: Invalid JSON in {file.name}: {e.msg} at line {e.lineno}", fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) from None + + # ── Factory ───────────────────────────────────────────────────────── @@ -123,7 +140,7 @@ def create_cmd( ): from dualentry_cli.main import get_client - payload = json.loads(file.read_text()) + payload = _load_json_file(file) client = get_client() data = client.post(f"/{path}/", json=payload) format_output(data, resource=resource, fmt=output) @@ -140,7 +157,7 @@ def update_cmd( ): from dualentry_cli.main import get_client - payload = json.loads(file.read_text()) + payload = _load_json_file(file) client = get_client() data = client.put(f"/{path}/{record_id}/", json=payload) format_output(data, resource=resource, fmt=output) diff --git a/src/dualentry_cli/commands/bills.py b/src/dualentry_cli/commands/bills.py deleted file mode 100644 index 81583c1..0000000 --- a/src/dualentry_cli/commands/bills.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Bill commands.""" - -from __future__ import annotations - -import json -from pathlib import Path - -import typer - -from dualentry_cli.cli import HelpfulGroup -from dualentry_cli.commands import AllPages, EndDate, Format, Limit, Offset, Search, StartDate, Status, _do_list -from dualentry_cli.output import format_output - -app = typer.Typer(help="Manage bills", no_args_is_help=True, cls=HelpfulGroup) - - -@app.command("list") -def list_bills( - limit: int = Limit, - offset: int = Offset, - all_pages: bool = AllPages, - search: str | None = Search, - status: str | None = Status, - start_date: str | None = StartDate, - end_date: str | None = EndDate, - output: str = Format, -): - """List bills.""" - from dualentry_cli.main import get_client - - client = get_client() - _do_list(client, "bills", "bill", limit, offset, all_pages, output, search=search, status=status, start_date=start_date, end_date=end_date) - - -@app.command("get") -def get_bill( - number: int = typer.Argument(help="Bill number"), - output: str = Format, -): - """Get a bill by number.""" - from dualentry_cli.main import get_client - - client = get_client() - data = client.get(f"/bills/{number}/") - format_output(data, resource="bill", fmt=output) - - -@app.command("create") -def create_bill( - file: Path = typer.Option(..., "--file", "-f", help="JSON file with bill data"), - output: str = Format, -): - """Create a bill from a JSON file.""" - from dualentry_cli.main import get_client - - payload = json.loads(file.read_text()) - client = get_client() - data = client.post("/bills/", json=payload) - format_output(data, resource="bill", fmt=output) diff --git a/src/dualentry_cli/commands/invoices.py b/src/dualentry_cli/commands/invoices.py deleted file mode 100644 index 2e54eb8..0000000 --- a/src/dualentry_cli/commands/invoices.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Invoice commands.""" - -from __future__ import annotations - -import json -from pathlib import Path - -import typer - -from dualentry_cli.cli import HelpfulGroup -from dualentry_cli.commands import AllPages, EndDate, Format, Limit, Offset, Search, StartDate, Status, _do_list -from dualentry_cli.output import format_output - -app = typer.Typer(help="Manage invoices", no_args_is_help=True, cls=HelpfulGroup) - - -@app.command("list") -def list_invoices( - limit: int = Limit, - offset: int = Offset, - all_pages: bool = AllPages, - search: str | None = Search, - status: str | None = Status, - start_date: str | None = StartDate, - end_date: str | None = EndDate, - output: str = Format, -): - """List invoices.""" - from dualentry_cli.main import get_client - - client = get_client() - _do_list(client, "invoices", "invoice", limit, offset, all_pages, output, search=search, status=status, start_date=start_date, end_date=end_date) - - -@app.command("get") -def get_invoice( - number: int = typer.Argument(help="Invoice number"), - output: str = Format, -): - """Get an invoice by number.""" - from dualentry_cli.main import get_client - - client = get_client() - data = client.get(f"/invoices/{number}/") - format_output(data, resource="invoice", fmt=output) - - -@app.command("create") -def create_invoice( - file: Path = typer.Option(..., "--file", "-f", help="JSON file with invoice data"), - output: str = Format, -): - """Create an invoice from a JSON file.""" - from dualentry_cli.main import get_client - - payload = json.loads(file.read_text()) - client = get_client() - data = client.post("/invoices/", json=payload) - format_output(data, resource="invoice", fmt=output) diff --git a/src/dualentry_cli/main.py b/src/dualentry_cli/main.py index 71e6a14..f669f91 100644 --- a/src/dualentry_cli/main.py +++ b/src/dualentry_cli/main.py @@ -8,8 +8,6 @@ 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.bills import app as bills_app -from dualentry_cli.commands.invoices import app as invoices_app from dualentry_cli.config import Config app = typer.Typer(name="dualentry", help="DualEntry accounting CLI", no_args_is_help=True, cls=HelpfulGroup) @@ -18,10 +16,10 @@ app.add_typer(auth_app, name="auth") app.add_typer(config_app, name="config") -# Custom-formatted resources -app.add_typer(invoices_app, name="invoices") -app.add_typer(bills_app, name="bills") -app.add_typer(accounts_app, name="accounts") +# Custom-formatted resources (use factory - output.py handles formatting via resource name) +app.add_typer(make_resource_app("invoices", "invoice", "invoices", has_number=True), name="invoices") +app.add_typer(make_resource_app("bills", "bill", "bills", has_number=True), 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), name="sales-orders") @@ -71,6 +69,9 @@ app.add_typer(make_resource_app("contracts", "contract", "contracts"), name="contracts") app.add_typer(make_resource_app("budgets", "budget", "budgets"), name="budgets") app.add_typer(make_resource_app("workflows", "workflow", "workflows", has_create=False, has_update=False), name="workflows") +app.add_typer(make_resource_app("intercompany journal entries", "intercompany-journal-entry", "intercompany-journal-entries", has_number=True), name="intercompany-journal-entries") +app.add_typer(make_resource_app("paper checks", "paper-check", "paper-checks", has_number=True), name="paper-checks") +app.add_typer(make_resource_app("inbox items", "inbox-item", "inbox", has_create=False, has_update=False), name="inbox") def version_callback(value: bool): @@ -82,13 +83,35 @@ def version_callback(value: bool): @app.callback() -def main(version: bool = typer.Option(False, "--version", "-v", help="Show version and exit.", callback=version_callback, is_eager=True)): +def main( + version: bool = typer.Option(False, "--version", "-v", help="Show version and exit.", callback=version_callback, is_eager=True), + retry: bool = typer.Option(False, "--retry", help="Retry transient errors (429, 503) with exponential backoff."), +): """DualEntry accounting CLI.""" + global _retry_enabled + _retry_enabled = retry from dualentry_cli.updater import check_for_updates check_for_updates() +@app.command() +def health(): + """Check API connectivity and status.""" + from dualentry_cli.client import APIError + + config = Config() + try: + client = get_client() + data = client.get("/health/") + typer.secho(f"API: {config.api_url}", fg=typer.colors.GREEN) + typer.secho(f"Status: {data.get('status', 'unknown')}", fg=typer.colors.GREEN) + typer.secho(f"Server time: {data.get('timestamp', 'unknown')}", fg=typer.colors.GREEN) + except APIError as e: + typer.secho(f"API error: {e.detail}", fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) from None + + @auth_app.command() def login(api_url: str = typer.Option(None, "--api-url", help="API base URL override")): """Log in to DualEntry via browser.""" @@ -150,6 +173,9 @@ def config_set_url(url: str = typer.Argument(help="Custom API base URL")): typer.echo(f"API URL set to {url}") +_retry_enabled: bool = False + + def get_client(): """Get an authenticated DualEntryClient.""" from dualentry_cli.client import DualEntryClient @@ -160,7 +186,7 @@ def get_client(): if not api_key: typer.echo("Not logged in. Run: dualentry auth login") raise typer.Exit(code=1) - return DualEntryClient(api_url=config.api_url, api_key=api_key) + return DualEntryClient(api_url=config.api_url, api_key=api_key, retry=_retry_enabled) def main_entrypoint(): diff --git a/src/dualentry_cli/output.py b/src/dualentry_cli/output.py index 2ecc7c6..26d4194 100644 --- a/src/dualentry_cli/output.py +++ b/src/dualentry_cli/output.py @@ -11,8 +11,6 @@ console = Console() -_format: str = "human" - # Resource name → display prefix (e.g. "invoice" → "IN") _RECORD_PREFIX: dict[str, str] = { "invoice": "IN", @@ -48,18 +46,8 @@ def _fmt_id(record_id, resource: str = "") -> str: return str(record_id) -def set_format(fmt: str) -> None: - global _format - _format = fmt - - -def get_format() -> str: - return _format - - -def format_output(data: dict, resource: str = "generic", fmt: str | None = None) -> None: - effective_fmt = fmt or _format - if effective_fmt == "json": +def format_output(data: dict, resource: str = "generic", fmt: str = "human") -> None: + if fmt == "json": print(json.dumps(data, indent=2)) return diff --git a/tests/test_client.py b/tests/test_client.py index 2d21dcf..a26ba2e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -51,3 +51,69 @@ def test_from_env_uses_api_key_env_var(self, monkeypatch): monkeypatch.setenv("X_API_KEY", "env_key_123") client = DualEntryClient.from_env(api_url="https://api.dualentry.com") assert client._client.headers["X-API-KEY"] == "env_key_123" + + +class TestErrorMessages: + """Test that error responses produce helpful messages.""" + + @respx.mock + def test_401_suggests_login(self): + from dualentry_cli.client import APIError, DualEntryClient + + respx.get("https://api.dualentry.com/public/v2/test/").mock(return_value=httpx.Response(401, json={"error": "unauthorized"})) + client = DualEntryClient(api_url="https://api.dualentry.com", api_key="bad_key") + with pytest.raises(APIError) as exc: + client.get("/test/") + assert "dualentry auth login" in exc.value.detail + + @respx.mock + def test_404_says_not_found(self): + from dualentry_cli.client import APIError, DualEntryClient + + respx.get("https://api.dualentry.com/public/v2/invoices/999/").mock(return_value=httpx.Response(404, json={"error": "not found"})) + client = DualEntryClient(api_url="https://api.dualentry.com", api_key="test_key") + with pytest.raises(APIError) as exc: + client.get("/invoices/999/") + assert "not found" in exc.value.detail.lower() + + @respx.mock + def test_422_shows_validation_details(self): + from dualentry_cli.client import APIError, DualEntryClient + + respx.post("https://api.dualentry.com/public/v2/invoices/").mock(return_value=httpx.Response(422, json={"errors": {"customer_id": ["required"]}})) + client = DualEntryClient(api_url="https://api.dualentry.com", api_key="test_key") + with pytest.raises(APIError) as exc: + client.post("/invoices/", json={}) + assert "validation" in exc.value.detail.lower() + assert "customer_id" in exc.value.detail + + @respx.mock + def test_429_says_rate_limited(self): + from dualentry_cli.client import APIError, DualEntryClient + + respx.get("https://api.dualentry.com/public/v2/invoices/").mock(return_value=httpx.Response(429, json={"error": "too many requests"})) + client = DualEntryClient(api_url="https://api.dualentry.com", api_key="test_key") + with pytest.raises(APIError) as exc: + client.get("/invoices/") + assert "rate limited" in exc.value.detail.lower() + + @respx.mock + def test_500_says_server_error(self): + from dualentry_cli.client import APIError, DualEntryClient + + respx.get("https://api.dualentry.com/public/v2/invoices/").mock(return_value=httpx.Response(500, text="Internal Server Error")) + client = DualEntryClient(api_url="https://api.dualentry.com", api_key="test_key") + with pytest.raises(APIError) as exc: + client.get("/invoices/") + assert "server error" in exc.value.detail.lower() + + +class TestContextManager: + """Test client as context manager.""" + + def test_context_manager_closes_client(self): + from dualentry_cli.client import DualEntryClient + + with DualEntryClient(api_url="https://api.dualentry.com", api_key="test_key") as client: + assert client._client is not None + assert client._client.is_closed diff --git a/tests/test_commands.py b/tests/test_commands.py index 242db67..f6cdf74 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -34,7 +34,11 @@ def test_invoices_list_json_output(self, mock_get_client): mock_get_client.get.return_value = {"items": [], "count": 0} result = runner.invoke(app, ["invoices", "list", "--format", "json"]) assert result.exit_code == 0 - parsed = json.loads(result.output) + # Filter out update notification lines (if present) to get just the JSON + output_lines = result.output.strip().split("\n") + json_start = next(i for i, line in enumerate(output_lines) if line.strip().startswith("{")) + json_output = "\n".join(output_lines[json_start:]) + parsed = json.loads(json_output) assert parsed == {"items": [], "count": 0} def test_invoices_get(self, mock_get_client): @@ -74,3 +78,52 @@ def test_accounts_get(self, mock_get_client): result = runner.invoke(app, ["accounts", "get", "1"]) assert result.exit_code == 0 assert "Cash" in result.output + + +class TestJsonFileValidation: + """Test JSON file loading and validation.""" + + def test_load_json_file_not_found(self, tmp_path): + from typer import Exit + + from dualentry_cli.commands import _load_json_file + + nonexistent = tmp_path / "nonexistent.json" + with pytest.raises(Exit) as exc: + _load_json_file(nonexistent) + assert exc.value.exit_code == 1 + + def test_load_json_file_invalid_json(self, tmp_path): + from typer import Exit + + from dualentry_cli.commands import _load_json_file + + invalid_json = tmp_path / "invalid.json" + invalid_json.write_text("{ this is not valid json }") + with pytest.raises(Exit) as exc: + _load_json_file(invalid_json) + assert exc.value.exit_code == 1 + + def test_load_json_file_valid(self, tmp_path): + from dualentry_cli.commands import _load_json_file + + valid_json = tmp_path / "valid.json" + valid_json.write_text('{"customer_id": 1, "amount": "100.00"}') + data = _load_json_file(valid_json) + assert data == {"customer_id": 1, "amount": "100.00"} + + @pytest.mark.usefixtures("mock_get_client") + def test_create_with_invalid_json_file(self, tmp_path): + """Test that create command shows helpful error for invalid JSON.""" + invalid_json = tmp_path / "invalid.json" + invalid_json.write_text("not valid json") + result = runner.invoke(app, ["invoices", "create", "--file", str(invalid_json)]) + assert result.exit_code == 1 + assert "Invalid JSON" in result.output or "Error" in result.output + + @pytest.mark.usefixtures("mock_get_client") + def test_create_with_missing_file(self, tmp_path): + """Test that create command shows helpful error for missing file.""" + result = runner.invoke(app, ["invoices", "create", "--file", str(tmp_path / "missing.json")]) + assert result.exit_code == 1 + assert "not found" in result.output.lower() or "Error" in result.output