Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,8 @@ before the PR is mergeable.

> **Running locally without exporting a token:** if the target project is already registered in a kbagent `config.json`, use config-dir mode -- `make test-e2e-local CONFIG_DIR=/path/to/.kbagent ALIAS=my-proj`. The harness reads the token from `config.json` at import time and promotes it into `E2E_API_TOKEN` / `E2E_URL`; an explicit `E2E_API_TOKEN` still wins.

- [ ] **API call-count test for hot read paths** -- a new or changed list/detail command that users and agents run often (the `project`/`config`/`job`/`storage`/`flow` read commands and their peers) adds or updates a case in `tests/test_api_call_counts.py`. It pins the exact `(METHOD, path)` calls via `helpers.assert_api_calls`; list commands also get a 1-vs-10-items case proving the count does not grow per item. The expected lists are a ratchet: raising one is a deliberate, reviewed change -- say why in the PR

- [ ] **Run `make check`** before committing (lint + format + full test suite)
- [ ] **Run `make typecheck`** -- `ty` must pass clean (0 diagnostics; the backlog was cleared in 0.45.0, so the gate is blocking, not warning-only)
- [ ] **No new `tuple[...]` returns** -- multi-value returns use a `@dataclass` ([Code Quality Patterns](#code-quality-patterns))
Expand Down
75 changes: 75 additions & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
ConfigStore instances. Used across multiple test files to avoid duplication.
"""

from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock

import httpx

from keboola_agent_cli.config_store import ConfigStore
from keboola_agent_cli.errors import KeboolaApiError
from keboola_agent_cli.models import ProjectConfig, TokenVerifyResponse
Expand Down Expand Up @@ -126,3 +130,74 @@ def setup_two_projects(tmp_config_dir: Path) -> ConfigStore:
),
)
return store


# ---------------------------------------------------------------------------
# API call-count recording (issue #802)
# ---------------------------------------------------------------------------

# One recorded HTTP call: (METHOD, path) -- host stripped, query optional.
ApiCall = tuple[str, str]

# Status returned for a request no route matches. Deliberately NOT a
# retryable status (429/5xx): an unmatched call must fail fast, never sleep
# through the client's backoff.
UNMATCHED_ROUTE_STATUS = 418


def _call_of(request: httpx.Request, *, include_query: bool) -> ApiCall:
path = request.url.path
if include_query and request.url.query:
path = f"{path}?{request.url.query.decode()}"
return (request.method, path)


def mock_api_routes(httpx_mock: Any, routes: Mapping[ApiCall, Any]) -> None:
"""Answer every HTTP request from a ``{(METHOD, path): json_body}`` table.

Routing ignores host and query string, so one table serves the Storage
and Queue hosts alike. The callback is reusable and optional: the same
route may be hit any number of times (that count is what the call-count
tests assert), and a command that makes zero calls does not trip
pytest-httpx's "response never requested" teardown check. A request no
route matches gets HTTP ``UNMATCHED_ROUTE_STATUS`` so it surfaces as a
command error and as an unexpected entry in the recorded calls.
"""

def _respond(request: httpx.Request) -> httpx.Response:
key = _call_of(request, include_query=False)
if key not in routes:
return httpx.Response(
UNMATCHED_ROUTE_STATUS,
json={"error": f"no mocked route for {key[0]} {key[1]}"},
)
return httpx.Response(200, json=routes[key])

httpx_mock.add_callback(_respond, is_reusable=True, is_optional=True)


def recorded_api_calls(httpx_mock: Any, *, include_query: bool = False) -> list[ApiCall]:
"""Every HTTP request the mock saw, as ``(METHOD, path)`` in send order."""
return [_call_of(r, include_query=include_query) for r in httpx_mock.get_requests()]


def assert_api_calls(
httpx_mock: Any,
expected: Sequence[ApiCall],
*,
include_query: bool = False,
ordered: bool = True,
) -> None:
"""Assert the exact number AND sequence of HTTP calls a command made.

``ordered=False`` compares sorted multisets -- use it where calls are
issued from a thread pool (multi-project fan-out) and the send order is
not deterministic. The count is exact either way: a duplicate call fails.
"""
actual = recorded_api_calls(httpx_mock, include_query=include_query)
if not ordered:
actual, expected = sorted(actual), sorted(expected)
assert actual == list(expected), (
f"API calls changed ({len(actual)} made, {len(expected)} expected).\n"
f"actual: {actual}\nexpected: {list(expected)}"
)
Loading
Loading