Complete Phase 1: policy kernel, REST service, and local smoke tooling
Implements QONTO-WP-0002 (policy-gated Qonto REST service with audit logging, rate limiting, and credential handling) and the ADHOC-2026-07-21 follow-up (fixture-backed local smoke mode, repo classification metadata). Marks QONTO-WP-0001/0002 and the ad-hoc workplan finished, and regenerates WORK-RECORDS.md and the ADHOC workplan's state_hub_workstream_id via fix-consistency. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
eef408bb19
commit
ca12843013
33 changed files with 2533 additions and 30 deletions
127
scripts/smoke_rest_api.py
Normal file
127
scripts/smoke_rest_api.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Run a local HTTP smoke test for qonto-assistant.")
|
||||
parser.add_argument(
|
||||
"--python",
|
||||
default=sys.executable,
|
||||
help="Python interpreter used to start qonto_assistant.main",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fixture-dir",
|
||||
default=str(Path(__file__).resolve().parents[1] / "tests" / "fixtures" / "qonto"),
|
||||
help="Fixture directory containing organization.json and transactions.json",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--startup-timeout",
|
||||
type=float,
|
||||
default=15.0,
|
||||
help="Seconds to wait for the local API to start",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def request_json(url: str, *, headers: dict[str, str] | None = None) -> dict[str, object]:
|
||||
request = urllib.request.Request(url, headers=headers or {})
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def wait_for_health(base_url: str, *, timeout_seconds: float) -> dict[str, object]:
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
last_error: Exception | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
return request_json(f"{base_url}/v1/health")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_error = exc
|
||||
time.sleep(0.25)
|
||||
raise RuntimeError(f"Timed out waiting for {base_url}/v1/health: {last_error}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
port = free_port()
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(repo_root / "src")
|
||||
env["QONTO_ASSISTANT_HOST"] = "127.0.0.1"
|
||||
env["QONTO_ASSISTANT_PORT"] = str(port)
|
||||
env["QONTO_FIXTURE_DIR"] = str(Path(args.fixture_dir).resolve())
|
||||
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
[args.python, "-m", "qonto_assistant.main"],
|
||||
cwd=repo_root,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
health = wait_for_health(base_url, timeout_seconds=args.startup_timeout)
|
||||
headers = {"X-Actor-ID": "smoke", "X-Tenant-ID": "binky"}
|
||||
accounts = request_json(f"{base_url}/v1/accounts", headers=headers)
|
||||
snapshot_recent = request_json(
|
||||
f"{base_url}/v1/snapshot?window_days=31&page_size=50",
|
||||
headers=headers,
|
||||
)
|
||||
snapshot_cost = request_json(
|
||||
f"{base_url}/v1/snapshot?window_days=90&page_size=50",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert health["status"] == "ok"
|
||||
assert accounts["organization"]["name"] == "Binky Hedgehog GmbH"
|
||||
assert accounts["accounts"][0]["iban_last4"] == "6810"
|
||||
assert snapshot_recent["cost_run_rate_hints"]["recurring_debits"] == []
|
||||
assert snapshot_cost["cost_run_rate_hints"]["recurring_debits"][0]["label"] == "HUB31"
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"health": health,
|
||||
"accounts": accounts,
|
||||
"snapshot_recent": snapshot_recent,
|
||||
"snapshot_cost": snapshot_cost,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
|
||||
if process.stdout is not None:
|
||||
output = process.stdout.read().strip()
|
||||
if output:
|
||||
sys.stderr.write(output + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue