feat(ttp): E.1.9 API contract — seven router endpoints, admin-gated state mutations, response models

Mounts /api/v1/ttp/* with empty-list / empty-Navigator responses.
GET endpoints viewer-gated; POST/DELETE /rules/{rule_id}/state
admin-gated server-side. POST parses JSON manually so a malformed
body returns the documented 400 (per feedback_schemathesis_400).

Drops xfail-strict markers from E.2.8 tests now that the router is
mounted; 26 tests pass against the contract handlers.
This commit is contained in:
2026-05-01 07:20:13 -04:00
parent cfbfaabfcd
commit b7f206c8c5
15 changed files with 515 additions and 56 deletions

View File

@@ -1,15 +1,10 @@
"""E.2.8 — GET endpoint shape + auth contract for /api/v1/ttp/*.
Today no TTP router is mounted under :mod:`decnet.web.api`; every
assertion that the documented endpoint exists (200 with a JWT, 401
without) lives behind ``@pytest.mark.xfail(strict=True)`` so this
suite is GREEN today and trips the day E.3.8 wires the router.
The router-presence sanity test is the only assertion that compiles
GREEN today: it asserts that AT LEAST ONE of the documented paths
returns something OTHER than 404 (i.e. the router exists). It is
xfail-strict — when the router lands, the marker flips and the suite
exercises the rest of the contract.
The TTP router landed at E.1.9 (contract phase) returning typed
empty values. Every documented GET endpoint is now exercised: 200
with a JWT, 401 without. Repo-backed data lands at E.3 implementation
phase; this suite stays green across that transition because the
shape contract is stable from E.1.9 forward.
"""
from __future__ import annotations
@@ -58,10 +53,6 @@ _GET_ENDPOINTS: list[str] = [
@pytest.mark.parametrize("path", _GET_ENDPOINTS)
@pytest.mark.xfail(
strict=True,
reason="impl phase E.3.8: TTP router not yet mounted",
)
@pytest.mark.asyncio
async def test_get_returns_200_with_jwt(
client: httpx.AsyncClient, auth_token: str, path: str,
@@ -77,10 +68,6 @@ async def test_get_returns_200_with_jwt(
@pytest.mark.parametrize("path", _GET_ENDPOINTS)
@pytest.mark.xfail(
strict=True,
reason="impl phase E.3.8: TTP router not yet mounted",
)
@pytest.mark.asyncio
async def test_get_returns_401_without_jwt(
client: httpx.AsyncClient, path: str,
@@ -95,10 +82,6 @@ async def test_get_returns_401_without_jwt(
# ─── Router-presence sanity ──────────────────────────────────────────────────
@pytest.mark.xfail(
strict=True,
reason="impl phase E.3.8: /api/v1/ttp router not yet mounted",
)
@pytest.mark.asyncio
async def test_ttp_router_is_mounted(
client: httpx.AsyncClient, auth_token: str,

View File

@@ -42,10 +42,6 @@ def test_placeholder_golden_is_stable() -> None:
)
@pytest.mark.xfail(
strict=True,
reason="impl phase E.3.8: TTP router not yet contributing to OpenAPI",
)
def test_openapi_includes_ttp_paths() -> None:
"""Every documented TTP endpoint must appear in the live OpenAPI
schema once the router lands. Pinned as a strict-xfail so the

View File

@@ -5,8 +5,9 @@ disable/clip/TTL knobs. Per the project's "no client-side role
checks" rule, the assertions here all hit the server and inspect
the response — never a feature flag, never a route table.
Today the router does not exist; every assertion is
``xfail(strict=True)`` and trips when E.3.8 wires it.
The router landed at E.1.9 with the admin guard on POST/DELETE; the
assertions exercise the auth + body-validation contract directly.
Persistence (state actually surviving a roundtrip) lands in E.3.
"""
from __future__ import annotations
@@ -29,10 +30,6 @@ def _path() -> str:
# ─── POST /rules/{rule_id}/state ─────────────────────────────────────────────
@pytest.mark.xfail(
strict=True,
reason="impl phase E.3.8: TTP router not yet mounted",
)
@pytest.mark.asyncio
async def test_post_state_without_jwt_is_401(
client: httpx.AsyncClient,
@@ -41,10 +38,6 @@ async def test_post_state_without_jwt_is_401(
assert res.status_code == 401, res.text
@pytest.mark.xfail(
strict=True,
reason="impl phase E.3.8: TTP router not yet mounted",
)
@pytest.mark.asyncio
async def test_post_state_non_admin_is_403_server_side(
client: httpx.AsyncClient, viewer_token: str,
@@ -59,10 +52,6 @@ async def test_post_state_non_admin_is_403_server_side(
assert res.status_code == 403, res.text
@pytest.mark.xfail(
strict=True,
reason="impl phase E.3.8: TTP router not yet mounted",
)
@pytest.mark.asyncio
async def test_post_state_admin_is_200(
client: httpx.AsyncClient, auth_token: str,
@@ -73,10 +62,6 @@ async def test_post_state_admin_is_200(
assert res.status_code == 200, res.text
@pytest.mark.xfail(
strict=True,
reason="impl phase E.3.8: TTP router not yet mounted",
)
@pytest.mark.asyncio
async def test_post_state_malformed_body_is_400(
client: httpx.AsyncClient, auth_token: str,
@@ -98,10 +83,6 @@ async def test_post_state_malformed_body_is_400(
# ─── DELETE /rules/{rule_id}/state ───────────────────────────────────────────
@pytest.mark.xfail(
strict=True,
reason="impl phase E.3.8: TTP router not yet mounted",
)
@pytest.mark.asyncio
async def test_delete_state_without_jwt_is_401(
client: httpx.AsyncClient,
@@ -110,10 +91,6 @@ async def test_delete_state_without_jwt_is_401(
assert res.status_code == 401, res.text
@pytest.mark.xfail(
strict=True,
reason="impl phase E.3.8: TTP router not yet mounted",
)
@pytest.mark.asyncio
async def test_delete_state_non_admin_is_403_server_side(
client: httpx.AsyncClient, viewer_token: str,
@@ -122,10 +99,6 @@ async def test_delete_state_non_admin_is_403_server_side(
assert res.status_code == 403, res.text
@pytest.mark.xfail(
strict=True,
reason="impl phase E.3.8: TTP router not yet mounted",
)
@pytest.mark.asyncio
async def test_delete_state_admin_is_204_or_200(
client: httpx.AsyncClient, auth_token: str,