362 lines
12 KiB
Python
362 lines
12 KiB
Python
"""Contract test for the robomp server API response vs status-contract.json.
|
|
|
|
Regenerate the committed fixture with:
|
|
ROBOMP_UPDATE_STATUS_CONTRACT=1 pytest tests/test_status_contract.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from robomp.config import Settings, reset_settings_cache
|
|
from robomp.db import get_database
|
|
from robomp.server import create_app
|
|
|
|
|
|
# Runtime/timestamp fields vary every run; normalize them so the live payload
|
|
# can be compared against (or regenerated into) a byte-stable committed fixture.
|
|
_VOLATILE_TS_KEYS = {"received_at", "started_at", "last_tool_ts", "updated_at"}
|
|
_FIXED_TS = "2024-01-01T00:00:00+00:00"
|
|
_UPDATE_ENV = "ROBOMP_UPDATE_STATUS_CONTRACT"
|
|
|
|
|
|
def _normalize_for_fixture(value: Any) -> Any:
|
|
if isinstance(value, dict):
|
|
normalized: dict[str, Any] = {}
|
|
for key, item in value.items():
|
|
if key == "uptime_seconds":
|
|
normalized[key] = 0.0
|
|
elif key in _VOLATILE_TS_KEYS and item is not None:
|
|
normalized[key] = _FIXED_TS
|
|
else:
|
|
normalized[key] = _normalize_for_fixture(item)
|
|
return normalized
|
|
if isinstance(value, list):
|
|
return [_normalize_for_fixture(item) for item in value]
|
|
return value
|
|
|
|
|
|
def test_status_contract(settings: Settings) -> None:
|
|
app = create_app(settings)
|
|
with TestClient(app) as client:
|
|
# Seed AFTER startup:
|
|
db = get_database(settings.sqlite_path)
|
|
|
|
# 1. A running issue with live detail:
|
|
db.upsert_issue(
|
|
key="octo/widget#1",
|
|
state="reproducing",
|
|
repo="octo/widget",
|
|
number=1,
|
|
branch="farm/abc12345/fix",
|
|
pr_number=77,
|
|
)
|
|
db.record_event(
|
|
delivery_id="run-x",
|
|
event_type="issue_comment",
|
|
repo="octo/widget",
|
|
issue_key="octo/widget#1",
|
|
payload={"action": "created"},
|
|
)
|
|
# claim_next_event will return run-x since it is queued
|
|
claimed = db.claim_next_event()
|
|
assert claimed is not None
|
|
assert claimed.delivery_id == "run-x"
|
|
db.set_event_model("run-x", "anthropic/claude-3-5-sonnet")
|
|
db.log_tool_call(issue_key="octo/widget#1", tool="edit", args={})
|
|
|
|
# 2. A failed issue:
|
|
db.upsert_issue(
|
|
key="octo/widget#2",
|
|
state="fixing",
|
|
repo="octo/widget",
|
|
number=2,
|
|
)
|
|
db.record_event(
|
|
delivery_id="failed-x",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key="octo/widget#2",
|
|
state="failed",
|
|
last_error="repro diverged",
|
|
payload={"action": "opened"},
|
|
)
|
|
|
|
# 3. A superseded failed issue (older failed event + newer done event):
|
|
db.upsert_issue(
|
|
key="octo/widget#3",
|
|
state="fixing",
|
|
repo="octo/widget",
|
|
number=3,
|
|
)
|
|
db.record_event(
|
|
delivery_id="superseded-failed",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key="octo/widget#3",
|
|
state="failed",
|
|
last_error="old error",
|
|
payload={"action": "opened"},
|
|
)
|
|
db.record_event(
|
|
delivery_id="new-done",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key="octo/widget#3",
|
|
state="done",
|
|
payload={"action": "opened"},
|
|
)
|
|
|
|
# 4. An issue-less failed (orphan failed):
|
|
db.record_event(
|
|
delivery_id="orphan-failed-x",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key=None,
|
|
state="failed",
|
|
last_error="orphan failed error",
|
|
payload={"action": "opened"},
|
|
)
|
|
|
|
# 5. A terminal issue:
|
|
db.upsert_issue(
|
|
key="octo/widget#4",
|
|
state="merged",
|
|
repo="octo/widget",
|
|
number=4,
|
|
)
|
|
db.record_event(
|
|
delivery_id="terminal-done",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key="octo/widget#4",
|
|
state="done",
|
|
payload={"action": "opened"},
|
|
)
|
|
|
|
# 6. A queued issue:
|
|
db.upsert_issue(
|
|
key="octo/widget#5",
|
|
state="new",
|
|
repo="octo/widget",
|
|
number=5,
|
|
)
|
|
db.record_event(
|
|
delivery_id="queued-x",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key="octo/widget#5",
|
|
state="queued",
|
|
payload={"action": "opened"},
|
|
)
|
|
|
|
# 7. An active issue (no latest event):
|
|
db.upsert_issue(
|
|
key="octo/widget#6",
|
|
state="new",
|
|
repo="octo/widget",
|
|
number=6,
|
|
)
|
|
|
|
resp = client.get("/api/status")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
|
|
# Assert Python-side: top-level keys exactly:
|
|
expected_keys = {"runtime", "event_counts", "issue_event_counts", "running_events", "inflight", "issues", "recent_events"}
|
|
assert set(data.keys()) == expected_keys
|
|
|
|
# check running_events[0] keys:
|
|
running_ev = data["running_events"]
|
|
assert len(running_ev) == 1
|
|
assert set(running_ev[0].keys()) == {
|
|
"delivery_id", "event_type", "repo", "issue_key", "received_at",
|
|
"started_at", "attempts", "model", "last_tool", "last_tool_ts"
|
|
}
|
|
assert running_ev[0]["model"] == "anthropic/claude-3-5-sonnet"
|
|
assert running_ev[0]["last_tool"] == "edit"
|
|
|
|
# check issues keys / latest_event keys:
|
|
for issue_row in data["issues"]:
|
|
assert set(issue_row.keys()) == {
|
|
"key", "repo", "number", "branch", "pr_number",
|
|
"state", "classification", "updated_at", "latest_event",
|
|
}
|
|
latest = issue_row["latest_event"]
|
|
if latest is not None:
|
|
assert set(latest.keys()) == {
|
|
"delivery_id", "event_type", "state", "attempts", "received_at", "last_error"
|
|
}
|
|
|
|
# check runtime:
|
|
assert data["runtime"]["repo_allowlist"] == ["octo/widget"]
|
|
|
|
# Compare the normalized payload against the committed fixture. Normal
|
|
# pytest runs assert equality; regenerate only when ROBOMP_UPDATE_STATUS_CONTRACT=1.
|
|
fixture_path = Path(__file__).parent.parent / "web/test/fixtures/status-contract.json"
|
|
actual = _normalize_for_fixture(data)
|
|
if os.environ.get(_UPDATE_ENV) == "1":
|
|
fixture_path.parent.mkdir(parents=True, exist_ok=True)
|
|
fixture_path.write_text(json.dumps(actual, indent=2), encoding="utf-8")
|
|
else:
|
|
expected = json.loads(fixture_path.read_text(encoding="utf-8"))
|
|
assert actual == expected
|
|
|
|
|
|
def _enable_replay(monkeypatch: pytest.MonkeyPatch) -> str:
|
|
token = "trigger-secret"
|
|
monkeypatch.setenv("ROBOMP_REPLAY_TOKEN", token)
|
|
reset_settings_cache()
|
|
return token
|
|
|
|
|
|
def test_cancel_happy_path(env, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
token = _enable_replay(monkeypatch)
|
|
cfg = Settings()
|
|
cfg.ensure_paths()
|
|
app = create_app(cfg)
|
|
with TestClient(app) as client:
|
|
db = get_database(cfg.sqlite_path)
|
|
db.record_event(
|
|
delivery_id="run-cancel-1",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key="octo/widget#1",
|
|
payload={"action": "opened"},
|
|
)
|
|
# claim it to make it running
|
|
claimed = db.claim_next_event()
|
|
assert claimed is not None
|
|
assert claimed.delivery_id == "run-cancel-1"
|
|
|
|
# Happy path
|
|
resp = client.post(
|
|
"/api/cancel",
|
|
json={"delivery_id": "run-cancel-1"},
|
|
headers={"X-Robomp-Replay-Token": token},
|
|
)
|
|
assert resp.status_code == 202
|
|
assert resp.json() == {
|
|
"delivery": "run-cancel-1",
|
|
"fired": False,
|
|
"previous_state": "running",
|
|
}
|
|
|
|
|
|
def test_cancel_errors_and_gating(env, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
token = _enable_replay(monkeypatch)
|
|
cfg = Settings()
|
|
cfg.ensure_paths()
|
|
app = create_app(cfg)
|
|
with TestClient(app) as client:
|
|
db = get_database(cfg.sqlite_path)
|
|
db.record_event(
|
|
delivery_id="run-cancel-2",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key="octo/widget#1",
|
|
payload={"action": "opened"},
|
|
)
|
|
# Not claimed, so it is queued
|
|
|
|
# 404 unknown
|
|
resp = client.post(
|
|
"/api/cancel",
|
|
json={"delivery_id": "nope"},
|
|
headers={"X-Robomp-Replay-Token": token},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
# 409 queued/non-running delivery: do not poison WorkerPool._cancelled.
|
|
resp = client.post(
|
|
"/api/cancel",
|
|
json={"delivery_id": "run-cancel-2"},
|
|
headers={"X-Robomp-Replay-Token": token},
|
|
)
|
|
assert resp.status_code == 409
|
|
queued = db.get_event("run-cancel-2")
|
|
assert queued is not None
|
|
assert queued.state == "queued"
|
|
|
|
# 401 with a bad token (header present but wrong value)
|
|
resp = client.post(
|
|
"/api/cancel",
|
|
json={"delivery_id": "run-cancel-2"},
|
|
headers={"X-Robomp-Replay-Token": "bad-token"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
# 401 with the header missing entirely (distinct from a bad token)
|
|
resp = client.post(
|
|
"/api/cancel",
|
|
json={"delivery_id": "run-cancel-2"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
# with replay disabled (token not set) -> 404
|
|
monkeypatch.setenv("ROBOMP_REPLAY_TOKEN", "")
|
|
reset_settings_cache()
|
|
cfg_disabled = Settings()
|
|
cfg_disabled.ensure_paths()
|
|
app_disabled = create_app(cfg_disabled)
|
|
with TestClient(app_disabled) as client:
|
|
resp = client.post(
|
|
"/api/cancel",
|
|
json={"delivery_id": "run-cancel-2"},
|
|
headers={"X-Robomp-Replay-Token": token},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
def test_retry_state_transition(env, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
token = _enable_replay(monkeypatch)
|
|
cfg = Settings()
|
|
cfg.ensure_paths()
|
|
app = create_app(cfg)
|
|
with TestClient(app) as client:
|
|
db = get_database(cfg.sqlite_path)
|
|
db.record_event(
|
|
delivery_id="running-same-issue",
|
|
event_type="issue_comment",
|
|
repo="octo/widget",
|
|
issue_key="octo/widget#1",
|
|
payload={"action": "created"},
|
|
)
|
|
claimed = db.claim_next_event()
|
|
assert claimed is not None
|
|
assert claimed.delivery_id == "running-same-issue"
|
|
|
|
db.record_event(
|
|
delivery_id="failed-retry-1",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key="octo/widget#1",
|
|
state="failed",
|
|
last_error="error",
|
|
payload={"action": "opened"},
|
|
)
|
|
|
|
resp = client.post(
|
|
"/api/trigger",
|
|
json={"mode": "retry", "delivery_id": "failed-retry-1"},
|
|
headers={"X-Robomp-Replay-Token": token},
|
|
)
|
|
assert resp.status_code == 202
|
|
assert resp.json() == {
|
|
"delivery": "failed-retry-1",
|
|
"state": "queued",
|
|
"mode": "retry",
|
|
}
|
|
|
|
# Verify DB side state transition
|
|
evt = db.get_event("failed-retry-1")
|
|
assert evt is not None
|
|
assert evt.state == "queued"
|
|
|