Skip to content

Commit ee86b3b

Browse files
njbrakeclaude
andcommitted
Add control-plane integration tests (generated client)
Full CRUD lifecycle tests for every control-plane endpoint (keys, users, budgets, pricing, usage) driving the generated client against a real gateway started on SQLite with a master key (no provider creds needed). Verified 22 endpoint operations pass; documents the Bearer-auth requirement and the per-language test pattern. Part of mozilla-ai/otari#96 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a566252 commit ee86b3b

2 files changed

Lines changed: 250 additions & 0 deletions

File tree

tests/integration/README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Control-plane integration tests
2+
3+
Strategy for verifying each control-plane endpoint (keys, users, budgets,
4+
pricing, usage) end to end against a real gateway. `test_control_plane_generated.py`
5+
is the Python reference; the same shape applies to the TS / Go / Rust SDKs.
6+
7+
## Why this works without Docker or provider keys
8+
9+
Control-plane endpoints are pure gateway + DB operations; they never call an LLM
10+
provider. So a test can run the gateway on **SQLite** with just a **master key**:
11+
no Postgres, no provider credentials. The fixture starts `gateway serve
12+
--database-url sqlite:///... --master-key ... --auto-migrate`, waits for
13+
`/health`, and tears it down.
14+
15+
## Auth (verified)
16+
17+
Management endpoints authenticate with `Authorization: Bearer <master_key>`.
18+
The `Otari-Key` header is for the virtual API keys used on inference endpoints
19+
and returns 401 here. The integrated client must send Bearer for control-plane
20+
calls.
21+
22+
## Per-endpoint coverage
23+
24+
Each resource is exercised through its full lifecycle, with extra attention on
25+
the create/POST operations (the manually-integrated surface):
26+
27+
| Resource | create | get | list | update | delete | extra |
28+
|----------|--------|-----|------|--------|--------|-------|
29+
| budgets | ✓ (asserts `budget_id`, `max_budget`) |||| ✓ → 404 | |
30+
| users ||||| ✓ → 404 | `GET .../usage` |
31+
| keys | ✓ (asserts the one-time `key` secret is returned) |||| ✓ → 404 | |
32+
| pricing ||||| ✓ → 404 | `GET .../history` |
33+
| usage |||||| |
34+
35+
Identifier fields differ per resource (gateway convention): `id` (keys),
36+
`user_id` (users), `budget_id` (budgets), `model_key` (pricing).
37+
38+
## Other languages (same pattern)
39+
40+
- **TypeScript** (vitest): a `beforeAll` spawns the gateway via `child_process`,
41+
polls `/health`; tests use the generated fetch client with an `Authorization`
42+
header; `afterAll` kills it.
43+
- **Go** (`testing`): `TestMain` starts the gateway with `os/exec`, waits on
44+
`/health`; table-driven lifecycle tests per resource; defer cleanup.
45+
- **Rust** (`tokio::test` + a shared harness): spawn the gateway with
46+
`std::process::Command`, await `/health`; reqwest-based generated client.
47+
48+
## Running
49+
50+
```bash
51+
# gateway must be importable as a console script (or set OTARI_GATEWAY_CMD)
52+
OTARI_GATEWAY_CMD="gateway" pytest tests/integration/test_control_plane_generated.py -v
53+
```
54+
55+
These tests target the generated client at `src/otari/_generated` for now. Once
56+
it is wired into the public client, point the imports at the public surface so
57+
the tests also cover the manual integration layer.
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""Integration tests for the generated control-plane client against a live gateway.
2+
3+
These drive the generated control-plane client through a full CRUD lifecycle for
4+
every management endpoint (keys, users, budgets, pricing, usage). They start a
5+
real gateway on SQLite with a master key, so no provider credentials or database
6+
server are needed: control-plane endpoints never call an LLM provider.
7+
8+
Run requirements:
9+
- The ``gateway`` console script on PATH (set ``OTARI_GATEWAY_CMD`` to override),
10+
e.g. ``pip install otari-gateway`` in CI.
11+
- The generated client importable (this preview imports it from
12+
``src/otari/_generated``; once it is wired into the public client, import from
13+
there instead).
14+
15+
Auth note, verified against the gateway: management endpoints authenticate via
16+
``Authorization: Bearer <master_key>``, NOT the ``Otari-Key`` virtual-key header
17+
used for inference.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import contextlib
23+
import os
24+
import socket
25+
import subprocess
26+
import sys
27+
import tempfile
28+
import time
29+
import urllib.error
30+
import urllib.request
31+
from collections.abc import Iterator
32+
from pathlib import Path
33+
34+
import pytest
35+
36+
pytestmark = pytest.mark.integration
37+
38+
MASTER_KEY = "itest-master-key"
39+
40+
# Preview: import the generated client from where the codegen PR drops it.
41+
_GENERATED = Path(__file__).resolve().parents[2] / "src" / "otari" / "_generated"
42+
if _GENERATED.is_dir():
43+
sys.path.insert(0, str(_GENERATED))
44+
45+
ocp = pytest.importorskip("otari_control_plane", reason="generated control-plane client not present")
46+
47+
from otari_control_plane.api.budgets_api import BudgetsApi # noqa: E402
48+
from otari_control_plane.api.keys_api import KeysApi # noqa: E402
49+
from otari_control_plane.api.pricing_api import PricingApi # noqa: E402
50+
from otari_control_plane.api.usage_api import UsageApi # noqa: E402
51+
from otari_control_plane.api.users_api import UsersApi # noqa: E402
52+
from otari_control_plane.exceptions import NotFoundException # noqa: E402
53+
from otari_control_plane.models.create_budget_request import CreateBudgetRequest # noqa: E402
54+
from otari_control_plane.models.create_key_request import CreateKeyRequest # noqa: E402
55+
from otari_control_plane.models.create_user_request import CreateUserRequest # noqa: E402
56+
from otari_control_plane.models.set_pricing_request import SetPricingRequest # noqa: E402
57+
from otari_control_plane.models.update_budget_request import UpdateBudgetRequest # noqa: E402
58+
from otari_control_plane.models.update_key_request import UpdateKeyRequest # noqa: E402
59+
from otari_control_plane.models.update_user_request import UpdateUserRequest # noqa: E402
60+
61+
62+
def _free_port() -> int:
63+
with socket.socket() as sock:
64+
sock.bind(("127.0.0.1", 0))
65+
return int(sock.getsockname()[1])
66+
67+
68+
def _wait_healthy(base_url: str, timeout: float = 30.0) -> None:
69+
deadline = time.time() + timeout
70+
while time.time() < deadline:
71+
with contextlib.suppress(urllib.error.URLError, ConnectionError):
72+
with urllib.request.urlopen(f"{base_url}/health", timeout=2) as resp:
73+
if resp.status == 200:
74+
return
75+
time.sleep(0.3)
76+
raise RuntimeError(f"gateway did not become healthy at {base_url}")
77+
78+
79+
@pytest.fixture(scope="module")
80+
def gateway_url() -> Iterator[str]:
81+
cmd = os.environ.get("OTARI_GATEWAY_CMD", "gateway").split()
82+
port = _free_port()
83+
db_path = tempfile.NamedTemporaryFile(suffix=".db", delete=False).name
84+
proc = subprocess.Popen(
85+
[
86+
*cmd, "serve",
87+
"--database-url", f"sqlite:///{db_path}",
88+
"--master-key", MASTER_KEY,
89+
"--host", "127.0.0.1", "--port", str(port),
90+
"--auto-migrate", "--log-level", "40",
91+
],
92+
stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT,
93+
)
94+
base_url = f"http://127.0.0.1:{port}"
95+
try:
96+
_wait_healthy(base_url)
97+
yield base_url
98+
finally:
99+
proc.terminate()
100+
with contextlib.suppress(subprocess.TimeoutExpired):
101+
proc.wait(timeout=10)
102+
with contextlib.suppress(FileNotFoundError):
103+
os.unlink(db_path)
104+
105+
106+
@pytest.fixture
107+
def api_client(gateway_url: str) -> Iterator[object]:
108+
config = ocp.Configuration(host=gateway_url)
109+
# Management endpoints use Bearer auth with the master key (not Otari-Key).
110+
with ocp.ApiClient(config) as client:
111+
client.set_default_header("Authorization", f"Bearer {MASTER_KEY}")
112+
yield client
113+
114+
115+
def test_budgets_lifecycle(api_client: object) -> None:
116+
api = BudgetsApi(api_client)
117+
created = api.create_budget_v1_budgets_post(CreateBudgetRequest(max_budget=100.0, budget_duration_sec=3600))
118+
assert created.budget_id
119+
assert created.max_budget == 100.0
120+
bid = created.budget_id
121+
122+
assert any(b.budget_id == bid for b in api.list_budgets_v1_budgets_get())
123+
assert api.get_budget_v1_budgets_budget_id_get(bid).budget_id == bid
124+
125+
updated = api.update_budget_v1_budgets_budget_id_patch(bid, UpdateBudgetRequest(max_budget=250.0))
126+
assert updated.max_budget == 250.0
127+
128+
api.delete_budget_v1_budgets_budget_id_delete(bid)
129+
with pytest.raises(NotFoundException):
130+
api.get_budget_v1_budgets_budget_id_get(bid)
131+
132+
133+
def test_users_lifecycle(api_client: object) -> None:
134+
api = UsersApi(api_client)
135+
created = api.create_user_v1_users_post(CreateUserRequest(user_id="itest-user", alias="Alice"))
136+
assert created.user_id == "itest-user"
137+
assert created.alias == "Alice"
138+
139+
assert any(u.user_id == "itest-user" for u in api.list_users_v1_users_get())
140+
assert api.get_user_v1_users_user_id_get("itest-user").user_id == "itest-user"
141+
142+
updated = api.update_user_v1_users_user_id_patch("itest-user", UpdateUserRequest(alias="Alice2"))
143+
assert updated.alias == "Alice2"
144+
145+
# usage sub-resource is readable for a known user
146+
api.get_user_usage_v1_users_user_id_usage_get("itest-user")
147+
148+
api.delete_user_v1_users_user_id_delete("itest-user")
149+
with pytest.raises(NotFoundException):
150+
api.get_user_v1_users_user_id_get("itest-user")
151+
152+
153+
def test_keys_lifecycle_returns_secret_on_create(api_client: object) -> None:
154+
api = KeysApi(api_client)
155+
created = api.create_key_v1_keys_post(CreateKeyRequest(key_name="itest-key"))
156+
assert created.id
157+
# The one-time key value must be present on create (manually-created surface).
158+
assert getattr(created, "key", None), "create_key must return the key secret"
159+
kid = created.id
160+
161+
assert any(k.id == kid for k in api.list_keys_v1_keys_get())
162+
assert api.get_key_v1_keys_key_id_get(kid).id == kid
163+
164+
updated = api.update_key_v1_keys_key_id_patch(kid, UpdateKeyRequest(key_name="itest-key-renamed"))
165+
assert updated.key_name == "itest-key-renamed"
166+
167+
api.delete_key_v1_keys_key_id_delete(kid)
168+
with pytest.raises(NotFoundException):
169+
api.get_key_v1_keys_key_id_get(kid)
170+
171+
172+
def test_pricing_lifecycle(api_client: object) -> None:
173+
api = PricingApi(api_client)
174+
model_key = "openai:itest-model"
175+
created = api.set_pricing_v1_pricing_post(
176+
SetPricingRequest(model_key=model_key, input_price_per_million=1.0, output_price_per_million=2.0)
177+
)
178+
assert created.model_key == model_key
179+
180+
assert any(p.model_key == model_key for p in api.list_pricing_v1_pricing_get())
181+
assert api.get_pricing_v1_pricing_model_key_get(model_key).model_key == model_key
182+
# history is populated after at least one set
183+
assert api.get_pricing_history_v1_pricing_model_key_history_get(model_key) is not None
184+
185+
api.delete_pricing_v1_pricing_model_key_delete(model_key)
186+
with pytest.raises(NotFoundException):
187+
api.get_pricing_v1_pricing_model_key_get(model_key)
188+
189+
190+
def test_usage_is_readable(api_client: object) -> None:
191+
api = UsageApi(api_client)
192+
# Fresh gateway: usage list is readable (and empty), proving the typed GET works.
193+
assert api.list_usage_v1_usage_get() is not None

0 commit comments

Comments
 (0)