diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..6f23a4d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## Summary + + + +## Changes + +- + +## Test plan + +- [ ] Unit tests pass (`uv run pytest`) +- [ ] Linter passes (`uv run ruff check .`) +- [ ] Manually tested with `dualentry ` diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cc26ea0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,113 @@ +name: Release + +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + build: + strategy: + matrix: + include: + - os: macos-14 + target: macos-arm64 + - os: macos-13 + target: macos-x86_64 + - os: ubuntu-latest + target: linux-x86_64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + with: + python-version: "3.12" + - name: Stamp version from tag + run: | + VERSION="${GITHUB_REF_NAME#v}" + sed -i.bak "s/^version = .*/version = \"$VERSION\"/" pyproject.toml + sed -i.bak "s/^__version__ = .*/__version__ = \"$VERSION\"/" src/dualentry_cli/__init__.py + rm -f pyproject.toml.bak src/dualentry_cli/__init__.py.bak + - run: uv sync --dev + - run: uv run python scripts/build.py + - name: Rename binary + run: mv dist/dualentry dist/dualentry-${{ matrix.target }} + - uses: actions/upload-artifact@v4 + with: + name: dualentry-${{ matrix.target }} + path: dist/dualentry-${{ matrix.target }} + + upload: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + - name: Upload binaries to release + env: + GH_TOKEN: ${{ github.token }} + run: | + for f in artifacts/*; do + gh release upload "${{ github.event.release.tag_name }}" "$f" --repo "${{ github.repository }}" + done + + update-tap: + needs: upload + runs-on: ubuntu-latest + steps: + - name: Update Homebrew tap + env: + TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + BASE_URL="https://github.com/${{ github.repository }}/releases/download/${{ github.event.release.tag_name }}" + + SHA_ARM64=$(curl -sL "$BASE_URL/dualentry-macos-arm64" | shasum -a 256 | cut -d' ' -f1) + SHA_X86_64=$(curl -sL "$BASE_URL/dualentry-macos-x86_64" | shasum -a 256 | cut -d' ' -f1) + SHA_LINUX=$(curl -sL "$BASE_URL/dualentry-linux-x86_64" | shasum -a 256 | cut -d' ' -f1) + + cat > /tmp/dualentry.rb << FORMULA + class Dualentry < Formula + desc "DualEntry accounting CLI" + homepage "https://github.com/${{ github.repository }}" + version "$VERSION" + + on_macos do + if Hardware::CPU.arm? + url "$BASE_URL/dualentry-macos-arm64" + sha256 "$SHA_ARM64" + else + url "$BASE_URL/dualentry-macos-x86_64" + sha256 "$SHA_X86_64" + end + end + + on_linux do + url "$BASE_URL/dualentry-linux-x86_64" + sha256 "$SHA_LINUX" + end + + def install + binary = Dir["dualentry-*"].first || "dualentry" + bin.install binary => "dualentry" + end + + test do + assert_match "dualentry-cli", shell_output("#{bin}/dualentry --version") + end + end + FORMULA + + git clone "https://x-access-token:${TAP_TOKEN}@github.com/dualentry/homebrew-tap.git" /tmp/tap + mkdir -p /tmp/tap/Formula + cp /tmp/dualentry.rb /tmp/tap/Formula/dualentry.rb + cd /tmp/tap + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Formula/dualentry.rb + git commit -m "Update dualentry to $VERSION" + git push diff --git a/.gitignore b/.gitignore index 1e38c2d..244f926 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ build/ .eggs/ uv.lock +*.spec diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2657924 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## [0.1.0] - 2026-03-31 + +- OAuth browser login with API key storage in system keychain +- List, get, create, and update for all transaction types +- Table and JSON output formats +- Pagination, search, and date/status filtering +- Homebrew tap and install script distribution +- Auto-update checker diff --git a/install.sh b/install.sh index 3be32f7..fa69220 100755 --- a/install.sh +++ b/install.sh @@ -1,28 +1,56 @@ -#!/usr/bin/env bash -set -euo pipefail +#!/bin/sh +set -e -# DualEntry CLI installer -# Usage: curl -sSL /install.sh | bash +REPO="dualentry/dualentry-cli" +INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" -REPO="git+https://github.com/dualentry/dualentry-cli.git" -TOOL_NAME="dualentry-cli" +get_arch() { + case "$(uname -m)" in + x86_64|amd64) echo "x86_64" ;; + arm64|aarch64) echo "arm64" ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; + esac +} -echo "Installing DualEntry CLI..." +get_os() { + case "$(uname -s)" in + Darwin) echo "macos" ;; + Linux) echo "linux" ;; + *) echo "Unsupported OS: $(uname -s)" >&2; exit 1 ;; + esac +} -# Prefer uv, fall back to pipx -if command -v uv &>/dev/null; then - echo "Using uv..." - uv tool install "$REPO" -elif command -v pipx &>/dev/null; then - echo "Using pipx..." - pipx install "$REPO" +OS=$(get_os) +ARCH=$(get_arch) +TARGET="${OS}-${ARCH}" + +if [ "$OS" = "linux" ] && [ "$ARCH" = "arm64" ]; then + echo "Linux arm64 is not currently supported." >&2 + exit 1 +fi + +echo "Detecting latest release..." +LATEST=$(curl -sI "https://github.com/${REPO}/releases/latest" | grep -i "^location:" | sed 's/.*tag\///' | tr -d '\r') + +if [ -z "$LATEST" ]; then + echo "Failed to detect latest release." >&2 + exit 1 +fi + +URL="https://github.com/${REPO}/releases/download/${LATEST}/dualentry-${TARGET}" + +echo "Downloading dualentry ${LATEST} for ${TARGET}..." +curl -fSL "$URL" -o /tmp/dualentry + +chmod +x /tmp/dualentry +mkdir -p "$INSTALL_DIR" + +if [ -w "$INSTALL_DIR" ]; then + mv /tmp/dualentry "$INSTALL_DIR/dualentry" else - echo "Error: requires uv or pipx" - echo "" - echo "Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh" - echo "Install pipx: brew install pipx && pipx ensurepath" - exit 1 + echo "Installing to ${INSTALL_DIR} (requires sudo)..." + sudo mv /tmp/dualentry "$INSTALL_DIR/dualentry" fi -echo "" -echo "Installed! Run: dualentry auth login" +echo "Installed dualentry to ${INSTALL_DIR}/dualentry" +echo "Run 'dualentry --help' to get started." diff --git a/pyproject.toml b/pyproject.toml index c047573..c28a0f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,16 +23,6 @@ packages = ["src/dualentry_cli"] [tool.pytest.ini_options] testpaths = ["tests"] -[project.optional-dependencies] -dev = [ - "pytest>=8.0", - "pytest-cov>=6.0", - "pytest-mock>=3.14", - "respx>=0.21", - "ruff>=0.11.11", - "pre-commit>=4.2", -] - [dependency-groups] dev = [ "pytest>=8.0", @@ -41,6 +31,7 @@ dev = [ "respx>=0.21", "ruff>=0.11.11", "pre-commit>=4.2", + "pyinstaller>=6.0", ] # ── Ruff ─────────────────────────────────────────────────────────────── @@ -120,12 +111,14 @@ ignore = [ "T201", "PLW0603", "PLW2901", + "PLR0912", "PLR0915", "F841", "SIM105", ] [tool.ruff.lint.per-file-ignores] +"scripts/*" = ["INP001", "S603"] "tests/*" = [ "S101", "S105", diff --git a/scripts/build.py b/scripts/build.py new file mode 100644 index 0000000..71ea8a7 --- /dev/null +++ b/scripts/build.py @@ -0,0 +1,48 @@ +"""Build standalone binary for DualEntry CLI using PyInstaller.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +BUILD_INFO = ROOT / "src" / "dualentry_cli" / "_build_info.py" + +PROD_BUILD_INFO = '''\ +"""Build-time configuration — generated by CI.""" + +BUILD_MODE = "prod" +DEFAULT_API_URL = "https://api.dualentry.com" +''' + + +def main(): + original = BUILD_INFO.read_text() + try: + BUILD_INFO.write_text(PROD_BUILD_INFO) + + subprocess.run( + [ + sys.executable, + "-m", + "PyInstaller", + "--onefile", + "--name", + "dualentry", + "--strip", + "--noconfirm", + str(ROOT / "src" / "dualentry_cli" / "main.py"), + ], + cwd=str(ROOT), + check=True, + ) + + print(f"\nBinary built: {ROOT / 'dist' / 'dualentry'}") + finally: + BUILD_INFO.write_text(original) + + +if __name__ == "__main__": + main() diff --git a/src/dualentry_cli/_build_info.py b/src/dualentry_cli/_build_info.py new file mode 100644 index 0000000..107406d --- /dev/null +++ b/src/dualentry_cli/_build_info.py @@ -0,0 +1,4 @@ +"""Build-time configuration.""" + +BUILD_MODE = "prod" +DEFAULT_API_URL = "https://api.dualentry.com" diff --git a/src/dualentry_cli/auth.py b/src/dualentry_cli/auth.py index 07d21be..74f330d 100644 --- a/src/dualentry_cli/auth.py +++ b/src/dualentry_cli/auth.py @@ -1,4 +1,4 @@ -"""Authentication for DualEntry CLI - OAuth flow via MCP endpoints and credential storage.""" +"""Authentication for DualEntry CLI - OAuth 2.1 with PKCE via public API endpoints.""" from __future__ import annotations @@ -8,37 +8,16 @@ import secrets import socket import webbrowser -from enum import StrEnum from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path -from urllib.parse import parse_qs, urlencode, urlparse +from urllib.parse import parse_qs, urlparse import httpx import keyring import typer - -class CodeChallengeMethod(StrEnum): - S256 = "S256" - - -class GrantType(StrEnum): - AUTHORIZATION_CODE = "authorization_code" - REFRESH_TOKEN = "refresh_token" # noqa: S105 - - -class ResponseType(StrEnum): - CODE = "code" - - -class TokenEndpointAuthMethod(StrEnum): - NONE = "none" - - _SERVICE_NAME = "dualentry-cli" -_KEY_NAME_ACCESS = "access_token" -_KEY_NAME_REFRESH = "refresh_token" -_KEY_NAME_API_KEY = "api_key" # legacy, still checked for migration +_KEY_NAME_API_KEY = "api_key" _TOKEN_FILE = Path.home() / ".dualentry" / "tokens.json" @@ -51,55 +30,42 @@ def _generate_pkce_pair() -> tuple[str, str]: return verifier, challenge -# ── Token storage ──────────────────────────────────────────────────── +# -- Credential storage ------------------------------------------------ -def store_tokens(access_token: str, refresh_token: str) -> None: - """Store OAuth tokens. Uses keyring with file fallback.""" +def store_api_key(api_key: str) -> None: + """Store API key. Uses keyring with file fallback.""" try: - keyring.set_password(_SERVICE_NAME, _KEY_NAME_ACCESS, access_token) - keyring.set_password(_SERVICE_NAME, _KEY_NAME_REFRESH, refresh_token) + keyring.set_password(_SERVICE_NAME, _KEY_NAME_API_KEY, api_key) except Exception: - # Fallback to file storage (e.g. CI, headless) _TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True) - _TOKEN_FILE.write_text(json.dumps({"access_token": access_token, "refresh_token": refresh_token})) + _TOKEN_FILE.write_text(json.dumps({"api_key": api_key})) _TOKEN_FILE.chmod(0o600) -def load_tokens() -> tuple[str | None, str | None]: - """Load OAuth tokens. Returns (access_token, refresh_token).""" +def load_api_key() -> str | None: + """Load stored API key.""" try: - access = keyring.get_password(_SERVICE_NAME, _KEY_NAME_ACCESS) - refresh = keyring.get_password(_SERVICE_NAME, _KEY_NAME_REFRESH) - if access and refresh: - return access, refresh + key = keyring.get_password(_SERVICE_NAME, _KEY_NAME_API_KEY) + if key: + return key except Exception: pass - # File fallback if _TOKEN_FILE.exists(): try: data = json.loads(_TOKEN_FILE.read_text()) - return data.get("access_token"), data.get("refresh_token") + return data.get("api_key") except (json.JSONDecodeError, OSError): pass - return None, None - - -def load_api_key() -> str | None: - """Load legacy API key (for X_API_KEY env var compat check).""" - try: - return keyring.get_password(_SERVICE_NAME, _KEY_NAME_API_KEY) - except Exception: - return None + return None def clear_credentials() -> None: """Clear all stored credentials.""" - for key in (_KEY_NAME_ACCESS, _KEY_NAME_REFRESH, _KEY_NAME_API_KEY): - try: - keyring.delete_password(_SERVICE_NAME, key) - except Exception: - pass + try: + keyring.delete_password(_SERVICE_NAME, _KEY_NAME_API_KEY) + except Exception: + pass if _TOKEN_FILE.exists(): try: _TOKEN_FILE.unlink() @@ -107,81 +73,58 @@ def clear_credentials() -> None: pass -# legacy alias -clear_api_key = clear_credentials +# -- OAuth endpoints --------------------------------------------------- -# ── MCP OAuth client registration ─────────────────────────────────── - - -def _register_client(mcp_url: str, redirect_uri: str) -> dict: - """Register as an OAuth client with the MCP server (dynamic client registration).""" +def _authorize(api_url: str, redirect_uri: str, code_challenge: str, state: str) -> str: + """POST /public/v2/oauth/authorize/ — returns the WorkOS authorization URL.""" response = httpx.post( - f"{mcp_url}/register", + f"{api_url.rstrip('/')}/public/v2/oauth/authorize/", json={ - "client_name": "DualEntry CLI", - "redirect_uris": [redirect_uri], - "grant_types": [GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN], - "response_types": [ResponseType.CODE], - "token_endpoint_auth_method": TokenEndpointAuthMethod.NONE, + "redirect_uri": redirect_uri, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "state": state, }, timeout=30.0, ) - response.raise_for_status() - return response.json() - - -# ── OAuth flow ─────────────────────────────────────────────────────── - - -def _start_authorize(mcp_url: str, client_id: str, redirect_uri: str, code_challenge: str, state: str) -> str: - """Build the authorization URL and return it (the MCP /authorize endpoint redirects to WorkOS).""" - params = { - "response_type": ResponseType.CODE, - "client_id": client_id, - "redirect_uri": redirect_uri, - "code_challenge": code_challenge, - "code_challenge_method": CodeChallengeMethod.S256, - "state": state, - } - return f"{mcp_url}/authorize?{urlencode(params)}" + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + try: + detail = exc.response.json() + except Exception: + detail = exc.response.text + typer.echo(f"Authorization failed (HTTP {exc.response.status_code}): {detail}", err=True) + raise typer.Exit(code=1) from None + return response.json()["authorization_url"] -def _exchange_token(mcp_url: str, client_id: str, code: str, code_verifier: str, redirect_uri: str) -> dict: - """Exchange authorization code for access/refresh tokens at MCP /token endpoint.""" +def _exchange_code(api_url: str, code: str, code_verifier: str, redirect_uri: str) -> dict: + """POST /public/v2/oauth/token/ — exchange auth code for API key.""" response = httpx.post( - f"{mcp_url}/token", - data={ - "grant_type": GrantType.AUTHORIZATION_CODE, - "client_id": client_id, + f"{api_url.rstrip('/')}/public/v2/oauth/token/", + json={ + "grant_type": "authorization_code", "code": code, "code_verifier": code_verifier, "redirect_uri": redirect_uri, }, - headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=30.0, ) - response.raise_for_status() - return response.json() - - -def refresh_access_token(mcp_url: str, client_id: str, refresh_token: str) -> dict: - """Use refresh token to get a new access/refresh token pair.""" - response = httpx.post( - f"{mcp_url}/token", - data={ - "grant_type": GrantType.REFRESH_TOKEN, - "client_id": client_id, - "refresh_token": refresh_token, - }, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - timeout=30.0, - ) - response.raise_for_status() + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + try: + detail = exc.response.json() + except Exception: + detail = exc.response.text + typer.echo(f"Token exchange failed (HTTP {exc.response.status_code}): {detail}", err=True) + raise typer.Exit(code=1) from None return response.json() -# ── Local callback server ──────────────────────────────────────────── +# -- Local callback server --------------------------------------------- def _find_free_port() -> int: @@ -212,28 +155,22 @@ def log_message(self, format, *args): pass -# ── Main login flow ────────────────────────────────────────────────── +# -- Main login flow --------------------------------------------------- def run_login_flow(api_url: str) -> dict: """ - Run the full OAuth login flow using MCP endpoints. + Run the full OAuth login flow via /public/v2/oauth/ endpoints. - Returns dict with access_token, refresh_token, and token metadata. + Returns dict with api_key, organization_id, user_email. """ - mcp_url = f"{api_url.rstrip('/')}/mcp" - port = _find_free_port() redirect_uri = f"http://localhost:{port}/callback" verifier, challenge = _generate_pkce_pair() state = secrets.token_urlsafe(16) - # Register as OAuth client - client_info = _register_client(mcp_url, redirect_uri) - client_id = client_info["client_id"] - - # Build authorize URL - auth_url = _start_authorize(mcp_url, client_id, redirect_uri, challenge, state) + # Get authorization URL from backend + auth_url = _authorize(api_url, redirect_uri, challenge, state) # Start local server and open browser _CallbackHandler.code = None @@ -244,7 +181,12 @@ def run_login_flow(api_url: str) -> dict: typer.echo(f"If the browser doesn't open, visit: {auth_url}") webbrowser.open(auth_url) - server.handle_request() + try: + server.handle_request() + except KeyboardInterrupt: + server.server_close() + typer.echo("\nLogin cancelled.") + raise typer.Exit(code=1) from None server.server_close() if not _CallbackHandler.code: @@ -254,12 +196,11 @@ def run_login_flow(api_url: str) -> dict: typer.echo("State mismatch - possible CSRF attack.") raise typer.Exit(code=1) - # Exchange code for tokens - token_response = _exchange_token(mcp_url, client_id, _CallbackHandler.code, verifier, redirect_uri) + # Exchange code for API key + token_response = _exchange_code(api_url, _CallbackHandler.code, verifier, redirect_uri) return { - "access_token": token_response["access_token"], - "refresh_token": token_response.get("refresh_token", ""), - "expires_in": token_response.get("expires_in"), - "client_id": client_id, + "api_key": token_response["api_key"], + "organization_id": token_response["organization_id"], + "user_email": token_response["user_email"], } diff --git a/src/dualentry_cli/client.py b/src/dualentry_cli/client.py index 67bb89f..386a8b5 100644 --- a/src/dualentry_cli/client.py +++ b/src/dualentry_cli/client.py @@ -16,23 +16,14 @@ def __init__(self, status_code: int, detail: str): class DualEntryClient: - def __init__(self, api_url: str, *, access_token: str | None = None, refresh_token: str | None = None, client_id: str | None = None, api_key: str | None = None): + def __init__(self, api_url: str, *, api_key: str): self._api_url = api_url.rstrip("/") self._base_url = f"{self._api_url}/public/v2" - self._access_token = access_token - self._refresh_token = refresh_token - self._client_id = client_id - self._api_key = api_key - - headers = self._build_headers() - self._client = httpx.Client(base_url=self._base_url, headers=headers, timeout=30.0) - - def _build_headers(self) -> dict[str, str]: - if self._api_key: - return {"X-API-KEY": self._api_key} - if self._access_token: - return {"Authorization": f"Bearer {self._access_token}"} - return {} + self._client = httpx.Client( + base_url=self._base_url, + headers={"X-API-KEY": api_key}, + timeout=30.0, + ) @classmethod def from_env(cls, api_url: str) -> DualEntryClient: @@ -42,28 +33,9 @@ def from_env(cls, api_url: str) -> DualEntryClient: raise ValueError(msg) return cls(api_url=api_url, api_key=api_key) - def _try_refresh(self) -> bool: - """Attempt to refresh the access token. Returns True if successful.""" - if not self._refresh_token or not self._client_id: - return False - try: - from dualentry_cli.auth import refresh_access_token, store_tokens - - mcp_url = f"{self._api_url}/mcp" - token_response = refresh_access_token(mcp_url, self._client_id, self._refresh_token) - self._access_token = token_response["access_token"] - self._refresh_token = token_response.get("refresh_token", self._refresh_token) - store_tokens(self._access_token, self._refresh_token) - self._client.headers.update({"Authorization": f"Bearer {self._access_token}"}) - except Exception as exc: - import sys - - print(f"Token refresh failed: {exc}. Re-login with: dualentry auth login", file=sys.stderr) - return False - else: - return True - def _handle_response(self, response: httpx.Response) -> dict: + if response.status_code == 401: + raise APIError(401, "API key is invalid or expired. Run: dualentry auth login") if response.status_code >= 400: try: detail = response.json() @@ -74,8 +46,6 @@ def _handle_response(self, response: httpx.Response) -> dict: def _request(self, method: str, path: str, **kwargs) -> dict: response = self._client.request(method, path, **kwargs) - if response.status_code in (401, 403) and self._access_token and self._try_refresh(): - response = self._client.request(method, path, **kwargs) return self._handle_response(response) def get(self, path: str, params: dict[str, Any] | None = None) -> dict: @@ -87,7 +57,7 @@ def paginate(self, path: str, params: dict[str, Any] | None = None, page_size: i params["limit"] = page_size params["offset"] = 0 all_items = [] - max_pages = 1000 # safety guard against infinite loops + max_pages = 1000 for _ in range(max_pages): data = self.get(path, params=params) diff --git a/src/dualentry_cli/commands/__init__.py b/src/dualentry_cli/commands/__init__.py index 62e354b..7794a9d 100644 --- a/src/dualentry_cli/commands/__init__.py +++ b/src/dualentry_cli/commands/__init__.py @@ -89,7 +89,7 @@ def list_cmd( @app.command("get") def get_cmd_with_number( - number: int = typer.Argument(help="Record number"), + number: str = typer.Argument(help="Record number (the # column)"), output: str = Format, ): from dualentry_cli.main import get_client diff --git a/src/dualentry_cli/config.py b/src/dualentry_cli/config.py index 05e9117..2827f42 100644 --- a/src/dualentry_cli/config.py +++ b/src/dualentry_cli/config.py @@ -6,24 +6,20 @@ import tomllib from pathlib import Path +from dualentry_cli._build_info import DEFAULT_API_URL + _DEFAULT_CONFIG_DIR = Path.home() / ".dualentry" _CONFIG_FILENAME = "config.toml" -ENVIRONMENTS = { - "prod": "https://api.dualentry.com", - "dev": "https://api-dev.dualentry.com", -} - class Config: def __init__(self, config_dir: Path | None = None): self._config_dir = config_dir or _DEFAULT_CONFIG_DIR self._config_file = self._config_dir / _CONFIG_FILENAME - self.api_url: str = ENVIRONMENTS["prod"] + self.api_url: str = DEFAULT_API_URL self.output: str = "table" self.organization_id: int | None = None self.user_email: str | None = None - self.client_id: str | None = None self._load() # Env var overrides config file env_url = os.environ.get("DUALENTRY_API_URL") @@ -41,15 +37,6 @@ def _load(self): auth = data.get("auth", {}) self.organization_id = auth.get("organization_id") self.user_email = auth.get("user_email") - self.client_id = auth.get("client_id") - - @property - def env_name(self) -> str: - """Return the environment name based on the current api_url.""" - for name, url in ENVIRONMENTS.items(): - if self.api_url == url: - return name - return "custom" @staticmethod def _escape_toml_string(value: str) -> str: @@ -64,14 +51,12 @@ def save(self): f'output = "{self._escape_toml_string(self.output)}"', "", ] - has_auth = any(v is not None for v in (self.organization_id, self.user_email, self.client_id)) + has_auth = any(v is not None for v in (self.organization_id, self.user_email)) if has_auth: lines.append("[auth]") if self.organization_id is not None: lines.append(f"organization_id = {self.organization_id}") if self.user_email is not None: lines.append(f'user_email = "{self._escape_toml_string(self.user_email)}"') - if self.client_id is not None: - lines.append(f'client_id = "{self._escape_toml_string(self.client_id)}"') lines.append("") self._config_file.write_text("\n".join(lines)) diff --git a/src/dualentry_cli/main.py b/src/dualentry_cli/main.py index 348cef3..9c87ea0 100644 --- a/src/dualentry_cli/main.py +++ b/src/dualentry_cli/main.py @@ -4,13 +4,13 @@ import typer -from dualentry_cli.auth import clear_credentials, load_tokens, run_login_flow, store_tokens +from dualentry_cli.auth import clear_credentials, load_api_key, run_login_flow, store_api_key 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 ENVIRONMENTS, Config +from dualentry_cli.config import Config app = typer.Typer(name="dualentry", help="DualEntry accounting CLI", no_args_is_help=True, cls=HelpfulGroup) auth_app = typer.Typer(help="Authentication commands", no_args_is_help=True, cls=HelpfulGroup) @@ -95,10 +95,11 @@ def login(api_url: str = typer.Option(None, "--api-url", help="API base URL over config = Config() url = api_url or config.api_url result = run_login_flow(api_url=url) - store_tokens(result["access_token"], result["refresh_token"]) - config.client_id = result["client_id"] + store_api_key(result["api_key"]) + config.organization_id = result["organization_id"] + config.user_email = result["user_email"] config.save() - typer.echo("Logged in successfully.") + typer.echo(f"Logged in as {result['user_email']} (org {result['organization_id']}).") @auth_app.command() @@ -115,38 +116,29 @@ def status(): if env_key: typer.echo("Authenticated via X_API_KEY environment variable") return - access_token, refresh_token = load_tokens() - if not access_token: + api_key = load_api_key() + if not api_key: typer.echo("Not logged in. Run: dualentry auth login") raise typer.Exit(code=1) config = Config() typer.echo(f"API URL: {config.api_url}") - typer.echo("Authenticated via OAuth tokens") - if refresh_token: - typer.echo("Refresh token: present") + if config.user_email: + typer.echo(f"User: {config.user_email}") + if config.organization_id: + typer.echo(f"Organization: {config.organization_id}") + typer.echo("Authenticated via API key") @config_app.command("show") def config_show(): """Show current configuration.""" config = Config() - typer.echo(f"Environment: {config.env_name}") typer.echo(f"API URL: {config.api_url}") typer.echo(f"Output format: {config.output}") - if config.client_id: - typer.echo(f"OAuth client ID: {config.client_id}") - - -@config_app.command("set-env") -def config_set_env(env: str = typer.Argument(help=f"Environment name: {', '.join(ENVIRONMENTS)}")): - """Switch between environments (dev, prod).""" - if env not in ENVIRONMENTS: - typer.echo(f"Unknown environment '{env}'. Available: {', '.join(ENVIRONMENTS)}") - raise typer.Exit(code=1) - config = Config() - config.api_url = ENVIRONMENTS[env] - config.save() - typer.echo(f"Switched to {env} ({ENVIRONMENTS[env]})") + if config.user_email: + typer.echo(f"User: {config.user_email}") + if config.organization_id: + typer.echo(f"Organization: {config.organization_id}") @config_app.command("set-url") @@ -164,18 +156,11 @@ def get_client(): config = Config() env_key = os.environ.get("X_API_KEY") - if env_key: - return DualEntryClient(api_url=config.api_url, api_key=env_key) - access_token, refresh_token = load_tokens() - if not access_token: + api_key = env_key or load_api_key() + 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, - access_token=access_token, - refresh_token=refresh_token, - client_id=config.client_id, - ) + return DualEntryClient(api_url=config.api_url, api_key=api_key) if __name__ == "__main__": diff --git a/src/dualentry_cli/output.py b/src/dualentry_cli/output.py index 2c6ed04..2ecc7c6 100644 --- a/src/dualentry_cli/output.py +++ b/src/dualentry_cli/output.py @@ -13,6 +13,40 @@ _format: str = "human" +# Resource name → display prefix (e.g. "invoice" → "IN") +_RECORD_PREFIX: dict[str, str] = { + "invoice": "IN", + "bill": "BI", + "sales-order": "SO", + "purchase-order": "PO", + "customer-payment": "CP", + "customer-credit": "CC", + "customer-prepayment": "CPP", + "customer-prepayment-application": "CPA", + "customer-deposit": "CD", + "customer-refund": "CR", + "cash-sale": "CS", + "direct-expense": "DE", + "vendor-payment": "VP", + "vendor-credit": "VC", + "vendor-prepayment": "VPP", + "vendor-prepayment-application": "VPA", + "vendor-refund": "VR", + "journal-entry": "JE", + "bank-transfer": "BT", + "fixed-asset": "FA", +} + + +def _fmt_id(record_id, resource: str = "") -> str: + """Format a record ID with its prefix, e.g. 135934 → IN-135934 (matches UI display).""" + if record_id is None: + return "-" + prefix = _RECORD_PREFIX.get(resource, "") + if prefix: + return f"{prefix}-{record_id}" + return str(record_id) + def set_format(fmt: str) -> None: global _format @@ -82,8 +116,10 @@ def _transaction_list( show_paid: bool = False, show_remaining: bool = False, show_memo: bool = False, + resource: str = "", ): table = Table(title=title, show_lines=False) + table.add_column("ID", style="dim", justify="right") table.add_column("#", style="bold", justify="right") table.add_column("Date", justify="center") table.add_column("Company") @@ -104,6 +140,7 @@ def _transaction_list( for r in items: currency = r.get("currency_iso_4217_code", "") row = [ + _fmt_id(r.get("internal_id"), resource), str(r.get("number", "")), r.get("date", "-"), r.get("company_name", "-"), @@ -137,12 +174,13 @@ def _transaction_detail( counterparty_label: str, counterparty_field: str, due_color: str = "green", + resource: str = "", ): currency = record.get("currency_iso_4217_code") or record.get("company_currency", "") header = Text() header.append(record_type.upper(), style="bold") - header.append(f" #{record.get('number', '')}", style="bold cyan") + header.append(f" {_fmt_id(record.get('internal_id'), resource)}", style="bold cyan") status = record.get("record_status", "") if status: header.append(f" {status.upper()}", style=_status_color(status)) @@ -170,6 +208,8 @@ def _transaction_detail( details = Table.grid(padding=(0, 2)) details.add_column(style="dim", min_width=16) details.add_column() + if record.get("number"): + details.add_row("Number:", str(record["number"])) details.add_row("Date:", record.get("date", "-")) if record.get("due_date"): details.add_row("Due Date:", record["due_date"]) @@ -229,11 +269,11 @@ def _transaction_detail( def _invoice_list(items): - _transaction_list(items, "Invoices", "Customer", "customer_name", show_due_date=True, show_paid=True) + _transaction_list(items, "Invoices", "Customer", "customer_name", show_due_date=True, show_paid=True, resource="invoice") def _invoice_detail(r): - _transaction_detail(r, "Invoice", "Customer", "customer_name", due_color="green") + _transaction_detail(r, "Invoice", "Customer", "customer_name", due_color="green", resource="invoice") _register("invoice", _invoice_list, _invoice_detail) @@ -243,11 +283,11 @@ def _invoice_detail(r): def _bill_list(items): - _transaction_list(items, "Bills", "Vendor", "vendor_name", show_due_date=True, show_paid=True) + _transaction_list(items, "Bills", "Vendor", "vendor_name", show_due_date=True, show_paid=True, resource="bill") def _bill_detail(r): - _transaction_detail(r, "Bill", "Vendor", "vendor_name", due_color="red") + _transaction_detail(r, "Bill", "Vendor", "vendor_name", due_color="red", resource="bill") _register("bill", _bill_list, _bill_detail) @@ -257,11 +297,11 @@ def _bill_detail(r): def _sales_order_list(items): - _transaction_list(items, "Sales Orders", "Customer", "customer_name") + _transaction_list(items, "Sales Orders", "Customer", "customer_name", resource="sales-order") def _sales_order_detail(r): - _transaction_detail(r, "Sales Order", "Customer", "customer_name") + _transaction_detail(r, "Sales Order", "Customer", "customer_name", resource="sales-order") _register("sales-order", _sales_order_list, _sales_order_detail) @@ -271,11 +311,11 @@ def _sales_order_detail(r): def _purchase_order_list(items): - _transaction_list(items, "Purchase Orders", "Vendor", "vendor_name") + _transaction_list(items, "Purchase Orders", "Vendor", "vendor_name", resource="purchase-order") def _purchase_order_detail(r): - _transaction_detail(r, "Purchase Order", "Vendor", "vendor_name") + _transaction_detail(r, "Purchase Order", "Vendor", "vendor_name", resource="purchase-order") _register("purchase-order", _purchase_order_list, _purchase_order_detail) @@ -285,11 +325,11 @@ def _purchase_order_detail(r): def _cash_sale_list(items): - _transaction_list(items, "Cash Sales", "Customer", "customer_name") + _transaction_list(items, "Cash Sales", "Customer", "customer_name", resource="cash-sale") def _cash_sale_detail(r): - _transaction_detail(r, "Cash Sale", "Customer", "customer_name") + _transaction_detail(r, "Cash Sale", "Customer", "customer_name", resource="cash-sale") _register("cash-sale", _cash_sale_list, _cash_sale_detail) @@ -299,11 +339,11 @@ def _cash_sale_detail(r): def _direct_expense_list(items): - _transaction_list(items, "Direct Expenses", "Vendor", "vendor_name") + _transaction_list(items, "Direct Expenses", "Vendor", "vendor_name", resource="direct-expense") def _direct_expense_detail(r): - _transaction_detail(r, "Direct Expense", "Vendor", "vendor_name") + _transaction_detail(r, "Direct Expense", "Vendor", "vendor_name", resource="direct-expense") _register("direct-expense", _direct_expense_list, _direct_expense_detail) @@ -313,11 +353,11 @@ def _direct_expense_detail(r): def _customer_payment_list(items): - _transaction_list(items, "Customer Payments", "Customer", "customer_name", show_memo=True) + _transaction_list(items, "Customer Payments", "Customer", "customer_name", show_memo=True, resource="customer-payment") def _customer_payment_detail(r): - _transaction_detail(r, "Customer Payment", "Customer", "customer_name") + _transaction_detail(r, "Customer Payment", "Customer", "customer_name", resource="customer-payment") _register("customer-payment", _customer_payment_list, _customer_payment_detail) @@ -327,11 +367,11 @@ def _customer_payment_detail(r): def _customer_credit_list(items): - _transaction_list(items, "Customer Credits", "Customer", "customer_name", show_remaining=True) + _transaction_list(items, "Customer Credits", "Customer", "customer_name", show_remaining=True, resource="customer-credit") def _customer_credit_detail(r): - _transaction_detail(r, "Customer Credit", "Customer", "customer_name") + _transaction_detail(r, "Customer Credit", "Customer", "customer_name", resource="customer-credit") _register("customer-credit", _customer_credit_list, _customer_credit_detail) @@ -341,11 +381,11 @@ def _customer_credit_detail(r): def _customer_prepayment_list(items): - _transaction_list(items, "Customer Prepayments", "Customer", "customer_name", show_remaining=True) + _transaction_list(items, "Customer Prepayments", "Customer", "customer_name", show_remaining=True, resource="customer-prepayment") def _customer_prepayment_detail(r): - _transaction_detail(r, "Customer Prepayment", "Customer", "customer_name") + _transaction_detail(r, "Customer Prepayment", "Customer", "customer_name", resource="customer-prepayment") _register("customer-prepayment", _customer_prepayment_list, _customer_prepayment_detail) @@ -355,11 +395,11 @@ def _customer_prepayment_detail(r): def _customer_prepayment_app_list(items): - _transaction_list(items, "Customer Prepayment Applications", "Customer", "customer_name") + _transaction_list(items, "Customer Prepayment Applications", "Customer", "customer_name", resource="customer-prepayment-application") def _customer_prepayment_app_detail(r): - _transaction_detail(r, "Customer Prepayment Application", "Customer", "customer_name") + _transaction_detail(r, "Customer Prepayment Application", "Customer", "customer_name", resource="customer-prepayment-application") _register("customer-prepayment-application", _customer_prepayment_app_list, _customer_prepayment_app_detail) @@ -369,11 +409,11 @@ def _customer_prepayment_app_detail(r): def _customer_deposit_list(items): - _transaction_list(items, "Customer Deposits", "Customer", "customer_name", show_memo=True) + _transaction_list(items, "Customer Deposits", "Customer", "customer_name", show_memo=True, resource="customer-deposit") def _customer_deposit_detail(r): - _transaction_detail(r, "Customer Deposit", "Customer", "customer_name") + _transaction_detail(r, "Customer Deposit", "Customer", "customer_name", resource="customer-deposit") _register("customer-deposit", _customer_deposit_list, _customer_deposit_detail) @@ -383,11 +423,11 @@ def _customer_deposit_detail(r): def _customer_refund_list(items): - _transaction_list(items, "Customer Refunds", "Customer", "customer_name") + _transaction_list(items, "Customer Refunds", "Customer", "customer_name", resource="customer-refund") def _customer_refund_detail(r): - _transaction_detail(r, "Customer Refund", "Customer", "customer_name") + _transaction_detail(r, "Customer Refund", "Customer", "customer_name", resource="customer-refund") _register("customer-refund", _customer_refund_list, _customer_refund_detail) @@ -397,11 +437,11 @@ def _customer_refund_detail(r): def _vendor_payment_list(items): - _transaction_list(items, "Vendor Payments", "Vendor", "vendor_name", show_memo=True) + _transaction_list(items, "Vendor Payments", "Vendor", "vendor_name", show_memo=True, resource="vendor-payment") def _vendor_payment_detail(r): - _transaction_detail(r, "Vendor Payment", "Vendor", "vendor_name") + _transaction_detail(r, "Vendor Payment", "Vendor", "vendor_name", resource="vendor-payment") _register("vendor-payment", _vendor_payment_list, _vendor_payment_detail) @@ -411,11 +451,11 @@ def _vendor_payment_detail(r): def _vendor_credit_list(items): - _transaction_list(items, "Vendor Credits", "Vendor", "vendor_name", show_remaining=True) + _transaction_list(items, "Vendor Credits", "Vendor", "vendor_name", show_remaining=True, resource="vendor-credit") def _vendor_credit_detail(r): - _transaction_detail(r, "Vendor Credit", "Vendor", "vendor_name") + _transaction_detail(r, "Vendor Credit", "Vendor", "vendor_name", resource="vendor-credit") _register("vendor-credit", _vendor_credit_list, _vendor_credit_detail) @@ -425,11 +465,11 @@ def _vendor_credit_detail(r): def _vendor_prepayment_list(items): - _transaction_list(items, "Vendor Prepayments", "Vendor", "vendor_name", show_remaining=True) + _transaction_list(items, "Vendor Prepayments", "Vendor", "vendor_name", show_remaining=True, resource="vendor-prepayment") def _vendor_prepayment_detail(r): - _transaction_detail(r, "Vendor Prepayment", "Vendor", "vendor_name") + _transaction_detail(r, "Vendor Prepayment", "Vendor", "vendor_name", resource="vendor-prepayment") _register("vendor-prepayment", _vendor_prepayment_list, _vendor_prepayment_detail) @@ -439,11 +479,11 @@ def _vendor_prepayment_detail(r): def _vendor_prepayment_app_list(items): - _transaction_list(items, "Vendor Prepayment Applications", "Vendor", "vendor_name") + _transaction_list(items, "Vendor Prepayment Applications", "Vendor", "vendor_name", resource="vendor-prepayment-application") def _vendor_prepayment_app_detail(r): - _transaction_detail(r, "Vendor Prepayment Application", "Vendor", "vendor_name") + _transaction_detail(r, "Vendor Prepayment Application", "Vendor", "vendor_name", resource="vendor-prepayment-application") _register("vendor-prepayment-application", _vendor_prepayment_app_list, _vendor_prepayment_app_detail) @@ -453,11 +493,11 @@ def _vendor_prepayment_app_detail(r): def _vendor_refund_list(items): - _transaction_list(items, "Vendor Refunds", "Vendor", "vendor_name") + _transaction_list(items, "Vendor Refunds", "Vendor", "vendor_name", resource="vendor-refund") def _vendor_refund_detail(r): - _transaction_detail(r, "Vendor Refund", "Vendor", "vendor_name") + _transaction_detail(r, "Vendor Refund", "Vendor", "vendor_name", resource="vendor-refund") _register("vendor-refund", _vendor_refund_list, _vendor_refund_detail) @@ -467,7 +507,7 @@ def _vendor_refund_detail(r): def _journal_entry_list(items): - _transaction_list(items, "Journal Entries", "Memo", "memo") + _transaction_list(items, "Journal Entries", "Memo", "memo", resource="journal-entry") def _journal_entry_detail(r): @@ -475,7 +515,7 @@ def _journal_entry_detail(r): header = Text() header.append("JOURNAL ENTRY", style="bold") - header.append(f" #{r.get('number', '')}", style="bold cyan") + header.append(f" {_fmt_id(r.get('internal_id'), 'journal-entry')}", style="bold cyan") status = r.get("record_status", "") if status: header.append(f" {status.upper()}", style=_status_color(status)) @@ -534,7 +574,7 @@ def _bank_transfer_list(items): send_currency = r.get("credit_bank_account_currency", "") recv_currency = r.get("debit_bank_account_currency", "") table.add_row( - str(r.get("number", "")), + _fmt_id(r.get("internal_id"), "bank-transfer"), r.get("date", "-"), r.get("company_name", "-"), r.get("credit_bank_account_name", "-"), @@ -550,7 +590,7 @@ def _bank_transfer_list(items): def _bank_transfer_detail(r): header = Text() header.append("BANK TRANSFER", style="bold") - header.append(f" #{r.get('number', '')}", style="bold cyan") + header.append(f" {_fmt_id(r.get('internal_id'), 'bank-transfer')}", style="bold cyan") status = r.get("record_status", "") if status: header.append(f" {status.upper()}", style=_status_color(status)) @@ -589,7 +629,7 @@ def _fixed_asset_list(items): for r in items: table.add_row( - str(r.get("number", "")), + _fmt_id(r.get("internal_id"), "fixed-asset"), r.get("name", "-"), r.get("company_name", "-"), r.get("purchase_date", "-"), diff --git a/tests/test_auth.py b/tests/test_auth.py index 6080942..2e0719f 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -15,62 +15,55 @@ def test_generate_pkce_pair(self): expected = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode("ascii") assert challenge == expected + def test_rfc7636_appendix_b_vector(self): + """Verify PKCE uses base64url(SHA256()) per RFC 7636 appendix B.""" + verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + actual = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()).rstrip(b"=").decode("ascii") + assert actual == expected + class TestCredentialStorage: - def test_store_and_load_tokens(self): - from dualentry_cli.auth import load_tokens, store_tokens + def test_store_and_load_api_key(self): + from dualentry_cli.auth import load_api_key, store_api_key with patch("dualentry_cli.auth.keyring") as mock_keyring: - mock_keyring.get_password.side_effect = lambda _svc, key: {"access_token": "acc_123", "refresh_token": "ref_456"}.get(key) - store_tokens("acc_123", "ref_456") - assert mock_keyring.set_password.call_count == 2 - access, refresh = load_tokens() - assert access == "acc_123" - assert refresh == "ref_456" + mock_keyring.get_password.return_value = "key_123" + store_api_key("key_123") + mock_keyring.set_password.assert_called_once_with("dualentry-cli", "api_key", "key_123") + key = load_api_key() + assert key == "key_123" def test_clear_credentials(self): from dualentry_cli.auth import clear_credentials with patch("dualentry_cli.auth.keyring") as mock_keyring: clear_credentials() - assert mock_keyring.delete_password.call_count == 3 + mock_keyring.delete_password.assert_called_once_with("dualentry-cli", "api_key") -class TestRegisterClient: +class TestAuthorize: @respx.mock - def test_registers_oauth_client(self): - from dualentry_cli.auth import _register_client - - route = respx.post("https://api.dualentry.com/mcp/register").mock(return_value=httpx.Response(200, json={"client_id": "client_abc", "client_secret": ""})) - result = _register_client("https://api.dualentry.com/mcp", "http://localhost:9876/callback") - assert result["client_id"] == "client_abc" - assert route.called - + def test_authorize_returns_url(self): + from dualentry_cli.auth import _authorize -class TestExchangeToken: - @respx.mock - def test_exchanges_code_for_tokens(self): - from dualentry_cli.auth import _exchange_token - - route = respx.post("https://api.dualentry.com/mcp/token").mock( - return_value=httpx.Response(200, json={"access_token": "acc_xyz", "refresh_token": "ref_xyz", "expires_in": 43200, "token_type": "Bearer"}) - ) - result = _exchange_token( - mcp_url="https://api.dualentry.com/mcp", client_id="client_abc", code="auth_code_123", code_verifier="test_verifier", redirect_uri="http://localhost:9876/callback" + route = respx.post("https://api.dualentry.com/public/v2/oauth/authorize/").mock( + return_value=httpx.Response(200, json={"authorization_url": "https://auth.example.com/authorize?state=abc"}) ) - assert result["access_token"] == "acc_xyz" - assert result["refresh_token"] == "ref_xyz" + url = _authorize("https://api.dualentry.com", "http://localhost:9876/callback", "challenge", "state") + assert url == "https://auth.example.com/authorize?state=abc" assert route.called -class TestRefreshToken: +class TestExchangeCode: @respx.mock - def test_refreshes_access_token(self): - from dualentry_cli.auth import refresh_access_token + def test_exchanges_code_for_api_key(self): + from dualentry_cli.auth import _exchange_code - route = respx.post("https://api.dualentry.com/mcp/token").mock( - return_value=httpx.Response(200, json={"access_token": "acc_new", "refresh_token": "ref_new", "expires_in": 43200, "token_type": "Bearer"}) + route = respx.post("https://api.dualentry.com/public/v2/oauth/token/").mock( + return_value=httpx.Response(200, json={"api_key": "org_live_xxxx", "organization_id": 42, "user_email": "test@example.com"}) ) - result = refresh_access_token(mcp_url="https://api.dualentry.com/mcp", client_id="client_abc", refresh_token="ref_old") - assert result["access_token"] == "acc_new" + result = _exchange_code("https://api.dualentry.com", "auth_code_123", "test_verifier", "http://localhost:9876/callback") + assert result["api_key"] == "org_live_xxxx" + assert result["organization_id"] == 42 assert route.called diff --git a/tests/test_commands.py b/tests/test_commands.py index a2d49e4..242db67 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -18,10 +18,10 @@ def mock_get_client(): class TestInvoiceCommands: def test_invoices_list(self, mock_get_client): - mock_get_client.get.return_value = {"items": [{"id": 1, "number": "INV-001", "total": "100.00"}], "count": 1} + mock_get_client.get.return_value = {"items": [{"internal_id": 42, "number": 1, "total": "100.00"}], "count": 1} result = runner.invoke(app, ["invoices", "list"]) assert result.exit_code == 0 - assert "INV" in result.output + assert "IN-42" in result.output mock_get_client.get.assert_called_once_with("/invoices/", params={"limit": 20, "offset": 0}) def test_invoices_list_with_pagination(self, mock_get_client): @@ -38,10 +38,10 @@ def test_invoices_list_json_output(self, mock_get_client): assert parsed == {"items": [], "count": 0} def test_invoices_get(self, mock_get_client): - mock_get_client.get.return_value = {"id": 1, "number": "INV-001", "total": "100.00"} + mock_get_client.get.return_value = {"internal_id": 42, "number": 1, "total": "100.00"} result = runner.invoke(app, ["invoices", "get", "1"]) assert result.exit_code == 0 - assert "INV-001" in result.output + assert "IN-42" in result.output mock_get_client.get.assert_called_once_with("/invoices/1/") def test_invoices_create(self, mock_get_client, tmp_path): diff --git a/tests/test_config.py b/tests/test_config.py index 4fb7e6e..414c68f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,16 +3,18 @@ def test_default_config(self, tmp_path): from dualentry_cli.config import Config config = Config(config_dir=tmp_path) - assert config.api_url == "https://api.dualentry.com" + from dualentry_cli._build_info import DEFAULT_API_URL + + assert config.api_url == DEFAULT_API_URL assert config.output == "table" def test_load_config_from_file(self, tmp_path): from dualentry_cli.config import Config config_file = tmp_path / "config.toml" - config_file.write_text('[default]\napi_url = "https://api-dev.dualentry.com"\noutput = "json"\n\n[auth]\norganization_id = 123\nuser_email = "test@example.com"\n') + config_file.write_text('[default]\napi_url = "https://custom.example.com"\noutput = "json"\n\n[auth]\norganization_id = 123\nuser_email = "test@example.com"\n') config = Config(config_dir=tmp_path) - assert config.api_url == "https://api-dev.dualentry.com" + assert config.api_url == "https://custom.example.com" assert config.output == "json" assert config.organization_id == 123 assert config.user_email == "test@example.com" diff --git a/tests/test_integration.py b/tests/test_integration.py index 21fdca4..ca602a1 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -3,11 +3,9 @@ Requires: - X_API_KEY env var set - - API server running (default: http://localhost:8000) Run: X_API_KEY=... pytest tests/test_integration.py -v - X_API_KEY=... pytest tests/test_integration.py -v -k invoices """ from __future__ import annotations