Add retire_legacy_route() helper and convert CRUD, dependency, and execution intent legacy routes to 410 Gone while preserving final-call legacy-meter attribution. Update tests, retirement backlog, and close STATE-WP-0075 with handoff to STATE-WP-0070 T02 and STATE-WP-0073 T04.
488 lines
20 KiB
Python
488 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
|
|
async def _create_domain(client, slug="legacy-domain", name="Legacy Domain"):
|
|
r = await client.post("/domains/", json={"slug": slug, "name": name})
|
|
assert r.status_code == 201, r.text
|
|
return r.json()
|
|
|
|
|
|
async def _create_topic(client, domain_slug="legacy-domain", slug="legacy-topic", title="Legacy Topic"):
|
|
r = await client.post("/topics/", json={
|
|
"slug": slug,
|
|
"title": title,
|
|
"domain": domain_slug,
|
|
})
|
|
assert r.status_code == 201, r.text
|
|
return r.json()
|
|
|
|
|
|
async def _create_repo(client, domain_slug="legacy-domain", slug="legacy-repo"):
|
|
r = await client.post("/repos/", json={
|
|
"domain_slug": domain_slug,
|
|
"slug": slug,
|
|
"name": "Legacy Repo",
|
|
})
|
|
assert r.status_code == 201, r.text
|
|
return r.json()
|
|
|
|
|
|
async def _create_workplan(client, topic_id, domain_slug="legacy-domain", slug="legacy-wp", title="Legacy WP"):
|
|
repo = await _create_repo(client, domain_slug=domain_slug, slug=f"{slug}-repo")
|
|
r = await client.post("/workplans/", json={
|
|
"repo_id": repo["id"],
|
|
"topic_id": topic_id,
|
|
"slug": slug,
|
|
"title": title,
|
|
"status": "ready",
|
|
})
|
|
assert r.status_code == 201, r.text
|
|
return r.json()
|
|
|
|
|
|
def _summary_by_key(summary):
|
|
return {item["interface"]["interface_key"]: item for item in summary["interfaces"]}
|
|
|
|
|
|
class TestWorkplanAliasesAndLegacyMeter:
|
|
async def test_preferred_workplan_routes_do_not_meter_legacy_usage(self, client):
|
|
await _create_domain(client)
|
|
topic = await _create_topic(client)
|
|
wp = await _create_workplan(client, topic["id"])
|
|
|
|
r = await client.get(f"/workplans/?topic_id={topic['id']}")
|
|
assert r.status_code == 200
|
|
assert [row["id"] for row in r.json()] == [wp["id"]]
|
|
|
|
r = await client.patch(f"/workplans/{wp['id']}", json={"status": "active"})
|
|
assert r.status_code == 200
|
|
assert r.json()["status"] == "active"
|
|
|
|
r = await client.get("/legacy-meter/summary")
|
|
assert r.status_code == 200
|
|
assert r.json()["interfaces"] == []
|
|
|
|
async def test_legacy_get_workstreams_returns_410_and_meters(self, client):
|
|
await _create_domain(client)
|
|
topic = await _create_topic(client)
|
|
await _create_workplan(client, topic["id"])
|
|
|
|
r = await client.get(
|
|
f"/workstreams/?topic_id={topic['id']}",
|
|
headers={
|
|
"X-StateHub-Tenant": "tenant-a",
|
|
"X-StateHub-User": "alice",
|
|
"X-StateHub-Component": "old-client",
|
|
},
|
|
)
|
|
assert r.status_code == 410
|
|
assert "retired" in r.json()["detail"]
|
|
assert r.headers["Deprecation"] == "true"
|
|
assert r.headers.get("Sunset")
|
|
assert r.headers["X-StateHub-Replacement"] == "/workplans/"
|
|
|
|
summary = (await client.get("/legacy-meter/summary")).json()
|
|
item = _summary_by_key(summary)["rest_api:GET /workstreams/"]
|
|
assert item["window"]["calls"] == 1
|
|
assert item["window"]["tenants"] == {"tenant-a": 1}
|
|
assert item["window"]["users"] == {"alice": 1}
|
|
assert item["window"]["components"] == {"old-client": 1}
|
|
|
|
async def test_weekly_review_accepts_hours_window(self, client):
|
|
r = await client.post("/legacy-meter/interfaces", json={
|
|
"interface_key": "rest_api:GET /hour-window",
|
|
"interface_kind": "rest_api",
|
|
"replacement_ref": "/workplans/",
|
|
"replacement_verified": True,
|
|
})
|
|
assert r.status_code == 201, r.text
|
|
|
|
review = (await client.get("/legacy-meter/weekly-review?hours=8")).json()
|
|
window_seconds = (
|
|
datetime.fromisoformat(review["window_end"].replace("Z", "+00:00"))
|
|
- datetime.fromisoformat(review["window_start"].replace("Z", "+00:00"))
|
|
).total_seconds()
|
|
assert 8 * 3600 - 5 <= window_seconds <= 8 * 3600 + 5
|
|
|
|
bad = await client.get("/legacy-meter/weekly-review?days=7&hours=8")
|
|
assert bad.status_code == 422
|
|
|
|
async def test_weekly_review_reports_unused_verified_legacy_interface(self, client):
|
|
r = await client.post("/legacy-meter/interfaces", json={
|
|
"interface_key": "rest_api:GET /obsolete",
|
|
"interface_kind": "rest_api",
|
|
"replacement_ref": "/workplans/",
|
|
"replacement_verified": True,
|
|
})
|
|
assert r.status_code == 201, r.text
|
|
|
|
review = (await client.get("/legacy-meter/weekly-review")).json()
|
|
assert review["activity_core_handoff"]["scheduler_owner"] == "activity-core"
|
|
assert review["activity_core_handoff"]["source_endpoint"] == "/legacy-meter/weekly-review"
|
|
assert "window_start" in review
|
|
assert "window_end" in review
|
|
assert isinstance(review["interfaces"], list)
|
|
assert isinstance(review["retirement_candidates"], list)
|
|
candidates = _summary_by_key({"interfaces": review["retirement_candidates"]})
|
|
assert "rest_api:GET /obsolete" in candidates
|
|
assert candidates["rest_api:GET /obsolete"]["retirement_reason"] == "no measured usage in review window"
|
|
|
|
if os.environ.get("CAPTURE_LEGACY_METER_EVIDENCE"):
|
|
evidence_dir = Path(__file__).resolve().parents[1] / "docs" / "evidence"
|
|
evidence_dir.mkdir(parents=True, exist_ok=True)
|
|
today = datetime.now(tz=timezone.utc).strftime("%Y%m%d")
|
|
out = evidence_dir / f"legacy-meter-weekly-review-{today}.json"
|
|
payload = {
|
|
"captured_at": datetime.now(tz=timezone.utc).isoformat(),
|
|
"source": "pytest",
|
|
"workplan": "STATE-WP-0069",
|
|
"weekly_review": review,
|
|
}
|
|
out.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
|
|
async def test_recent_usage_blocks_weekly_retirement_candidate(self, client):
|
|
r = await client.post("/legacy-meter/usage", json={
|
|
"interface_key": "rest_api:GET /old-but-used",
|
|
"interface_kind": "rest_api",
|
|
"replacement_ref": "/workplans/",
|
|
"replacement_verified": True,
|
|
"tenant_key": "tenant-a",
|
|
"user_key": "alice",
|
|
"component_key": "old-client",
|
|
})
|
|
assert r.status_code == 201, r.text
|
|
|
|
review = (await client.get("/legacy-meter/weekly-review")).json()
|
|
items = _summary_by_key(review)
|
|
item = items["rest_api:GET /old-but-used"]
|
|
assert item["window"]["calls"] == 1
|
|
assert item["retirement_candidate"] is False
|
|
assert item["retirement_reason"] == "1 call(s) in review window"
|
|
|
|
async def test_legacy_patch_workstream_returns_410_and_meters(self, client):
|
|
await _create_domain(client)
|
|
topic = await _create_topic(client)
|
|
wp = await _create_workplan(client, topic["id"])
|
|
|
|
r = await client.patch(
|
|
f"/workstreams/{wp['id']}",
|
|
json={"status": "finished"},
|
|
headers={"X-StateHub-Component": "old-client"},
|
|
)
|
|
assert r.status_code == 410
|
|
assert "retired" in r.json()["detail"]
|
|
|
|
summary = (await client.get("/legacy-meter/summary")).json()
|
|
items = _summary_by_key(summary)
|
|
assert items["rest_api:PATCH /workstreams/{workstream_id}"]["window"]["components"] == {
|
|
"old-client": 1
|
|
}
|
|
|
|
async def test_legacy_workplan_index_returns_410(self, client):
|
|
r = await client.get(
|
|
"/workstreams/workplan-index",
|
|
headers={"X-StateHub-Component": "old-index-client"},
|
|
)
|
|
assert r.status_code == 410
|
|
assert "retired" in r.json()["detail"]
|
|
assert r.headers["X-StateHub-Replacement"] == "/workplans/index"
|
|
|
|
summary = (await client.get("/legacy-meter/summary")).json()
|
|
item = _summary_by_key(summary)["rest_api:GET /workstreams/workplan-index"]
|
|
assert item["window"]["calls"] == 1
|
|
assert item["window"]["components"] == {"old-index-client": 1}
|
|
|
|
async def test_legacy_delete_workstream_returns_410(self, client):
|
|
await _create_domain(client)
|
|
topic = await _create_topic(client)
|
|
wp = await _create_workplan(client, topic["id"])
|
|
|
|
r = await client.delete(
|
|
f"/workstreams/{wp['id']}",
|
|
headers={"X-StateHub-Component": "old-archiver"},
|
|
)
|
|
assert r.status_code == 410
|
|
assert "retired" in r.json()["detail"]
|
|
assert r.headers["Deprecation"] == "true"
|
|
assert r.headers["X-StateHub-Replacement"] == "DELETE /workplans/{workplan_id}"
|
|
|
|
summary = (await client.get("/legacy-meter/summary")).json()
|
|
item = _summary_by_key(summary)["rest_api:DELETE /workstreams/{workstream_id}"]
|
|
assert item["window"]["calls"] == 1
|
|
assert item["window"]["components"] == {"old-archiver": 1}
|
|
|
|
async def test_legacy_workstream_id_query_param_on_tasks_is_metered(self, client):
|
|
await _create_domain(client)
|
|
topic = await _create_topic(client)
|
|
wp = await _create_workplan(client, topic["id"])
|
|
task_r = await client.post("/tasks/", json={
|
|
"workplan_id": wp["id"],
|
|
"title": "Meter me",
|
|
"status": "todo",
|
|
})
|
|
assert task_r.status_code == 201, task_r.text
|
|
|
|
r = await client.get(
|
|
f"/tasks/?workstream_id={wp['id']}",
|
|
headers={
|
|
"X-StateHub-Tenant": "tenant-a",
|
|
"X-StateHub-User": "alice",
|
|
"X-StateHub-Component": "old-task-client",
|
|
},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.headers["Deprecation"] == "true"
|
|
assert r.headers["X-StateHub-Replacement"] == "/tasks/?workplan_id=<workplan_id>"
|
|
|
|
summary = (await client.get("/legacy-meter/summary")).json()
|
|
item = _summary_by_key(summary)["rest_api:GET /tasks/?workstream_id"]
|
|
assert item["window"]["calls"] == 1
|
|
assert item["window"]["components"] == {"old-task-client": 1}
|
|
|
|
async def test_legacy_workstream_id_query_param_on_progress_is_metered(self, client):
|
|
await _create_domain(client)
|
|
topic = await _create_topic(client)
|
|
wp = await _create_workplan(client, topic["id"])
|
|
|
|
r = await client.get(
|
|
f"/progress/?workstream_id={wp['id']}",
|
|
headers={"X-StateHub-Component": "old-progress-client"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.headers["Deprecation"] == "true"
|
|
assert r.headers["X-StateHub-Replacement"] == "/progress/?workplan_id=<workplan_id>"
|
|
|
|
summary = (await client.get("/legacy-meter/summary")).json()
|
|
item = _summary_by_key(summary)["rest_api:GET /progress/?workstream_id"]
|
|
assert item["window"]["calls"] == 1
|
|
assert item["window"]["components"] == {"old-progress-client": 1}
|
|
|
|
async def test_legacy_workstream_id_body_on_progress_post_is_metered(self, client):
|
|
await _create_domain(client)
|
|
topic = await _create_topic(client)
|
|
wp = await _create_workplan(client, topic["id"])
|
|
|
|
r = await client.post(
|
|
"/progress/",
|
|
json={
|
|
"workstream_id": str(wp["id"]),
|
|
"event_type": "note",
|
|
"summary": "legacy body field",
|
|
"author": "test",
|
|
},
|
|
headers={"X-StateHub-Component": "old-progress-writer"},
|
|
)
|
|
assert r.status_code == 201
|
|
assert r.headers["Deprecation"] == "true"
|
|
assert r.headers["X-StateHub-Replacement"] == "POST /progress/ with workplan_id"
|
|
|
|
summary = (await client.get("/legacy-meter/summary")).json()
|
|
item = _summary_by_key(summary)["rest_api:POST /progress/ workstream_id"]
|
|
assert item["window"]["calls"] == 1
|
|
assert item["window"]["components"] == {"old-progress-writer": 1}
|
|
|
|
async def test_workplan_id_body_on_progress_post_is_not_metered(self, client):
|
|
await _create_domain(client)
|
|
topic = await _create_topic(client)
|
|
wp = await _create_workplan(client, topic["id"])
|
|
|
|
r = await client.post(
|
|
"/progress/",
|
|
json={
|
|
"workplan_id": str(wp["id"]),
|
|
"event_type": "note",
|
|
"summary": "preferred body field",
|
|
"author": "test",
|
|
},
|
|
)
|
|
assert r.status_code == 201
|
|
assert r.headers.get("Deprecation") != "true"
|
|
|
|
summary = (await client.get("/legacy-meter/summary")).json()
|
|
assert _summary_by_key(summary).get("rest_api:POST /progress/ workstream_id") is None
|
|
|
|
async def test_legacy_workstream_id_body_on_tasks_post_is_metered(self, client):
|
|
await _create_domain(client)
|
|
topic = await _create_topic(client)
|
|
wp = await _create_workplan(client, topic["id"])
|
|
|
|
r = await client.post(
|
|
"/tasks/",
|
|
json={
|
|
"workstream_id": str(wp["id"]),
|
|
"title": "legacy task body",
|
|
"status": "todo",
|
|
},
|
|
headers={"X-StateHub-Component": "old-task-writer"},
|
|
)
|
|
assert r.status_code == 201
|
|
assert r.headers["Deprecation"] == "true"
|
|
|
|
summary = (await client.get("/legacy-meter/summary")).json()
|
|
item = _summary_by_key(summary)["rest_api:POST /tasks/ workstream_id"]
|
|
assert item["window"]["calls"] == 1
|
|
assert item["window"]["components"] == {"old-task-writer": 1}
|
|
|
|
async def test_legacy_workstream_id_body_on_decisions_post_is_metered(self, client):
|
|
await _create_domain(client)
|
|
topic = await _create_topic(client)
|
|
wp = await _create_workplan(client, topic["id"])
|
|
|
|
r = await client.post(
|
|
"/decisions/",
|
|
json={
|
|
"workstream_id": str(wp["id"]),
|
|
"title": "legacy decision body",
|
|
"decision_type": "pending",
|
|
},
|
|
headers={"X-StateHub-Component": "old-decision-writer"},
|
|
)
|
|
assert r.status_code == 201
|
|
assert r.headers["Deprecation"] == "true"
|
|
|
|
summary = (await client.get("/legacy-meter/summary")).json()
|
|
item = _summary_by_key(summary)["rest_api:POST /decisions/ workstream_id"]
|
|
assert item["window"]["calls"] == 1
|
|
|
|
async def test_workplan_id_query_param_on_tasks_is_not_metered(self, client):
|
|
await _create_domain(client)
|
|
topic = await _create_topic(client)
|
|
wp = await _create_workplan(client, topic["id"])
|
|
|
|
r = await client.get(f"/tasks/?workplan_id={wp['id']}")
|
|
assert r.status_code == 200
|
|
assert r.headers.get("Deprecation") != "true"
|
|
|
|
summary = (await client.get("/legacy-meter/summary")).json()
|
|
assert _summary_by_key(summary).get("rest_api:GET /tasks/?workstream_id") is None
|
|
|
|
async def test_workplan_dependency_and_execution_aliases(self, client):
|
|
await _create_domain(client)
|
|
topic = await _create_topic(client)
|
|
first = await _create_workplan(client, topic["id"], slug="first", title="First")
|
|
second = await _create_workplan(client, topic["id"], slug="second", title="Second")
|
|
|
|
r = await client.post(
|
|
f"/workplans/{first['id']}/dependencies/",
|
|
json={"to_workstream_id": second["id"]},
|
|
)
|
|
assert r.status_code == 201, r.text
|
|
|
|
r = await client.get(f"/workplans/{first['id']}/dependencies/")
|
|
assert r.status_code == 200
|
|
assert len(r.json()) == 1
|
|
|
|
r = await client.patch(
|
|
f"/execution/workplans/{first['id']}/intent",
|
|
json={"execution_state": "queued", "launch_mode": "queued"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["execution_state"] == "queued"
|
|
|
|
r = await client.patch(
|
|
f"/execution/workstreams/{first['id']}/intent",
|
|
json={"execution_state": "manual", "launch_mode": "manual"},
|
|
headers={"X-StateHub-Component": "old-executor"},
|
|
)
|
|
assert r.status_code == 410
|
|
assert "retired" in r.json()["detail"]
|
|
assert r.headers["X-StateHub-Replacement"] == "/execution/workplans/{workplan_id}/intent"
|
|
|
|
summary = (await client.get("/legacy-meter/summary")).json()
|
|
item = _summary_by_key(summary)["rest_api:PATCH /execution/workstreams/{workstream_id}/intent"]
|
|
assert item["window"]["components"] == {"old-executor": 1}
|
|
|
|
async def test_legacy_post_workstream_returns_410_and_meters(self, client):
|
|
await _create_domain(client)
|
|
topic = await _create_topic(client)
|
|
repo = await _create_repo(client, slug="legacy-create-repo")
|
|
|
|
r = await client.post(
|
|
"/workstreams/",
|
|
json={
|
|
"repo_id": str(repo["id"]),
|
|
"topic_id": str(topic["id"]),
|
|
"slug": "legacy-create",
|
|
"title": "Legacy Create",
|
|
"status": "active",
|
|
},
|
|
headers={"X-StateHub-Component": "old-creator"},
|
|
)
|
|
assert r.status_code == 410
|
|
assert "retired" in r.json()["detail"]
|
|
assert r.headers["X-StateHub-Replacement"] == "/workplans/"
|
|
|
|
summary = (await client.get("/legacy-meter/summary")).json()
|
|
item = _summary_by_key(summary)["rest_api:POST /workstreams/"]
|
|
assert item["window"]["calls"] == 1
|
|
assert item["window"]["components"] == {"old-creator": 1}
|
|
|
|
async def test_legacy_get_workstream_detail_returns_410_and_meters(self, client):
|
|
await _create_domain(client)
|
|
topic = await _create_topic(client)
|
|
wp = await _create_workplan(client, topic["id"])
|
|
|
|
r = await client.get(
|
|
f"/workstreams/{wp['id']}",
|
|
headers={"X-StateHub-Component": "old-reader"},
|
|
)
|
|
assert r.status_code == 410
|
|
assert "retired" in r.json()["detail"]
|
|
assert r.headers["X-StateHub-Replacement"] == "/workplans/{workplan_id}"
|
|
|
|
summary = (await client.get("/legacy-meter/summary")).json()
|
|
item = _summary_by_key(summary)["rest_api:GET /workstreams/{workstream_id}"]
|
|
assert item["window"]["calls"] == 1
|
|
assert item["window"]["components"] == {"old-reader": 1}
|
|
|
|
async def test_legacy_workstream_dependency_routes_return_410_and_meter(self, client):
|
|
await _create_domain(client)
|
|
topic = await _create_topic(client)
|
|
first = await _create_workplan(client, topic["id"], slug="dep-first", title="First")
|
|
second = await _create_workplan(client, topic["id"], slug="dep-second", title="Second")
|
|
|
|
dep = await client.post(
|
|
f"/workplans/{first['id']}/dependencies/",
|
|
json={"to_workplan_id": second["id"]},
|
|
)
|
|
assert dep.status_code == 201, dep.text
|
|
dep_id = dep.json()["id"]
|
|
|
|
r = await client.post(
|
|
f"/workstreams/{first['id']}/dependencies/",
|
|
json={"to_workstream_id": second["id"]},
|
|
headers={"X-StateHub-Component": "old-dep-writer"},
|
|
)
|
|
assert r.status_code == 410
|
|
assert "retired" in r.json()["detail"]
|
|
|
|
r = await client.get(
|
|
f"/workstreams/{first['id']}/dependencies/",
|
|
headers={"X-StateHub-Component": "old-dep-reader"},
|
|
)
|
|
assert r.status_code == 410
|
|
assert "retired" in r.json()["detail"]
|
|
|
|
r = await client.delete(
|
|
f"/workstreams/{first['id']}/dependencies/{dep_id}",
|
|
headers={"X-StateHub-Component": "old-dep-archiver"},
|
|
)
|
|
assert r.status_code == 410
|
|
assert "retired" in r.json()["detail"]
|
|
|
|
summary = (await client.get("/legacy-meter/summary")).json()
|
|
items = _summary_by_key(summary)
|
|
assert items["rest_api:POST /workstreams/{workstream_id}/dependencies/"]["window"]["components"] == {
|
|
"old-dep-writer": 1
|
|
}
|
|
assert items["rest_api:GET /workstreams/{workstream_id}/dependencies/"]["window"]["components"] == {
|
|
"old-dep-reader": 1
|
|
}
|
|
assert items["rest_api:DELETE /workstreams/{workstream_id}/dependencies/{dep_id}"]["window"]["components"] == {
|
|
"old-dep-archiver": 1
|
|
}
|