diff --git a/AGENTS.md b/AGENTS.md index 43aaa1c..1835e74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,13 +7,13 @@ Guidance for AI coding agents working in this repository. Humans: start with `capctl` (distribution name `capitalcom-cli`) is a command-line client for the Capital.com Open API, built with Typer + Rich. The CLI is the first-class -surface, but there is also an **experimental** SDK layer at `capital_cli.sdk` +surface, but there is also a **stable (0.x)** SDK layer at `capital_cli.sdk` (exporting `CapitalComApp`, `CapitalComConfig`, `RiskPolicy`) that embeds the same tested broker engine in your own Python — see [docs/sdk.md](docs/sdk.md). The SDK import paths and the `capital_cli.core.models` pydantic models are -documented and intended-stable but may shift between 0.x minors until 1.0. The -`capital_cli.core.*` internals remain **private** — depend on -`capital_cli.sdk` / `capital_cli.services`, not on `core`. +**stable within 0.x** — no breaking changes without a deprecation cycle; breaking +changes are reserved for a future 1.0. The `capital_cli.core.*` internals remain +**private** — depend on `capital_cli.sdk` / `capital_cli.services`, not on `core`. ## Setup & commands @@ -44,7 +44,7 @@ CAPCTL_E2E=1 pytest tests/e2e -m e2e -v - `capital_cli/services/` — presentation-free domain services (markets, accounts, watchlists, trading, streaming, confirmations) composing `core`; the reusable broker engine. -- `capital_cli/sdk/` — the experimental public facade (`CapitalComApp`, +- `capital_cli/sdk/` — the public facade (`CapitalComApp`, `CapitalComConfig`, `RiskPolicy`). - `capital_cli/cli/` — thin Typer wrappers, one app per command group; Rich/JSON rendering in `cli/output.py`; the async→exit-code runner in diff --git a/CHANGELOG.md b/CHANGELOG.md index 3228bb7..9dfeac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.6.0] - 2026-06-14 + +### Added +- SDK parity: `app.session.server_time()`, `app.session.details()`, and + `app.session.encryption_key()` — the SDK now covers the full Open API surface + (coverage matrix 164/164, no N/A). The CLI `session time`/`details`/ + `encryption-key` commands share these methods. + +### Changed +- The SDK is now positioned as **stable within 0.x** (no breaking changes without + a deprecation cycle); dropped the "experimental" label across the README, + `docs/sdk.md`, and module docstrings. +- README and `docs/api-coverage.md` now describe **full** (not "main") Open API + coverage, backed by the automated matrix; the coverage badge is relabeled + **CLI + SDK coverage**. + ## [0.5.1] - 2026-06-14 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e358ea..72a2828 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ It opens and closes a minimum-size BTCUSD position on the **demo** account. risk engine, models, streaming, state. All safety rules live here (private). - `capital_cli/services/` — presentation-free domain services (markets, accounts, watchlists, trading, streaming, confirmations) composing `core`. -- `capital_cli/sdk/` — the experimental public facade (`CapitalComApp`, +- `capital_cli/sdk/` — the public facade (`CapitalComApp`, `CapitalComConfig`, `RiskPolicy`). - `capital_cli/cli/` — Typer commands. Thin: parse → call a service → render; the `cli/` layer never calls the broker API directly. diff --git a/README.md b/README.md index 22e39ca..1208ae5 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Browse markets, manage accounts and watchlists, preview and execute trades behin [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) [![Release](https://img.shields.io/github/v/release/SimonTarara62/capitalcom-cli?sort=semver)](https://github.com/SimonTarara62/capitalcom-cli/releases) -[![API coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/SimonTarara62/capitalcom-cli/main/docs/coverage-badge.json)](docs/api-coverage.md) +[![CLI + SDK coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/SimonTarara62/capitalcom-cli/main/docs/coverage-badge.json)](docs/api-coverage.md) ```text $ capctl market search "gold" @@ -39,8 +39,8 @@ The Capital.com web platform is built for clicking; `capctl` is built for **repe See [practical use cases](docs/use-cases.md) for worked, copy-pasteable scenarios. **Scope:** `capctl` is first and foremost a command-line application, but the same -tested broker engine is also embeddable as an **experimental** Python SDK — see -[Use as a library](#use-as-a-library-experimental) and [docs/sdk.md](docs/sdk.md). +tested broker engine is also embeddable as a Python SDK — see +[Use as a library](#use-as-a-library) and [docs/sdk.md](docs/sdk.md). The `capital_cli.core.*` internals remain private and may change between releases. The CLI itself is short-lived: each command runs as its own process. By default (`CAP_PERSIST_SESSION=true`) the short-lived session tokens are cached so @@ -50,7 +50,7 @@ session login` is mainly a connectivity/account check. ## Features -- **Six command groups** covering the main Capital.com Open API workflows — `session`, `market`, `account`, `trade`, `watchlist`, `stream` (see the [API coverage table](docs/api-coverage.md)) +- **Six command groups** covering the **full** Capital.com Open API surface — `session`, `market`, `account`, `trade`, `watchlist`, `stream` — verified by an automated [coverage matrix](docs/api-coverage.md) - **Safety-first trading** — trading is off by default; enabling it requires an explicit EPIC allowlist, every execution goes through a two-phase *preview → execute* flow with risk checks, and mutating commands require `--yes` - **`--json` everywhere** — every command can emit raw JSON for piping into `jq`, scripts, or CI jobs - **Distinct exit codes per failure class** — scripts can branch on *why* a command failed (auth vs. risk-block vs. upstream error) @@ -58,7 +58,7 @@ session login` is mainly a connectivity/account check. - **Demo and live environments** — defaults to demo; live requires explicit opt-in - **Built-in rate limiting** — client-side token buckets respect Capital.com's 10 req/s global, 1 req/s session, and trading-burst limits - **Session-token caching** — back-to-back commands reuse one login instead of re-authenticating (avoids HTTP 429); on by default, opt out with `CAP_PERSIST_SESSION=false` -- **Embeddable SDK (experimental)** — the same broker engine ships as an async Python library; see [Use as a library](#use-as-a-library-experimental) +- **Embeddable SDK** — the same broker engine ships as an async Python library with a stable 0.x surface; see [Use as a library](#use-as-a-library) ## Installation @@ -367,7 +367,7 @@ capctl stream alerts BTCUSD 75000 --direction ABOVE --duration 3600 */15 * * * * capctl --json trade positions >> ~/logs/positions.jsonl 2>&1 ``` -## Use as a library (experimental) +## Use as a library Beyond the CLI, the same broker engine — risk policy, two-phase preview→execute, rate limiting, WebSocket streaming — is available as an embeddable async SDK. @@ -390,9 +390,12 @@ async def main(): asyncio.run(main()) ``` -The SDK is **experimental** until 1.0: import paths and the pydantic models are -intended-stable but may shift between 0.x minors. See [docs/sdk.md](docs/sdk.md) -for read-only, preview→execute, streaming, and risk-policy examples. +**Stability:** the documented SDK surface — `CapitalComApp`, `CapitalComConfig`, +`RiskPolicy`, the service classes, and the `capital_cli.core.models` pydantic +models — is **stable**: no breaking changes within the 0.x series without a +deprecation cycle; breaking changes are reserved for a future 1.0. The +`capital_cli.core.*` internals remain private. See [docs/sdk.md](docs/sdk.md) for +read-only, preview→execute, streaming, and risk-policy examples. ## Exit codes @@ -459,7 +462,7 @@ Or use the task runner: `make check` (lint + typecheck + test), `make docs`, `ma ## Documentation - [Full CLI reference](docs/CLI.md) — every command and option (auto-generated) -- [Using capctl as a Python SDK](docs/sdk.md) — experimental async SDK, import paths, examples, versioning +- [Using capctl as a Python SDK](docs/sdk.md) — stable async SDK, import paths, examples, versioning - [Scripting & automation](docs/scripting.md) — CI credentials, exit codes, jq recipes - [Getting started from zero](docs/getting-started.md) — account, API key, install, first trade - [Practical use cases](docs/use-cases.md) — what people actually do with capctl, with copy-pasteable scenarios diff --git a/capital_cli/__init__.py b/capital_cli/__init__.py index 0b2a32d..cd51f69 100644 --- a/capital_cli/__init__.py +++ b/capital_cli/__init__.py @@ -1,3 +1,3 @@ """Capital.com command-line client.""" -__version__ = "0.5.1" +__version__ = "0.6.0" diff --git a/capital_cli/cli/session_cmds.py b/capital_cli/cli/session_cmds.py index 98a1e25..df5f813 100644 --- a/capital_cli/cli/session_cmds.py +++ b/capital_cli/cli/session_cmds.py @@ -7,7 +7,6 @@ import typer from capital_cli.cli.runner import run -from capital_cli.core.http_client import get_client from capital_cli.core.session import get_session_manager app = typer.Typer(no_args_is_help=True, help="Session lifecycle: login, ping, logout.") @@ -98,9 +97,7 @@ def time(ctx: typer.Context) -> None: out = ctx.obj.out async def _do() -> dict[str, Any]: - client = get_client() - data = (await client.get("/time")).json() - return data if isinstance(data, dict) else {"serverTime": data} + return await get_session_manager().server_time() out.record(run(out, _do, label="session time"), title="Server time") @@ -111,10 +108,7 @@ def details(ctx: typer.Context) -> None: out = ctx.obj.out async def _do() -> dict[str, Any]: - sm = get_session_manager() - client = get_client() - await sm.ensure_logged_in() - return (await client.get("/session")).json() + return await get_session_manager().details() out.record(run(out, _do, label="session details"), title="Session details") @@ -125,7 +119,6 @@ def encryption_key(ctx: typer.Context) -> None: out = ctx.obj.out async def _do() -> dict[str, Any]: - client = get_client() - return (await client.get("/session/encryptionKey")).json() + return await get_session_manager().encryption_key() out.record(run(out, _do, label="session encryption-key"), title="Encryption key") diff --git a/capital_cli/core/session.py b/capital_cli/core/session.py index da3eb75..4fd7ae3 100644 --- a/capital_cli/core/session.py +++ b/capital_cli/core/session.py @@ -187,6 +187,23 @@ async def ping(self) -> dict[str, Any]: response = await self.client.get("/ping") return response.json() if response.text else {"status": "ok"} + async def server_time(self) -> dict[str, Any]: + """Return the broker's current server time. No authentication required.""" + response = await self.client.get("/time") + data = response.json() if response.text else {} + return data if isinstance(data, dict) else {"serverTime": data} + + async def details(self) -> dict[str, Any]: + """Return server-side session details (client id, account id, currency, timezone).""" + await self.ensure_logged_in() + response = await self.client.get("/session") + return response.json() if response.text else {} + + async def encryption_key(self) -> dict[str, Any]: + """Fetch the API encryption key and timestamp (encrypted-password login).""" + response = await self.client.get("/session/encryptionKey") + return response.json() if response.text else {} + async def logout(self) -> None: """End session and clear tokens.""" if not self.tokens: diff --git a/capital_cli/sdk/__init__.py b/capital_cli/sdk/__init__.py index 061c1fa..a2b9afb 100644 --- a/capital_cli/sdk/__init__.py +++ b/capital_cli/sdk/__init__.py @@ -1,4 +1,4 @@ -"""Capital.com SDK — experimental public API (paths/models stable from 1.0). +"""Capital.com SDK — public API, stable within 0.x (frozen at 1.0). from capital_cli.sdk import CapitalComApp, CapitalComConfig, RiskPolicy """ diff --git a/capital_cli/sdk/app.py b/capital_cli/sdk/app.py index e468d9d..43ab666 100644 --- a/capital_cli/sdk/app.py +++ b/capital_cli/sdk/app.py @@ -1,8 +1,8 @@ """CapitalComApp — the SDK facade wiring config + services + risk policy. -EXPERIMENTAL (0.x): one app per process. The underlying services use -process-global singletons, so constructing a second CapitalComApp with a -different config in the same process is not supported yet. +Note (0.x): one app per process. The underlying services use process-global +singletons, so constructing a second CapitalComApp with a different config in the +same process is not supported yet. """ from __future__ import annotations diff --git a/capital_cli/sdk/config.py b/capital_cli/sdk/config.py index fccb3e5..e202ed6 100644 --- a/capital_cli/sdk/config.py +++ b/capital_cli/sdk/config.py @@ -1,4 +1,4 @@ -"""Public configuration type for the Capital.com SDK (experimental until 1.0).""" +"""Public configuration type for the Capital.com SDK (stable within 0.x).""" from __future__ import annotations diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 46610ac..b12d97a 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -68,12 +68,11 @@ issues the request and surfaces the response (via tables or `--json`). **Verified coverage.** Every endpoint below is exercised by automated tests on the real demo API across four cells — CLI positive, CLI negative, SDK positive, SDK -negative — generated from `tests/e2e/endpoints.py`. See the matrix at the bottom of -this file (regenerate with `make coverage-doc`). Three read-only connectivity -primitives (`GET /time`, `GET /session`, `GET /session/encryptionKey`) are CLI-only -by design and marked **N/A** in the SDK columns. The only Open API surface not -exposed as a command is the RSA encrypted-password login flow (the CLI uses the -standard credential login). +negative — generated from `tests/e2e/endpoints.py` (regenerate with +`make coverage-doc`). Both the CLI and the SDK cover the **full** Open API surface; +the only surface not exposed as a command is the RSA encrypted-password login flow +(the CLI uses the standard credential login). See the matrix at the bottom of this +file. @@ -81,10 +80,10 @@ standard credential login). | Endpoint | HTTP / WS | CLI + | CLI − | SDK + | SDK − | |----------|-----------|:-----:|:-----:|:-----:|:-----:| -| `session.time` | `GET /time` | tested | tested | N/A | N/A | +| `session.time` | `GET /time` | tested | tested | tested | tested | | `session.ping` | `GET /ping` | tested | tested | tested | tested | -| `session.details` | `GET /session` | tested | tested | N/A | N/A | -| `session.encryption_key` | `GET /session/encryptionKey` | tested | tested | N/A | N/A | +| `session.details` | `GET /session` | tested | tested | tested | tested | +| `session.encryption_key` | `GET /session/encryptionKey` | tested | tested | tested | tested | | `session.login` | `POST /session` | tested | tested | tested | tested | | `session.switch` | `PUT /session` | tested | tested | tested | tested | | `session.logout` | `DELETE /session` | tested | tested | tested | tested | @@ -123,4 +122,4 @@ standard credential login). | `stream.alerts` | `WS quotes (level cross)` | tested | tested | tested | tested | | `stream.portfolio` | `WS quotes (position epics)` | tested | tested | tested | tested | -**Coverage: 158/158 applicable cells tested (100%) across 41 endpoints.** N/A = operation not exposed on that surface (CLI-only connectivity primitives). +**Coverage: 164/164 applicable cells tested (100%) across 41 endpoints — CLI and SDK, positive and negative.** diff --git a/docs/coverage-badge.json b/docs/coverage-badge.json index c392a98..5e9493c 100644 --- a/docs/coverage-badge.json +++ b/docs/coverage-badge.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "label": "API coverage", - "message": "158/158 (100%)", + "label": "CLI + SDK coverage", + "message": "164/164 (100%)", "color": "brightgreen" } diff --git a/docs/sdk.md b/docs/sdk.md index 41d397b..e0ee517 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -1,10 +1,11 @@ -# Using capctl as a Python SDK (experimental) +# Using capctl as a Python SDK -> **EXPERIMENTAL.** The SDK is not yet 1.0. The documented import paths -> (`capital_cli.sdk.{CapitalComApp, CapitalComConfig, RiskPolicy}`) and the -> pydantic models in `capital_cli.core.models` are intended to be stable, but -> they **may shift between 0.x minors** until 1.0. Pin a version if you depend -> on them. See [Versioning](#versioning) below. +> **Stable surface (pre-1.0).** The documented import paths +> (`capital_cli.sdk.{CapitalComApp, CapitalComConfig, RiskPolicy}`), the service +> classes, and the pydantic models in `capital_cli.core.models` are **stable**: +> no breaking changes within the 0.x series without a deprecation cycle. The +> `capital_cli.core.*` internals remain private. Breaking changes are reserved +> for a future 1.0. `capctl` is first and foremost a CLI, but the same tested broker engine — the risk policy, the two-phase preview→execute flow, the rate limiter, the @@ -204,10 +205,9 @@ raw API responses. ## Versioning -Until 1.0 the SDK is **experimental**: import paths -(`capital_cli.sdk.{CapitalComApp, CapitalComConfig, RiskPolicy}`) and the -pydantic models are documented and intended-stable, but may shift between -0.x minors. From 1.0: +The documented SDK surface (`capital_cli.sdk.{CapitalComApp, CapitalComConfig, +RiskPolicy}`, the service classes, and the `capital_cli.core.models` models) is +**stable within 0.x** — no breaking changes without a deprecation cycle. From 1.0: - Breaking changes to the SDK/service layer only in **major** versions. - CLI UX may evolve in minors independently of the SDK contract. - The `capital_cli.core.*` internals remain private (no compatibility promise); diff --git a/tests/e2e/endpoints.py b/tests/e2e/endpoints.py index 686607d..217e066 100644 --- a/tests/e2e/endpoints.py +++ b/tests/e2e/endpoints.py @@ -26,10 +26,10 @@ class Endpoint: # --- The 41-row registry (mirror of docs/api-coverage.md row order) ---------- ENDPOINTS: list[Endpoint] = [ - Endpoint("session.time", "GET /time", "session time", None, "session", False), + Endpoint("session.time", "GET /time", "session time", "session.server_time", "session", False), Endpoint("session.ping", "GET /ping", "session ping", "session.ping", "session", False), - Endpoint("session.details", "GET /session", "session details", None, "session", False), - Endpoint("session.encryption_key", "GET /session/encryptionKey", "session encryption-key", None, "session", False), + Endpoint("session.details", "GET /session", "session details", "session.details", "session", False), + Endpoint("session.encryption_key", "GET /session/encryptionKey", "session encryption-key", "session.encryption_key", "session", False), Endpoint("session.login", "POST /session", "session login", "session.login", "session", True), Endpoint("session.switch", "PUT /session", "session switch", "session.switch_account", "session", True), Endpoint("session.logout", "DELETE /session", "session logout", "session.logout", "session", True), @@ -235,6 +235,9 @@ class Cells: # --- sdk_pos wiring (Task 5): existing sdk-e2e + new gap tests ----------------- _S = "tests/e2e/test_sdk_e2e.py" _P = "tests/e2e/test_sdk_positive_gaps_e2e.py" +COVERAGE["session.time"].sdk_pos = f"{_P}::test_sdk_session_reads" +COVERAGE["session.details"].sdk_pos = f"{_P}::test_sdk_session_reads" +COVERAGE["session.encryption_key"].sdk_pos = f"{_P}::test_sdk_session_reads" COVERAGE["session.ping"].sdk_pos = f"{_P}::test_sdk_session_ping_switch_logout" COVERAGE["session.login"].sdk_pos = f"{_S}::test_sdk_read_only_flow" COVERAGE["session.switch"].sdk_pos = f"{_P}::test_sdk_session_ping_switch_logout" @@ -297,6 +300,9 @@ class Cells: COVERAGE["watchlist.delete"].sdk_neg = f"{_SN}::test_sdk_nontrade_mutations_require_confirm[watchlist.delete]" COVERAGE["account.prefs_set"].sdk_neg = f"{_SN}::test_sdk_nontrade_mutations_require_confirm[account.prefs_set]" COVERAGE["account.topup"].sdk_neg = f"{_SN}::test_sdk_nontrade_mutations_require_confirm[account.topup]" +COVERAGE["session.time"].sdk_neg = f"{_SN}::test_sdk_missing_config_raises[session.time]" +COVERAGE["session.details"].sdk_neg = f"{_SN}::test_sdk_missing_config_raises[session.details]" +COVERAGE["session.encryption_key"].sdk_neg = f"{_SN}::test_sdk_missing_config_raises[session.encryption_key]" COVERAGE["session.ping"].sdk_neg = f"{_SN}::test_sdk_missing_config_raises[session.ping]" COVERAGE["session.switch"].sdk_neg = f"{_SN}::test_sdk_missing_config_raises[session.switch]" COVERAGE["session.logout"].sdk_neg = f"{_SN}::test_sdk_missing_config_raises[session.logout]" diff --git a/tests/e2e/test_sdk_negative_e2e.py b/tests/e2e/test_sdk_negative_e2e.py index 0517c40..a9054b5 100644 --- a/tests/e2e/test_sdk_negative_e2e.py +++ b/tests/e2e/test_sdk_negative_e2e.py @@ -174,7 +174,8 @@ def _missing_config_env(): @pytest.mark.parametrize( "endpoint_id", - ["session.ping", "session.switch", "session.logout", "session.login", + ["session.time", "session.details", "session.encryption_key", + "session.ping", "session.switch", "session.logout", "session.login", "account.list", "account.prefs_get", "account.history_activity", "account.history_transactions", "market.search", "market.sentiment", "market.nav_root", "market.nav_node", "position.list", "order.list", diff --git a/tests/e2e/test_sdk_positive_gaps_e2e.py b/tests/e2e/test_sdk_positive_gaps_e2e.py index 1d03a2e..4b985bf 100644 --- a/tests/e2e/test_sdk_positive_gaps_e2e.py +++ b/tests/e2e/test_sdk_positive_gaps_e2e.py @@ -20,6 +20,21 @@ EPIC = "BTCUSD" +def test_sdk_session_reads(): + from capital_cli.sdk import CapitalComApp + + async def _run(): + async with CapitalComApp() as app: + t = await app.session.server_time() + assert isinstance(t, dict) and t, t + d = await app.session.details() + assert isinstance(d, dict) and d, d + k = await app.session.encryption_key() + assert isinstance(k, dict) and (k.get("encryptionKey") or k), k + + asyncio.run(_run()) + + def test_sdk_session_ping_switch_logout(): from capital_cli.sdk import CapitalComApp diff --git a/tests/test_session_cmds.py b/tests/test_session_cmds.py index 6c42190..8c3e39f 100644 --- a/tests/test_session_cmds.py +++ b/tests/test_session_cmds.py @@ -45,44 +45,38 @@ def test_switch(runner, mock_session): @pytest.fixture def mock_session_client(monkeypatch): - """Patch get_client (and get_session_manager) used by the new session commands.""" + """Patch the SessionManager read methods backing the session commands. + + The CLI now delegates to ``get_session_manager().server_time()`` etc. rather + than calling the HTTP client inline, so we stub those passthroughs. + """ sm = MagicMock() sm.ensure_logged_in = AsyncMock() + sm.server_time = AsyncMock(return_value={}) + sm.details = AsyncMock(return_value={}) + sm.encryption_key = AsyncMock(return_value={}) monkeypatch.setattr("capital_cli.cli.session_cmds.get_session_manager", lambda: sm) - - client = MagicMock() - resp = MagicMock() - resp.json = MagicMock(return_value={}) - client.get = AsyncMock(return_value=resp) - monkeypatch.setattr("capital_cli.cli.session_cmds.get_client", lambda: client) - return client + return sm def test_time(runner, mock_session_client): - mock_session_client.get.return_value.json.return_value = {"serverTime": 1700000000000} - result = runner.invoke(app, ["--json", "session", "time"]) - assert result.exit_code == 0 - assert json.loads(result.stdout)["serverTime"] == 1700000000000 - assert mock_session_client.get.await_args.args[0] == "/time" - - -def test_time_wraps_bare_value(runner, mock_session_client): - mock_session_client.get.return_value.json.return_value = 1700000000000 + mock_session_client.server_time.return_value = {"serverTime": 1700000000000} result = runner.invoke(app, ["--json", "session", "time"]) assert result.exit_code == 0 assert json.loads(result.stdout)["serverTime"] == 1700000000000 + mock_session_client.server_time.assert_awaited_once() def test_details(runner, mock_session_client): - mock_session_client.get.return_value.json.return_value = {"clientId": "C1", "accountId": "A1"} + mock_session_client.details.return_value = {"clientId": "C1", "accountId": "A1"} result = runner.invoke(app, ["--json", "session", "details"]) assert result.exit_code == 0 assert json.loads(result.stdout)["clientId"] == "C1" - assert mock_session_client.get.await_args.args[0] == "/session" + mock_session_client.details.assert_awaited_once() def test_encryption_key(runner, mock_session_client): - mock_session_client.get.return_value.json.return_value = {"encryptionKey": "K", "timeStamp": 1} + mock_session_client.encryption_key.return_value = {"encryptionKey": "K", "timeStamp": 1} result = runner.invoke(app, ["session", "encryption-key"]) assert result.exit_code == 0 - assert mock_session_client.get.await_args.args[0] == "/session/encryptionKey" + mock_session_client.encryption_key.assert_awaited_once() diff --git a/tests/test_session_reads.py b/tests/test_session_reads.py new file mode 100644 index 0000000..1ce4fae --- /dev/null +++ b/tests/test_session_reads.py @@ -0,0 +1,83 @@ +"""Unit tests for the SessionManager read passthroughs (server_time / details / +encryption_key) that back both the CLI session commands and the SDK.""" + +import asyncio + +import pytest + + +@pytest.fixture +def _demo_env(monkeypatch): + monkeypatch.setenv("CAP_ENV", "demo") + monkeypatch.setenv("CAP_API_KEY", "k") + monkeypatch.setenv("CAP_IDENTIFIER", "id@example.com") + monkeypatch.setenv("CAP_API_PASSWORD", "pw") + from capital_cli.core.config import reset_config + + reset_config() + yield + from capital_cli.core.config import reset_config as _rc + + _rc() + + +class _FakeResp: + def __init__(self, payload): + self._payload = payload + self.text = "x" + + def json(self): + return self._payload + + +class _FakeClient: + def __init__(self, payload): + self._payload = payload + self.path = None + + def set_relogin(self, *_a): + pass + + async def get(self, path): + self.path = path + return _FakeResp(self._payload) + + +def _make_sm(payload): + from capital_cli.core.session import SessionManager + + sm = SessionManager() + sm.client = _FakeClient(payload) + return sm + + +def test_server_time_hits_time_endpoint(_demo_env): + sm = _make_sm({"serverTime": "2026-06-14T00:00:00"}) + out = asyncio.run(sm.server_time()) + assert sm.client.path == "/time" + assert out == {"serverTime": "2026-06-14T00:00:00"} + + +def test_server_time_wraps_bare_value(_demo_env): + sm = _make_sm(1700000000000) + out = asyncio.run(sm.server_time()) + assert out == {"serverTime": 1700000000000} + + +def test_encryption_key_hits_endpoint(_demo_env): + sm = _make_sm({"encryptionKey": "abc", "timeStamp": 1}) + out = asyncio.run(sm.encryption_key()) + assert sm.client.path == "/session/encryptionKey" + assert out["encryptionKey"] == "abc" + + +def test_details_logs_in_then_hits_session(_demo_env, monkeypatch): + sm = _make_sm({"clientId": "c", "accountId": "a"}) + + async def _noop(): + return None + + monkeypatch.setattr(sm, "ensure_logged_in", _noop) + out = asyncio.run(sm.details()) + assert sm.client.path == "/session" + assert out["accountId"] == "a" diff --git a/tools/render_coverage.py b/tools/render_coverage.py index 65f850e..4b90759 100644 --- a/tools/render_coverage.py +++ b/tools/render_coverage.py @@ -50,13 +50,16 @@ def render() -> tuple[str, str]: tested += 1 rows.append(f"| `{e.id}` | `{e.http}` | {cli_pos} | {cli_neg} | {sdk_pos} | {sdk_neg} |") pct = round(100 * tested / applicable) if applicable else 0 + na_total = sum(1 for e in ENDPOINTS if e.sdk is None) * 2 + na_note = (" N/A = operation not exposed on that surface " + "(CLI-only connectivity primitives)." if na_total else "") summary = (f"\n**Coverage: {tested}/{applicable} applicable cells tested " - f"({pct}%) across {len(ENDPOINTS)} endpoints.** " - "N/A = operation not exposed on that surface (CLI-only connectivity primitives).\n") + f"({pct}%) across {len(ENDPOINTS)} endpoints" + f" — CLI and SDK, positive and negative.**{na_note}\n") table_md = MARKER + "\n\n## Test matrix\n\n" + "\n".join(rows) + "\n" + summary color = "brightgreen" if pct == 100 else ("yellow" if pct >= 80 else "red") - badge = {"schemaVersion": 1, "label": "API coverage", + badge = {"schemaVersion": 1, "label": "CLI + SDK coverage", "message": f"{tested}/{applicable} ({pct}%)", "color": color} return table_md, json.dumps(badge, indent=2) + "\n"