state-hub/tests/test_wait_kind.py
tegwick a495a27e0e
Some checks are pending
CI Smoke / host-smoke (push) Waiting to run
CI Smoke / container-smoke (push) Waiting to run
Build and Publish Multi-Context Image / build-and-push (push) Waiting to run
CUST-WP-0074-T02 follow-up: blocked_on / wait_until reach the hub read model
Migration f9a0b1c2d3e4 (revises e8f9a0b1c2d3) adds tasks.blocked_on
(String 200) and tasks.wait_until (Date); TaskCreate/Update/Read carry both.

Read model (api/services/wait_kind.py): a wait task is external when it has
a dependency edge, a blocked_on matching ^message-from: (rule 6), or a
wait_until date (a window clears by itself). human/both/unqualified and
blocked_kind are unchanged in shape.

fix-consistency: _task_wait_fields carries blocked_on and wait_until (ISO
date, PyYAML date objects normalised), so the C-06/C-11 creates and the
C-10 status PATCH send them, and C-41 compares and syncs them like the
other qualifiers. C-39 accepts wait_until: YYYY-MM-DD as a qualifier; C-40
also fires when wait_until is before UTC today (report only).

/state/deps: depends_on stubs gain from_task_id / from_task_title so
consumers see task-scoped edges; both optional, response otherwise as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 237582@bnt-lap001
Assistant-Session: f2b3d9f1-8fb9-4b9c-bc2b-837ec5dfc826
2026-09-29 21:38:27 +02:00

216 lines
9.9 KiB
Python

"""CUST-WP-0074-T02: derived wait_kind / blocked_kind read-model fields and the
from_task_id dependency edge. Real PostgreSQL via the shared `client` fixture."""
from __future__ import annotations
from datetime import date
import pytest
from api.services.wait_kind import derive_blocked_kind, derive_wait_kind
from tests.conftest import create_test_domain, create_test_repo, create_test_workplan
class TestDeriveFunctions:
def test_wait_kind_by_qualifiers(self):
assert derive_wait_kind("todo", needs_human=True, has_dependency=True) is None
assert derive_wait_kind("wait", needs_human=False, has_dependency=False) == "unqualified"
assert derive_wait_kind("wait", needs_human=False, has_dependency=True) == "external"
assert derive_wait_kind("wait", needs_human=True, has_dependency=False) == "human"
assert derive_wait_kind("wait", needs_human=True, has_dependency=True) == "both"
def test_owner_return_marker_and_wait_window_are_external(self):
assert derive_wait_kind(
"wait", needs_human=False, has_dependency=False, blocked_on="message-from:fin-hub"
) == "external"
assert derive_wait_kind(
"wait", needs_human=False, has_dependency=False, blocked_on="fin-hub"
) == "unqualified"
assert derive_wait_kind(
"wait", needs_human=False, has_dependency=False, wait_until=date(2099, 1, 31)
) == "external"
assert derive_wait_kind(
"wait", needs_human=True, has_dependency=False, wait_until=date(2020, 1, 31)
) == "both"
assert derive_wait_kind(
"todo", needs_human=False, has_dependency=False, blocked_on="message-from:x"
) is None
def test_blocked_kind_prefers_human(self):
assert derive_blocked_kind("active", ["human"]) is None
assert derive_blocked_kind("blocked", []) == "none"
assert derive_blocked_kind("blocked", ["unqualified", None]) == "none"
assert derive_blocked_kind("blocked", ["external", "unqualified"]) == "external"
assert derive_blocked_kind("blocked", ["external", "human"]) == "human"
assert derive_blocked_kind("blocked", ["both"]) == "human"
async def _task(client, workplan_id, title, **extra):
r = await client.post("/tasks/", json={"workplan_id": workplan_id, "title": title, **extra})
assert r.status_code == 201, r.text
return r.json()
@pytest.mark.asyncio
async def test_wait_kind_and_blocked_kind_on_read_routes(client):
domain = await create_test_domain(client)
repo = await create_test_repo(client, domain_slug=domain["slug"])
upstream = await create_test_workplan(client, repo["id"], slug="upstream", status="active")
blocked = await create_test_workplan(client, repo["id"], slug="blocked", status="blocked")
human = await _task(
client, blocked["id"], "human gate", status="wait",
needs_human=True, blocking_reason="operator must provision creds",
intervention_note="operator must provision creds", decision_id="CCR-2026-0004",
)
external = await _task(
client, blocked["id"], "external", status="wait", blocking_reason="upstream first",
)
unqualified = await _task(client, blocked["id"], "unqualified", status="wait", blocking_reason="?")
todo = await _task(client, blocked["id"], "plain todo")
r = await client.post(
f"/workplans/{blocked['id']}/dependencies/",
json={"from_task_id": external["id"], "to_workplan_id": upstream["id"]},
)
assert r.status_code == 201, r.text
assert r.json()["from_task_id"] == external["id"]
r = await client.get("/tasks/", params={"workplan_id": blocked["id"]})
assert r.status_code == 200
kinds = {row["title"]: row["wait_kind"] for row in r.json()}
assert kinds == {
"human gate": "human", "external": "external", "unqualified": "unqualified", "plain todo": None,
}
r = await client.get(f"/tasks/{human['id']}")
assert r.json()["wait_kind"] == "human"
assert r.json()["decision_id"] == "CCR-2026-0004"
r = await client.get(f"/workplans/{blocked['id']}")
assert r.json()["blocked_kind"] == "human"
r = await client.get(f"/workplans/{upstream['id']}")
assert r.json()["blocked_kind"] is None
# Human gate lifted → the workplan is blocked on the external wait only.
r = await client.patch(f"/tasks/{human['id']}", json={"status": "done"})
assert r.status_code == 200, r.text
r = await client.get("/workplans/", params={"repo_id": repo["id"]})
kinds = {row["slug"]: row["blocked_kind"] for row in r.json()}
assert kinds == {"upstream": None, "blocked": "external"}
# Dependency and human flag together → both.
r = await client.patch(
f"/tasks/{external['id']}",
json={"needs_human": True, "intervention_note": "confirm with owner"},
)
assert r.status_code == 200, r.text
r = await client.get(f"/tasks/{external['id']}")
assert r.json()["wait_kind"] == "both"
del unqualified, todo
@pytest.mark.asyncio
async def test_from_task_must_belong_to_the_from_workplan(client):
domain = await create_test_domain(client)
repo = await create_test_repo(client, domain_slug=domain["slug"])
a = await create_test_workplan(client, repo["id"], slug="a")
b = await create_test_workplan(client, repo["id"], slug="b")
task_in_b = await _task(client, b["id"], "in b")
r = await client.post(
f"/workplans/{a['id']}/dependencies/",
json={"from_task_id": task_in_b["id"], "to_workplan_id": b["id"]},
)
assert r.status_code == 404
r = await client.post(
f"/workplans/{b['id']}/dependencies/",
json={"from_task_id": task_in_b["id"], "to_task_id": task_in_b["id"]},
)
assert r.status_code == 422
@pytest.mark.asyncio
async def test_two_tasks_may_wait_on_the_same_target(client):
domain = await create_test_domain(client)
repo = await create_test_repo(client, domain_slug=domain["slug"])
upstream = await create_test_workplan(client, repo["id"], slug="upstream")
wp = await create_test_workplan(client, repo["id"], slug="waiting")
t1 = await _task(client, wp["id"], "t1")
t2 = await _task(client, wp["id"], "t2")
for task in (t1, t2):
r = await client.post(
f"/workplans/{wp['id']}/dependencies/",
json={"from_task_id": task["id"], "to_workplan_id": upstream["id"]},
)
assert r.status_code == 201, r.text
r = await client.get(f"/workplans/{wp['id']}/dependencies/")
assert sorted(row["from_task_id"] for row in r.json()) == sorted([t1["id"], t2["id"]])
@pytest.mark.asyncio
async def test_blocked_on_and_wait_until_reach_the_read_model(client):
domain = await create_test_domain(client)
repo = await create_test_repo(client, domain_slug=domain["slug"])
blocked = await create_test_workplan(client, repo["id"], slug="blocked", status="blocked")
owner_return = await _task(
client, blocked["id"], "owner return", status="wait",
blocking_reason="fin-hub owner returns an id", blocked_on="message-from:fin-hub",
)
window = await _task(
client, blocked["id"], "window", status="wait",
blocking_reason="embargo", wait_until="2099-01-31",
)
plain = await _task(
client, blocked["id"], "plain", status="wait", blocking_reason="?", blocked_on="fin-hub",
)
r = await client.get("/tasks/", params={"workplan_id": blocked["id"]})
rows = {row["title"]: row for row in r.json()}
assert rows["owner return"]["wait_kind"] == "external"
assert rows["owner return"]["blocked_on"] == "message-from:fin-hub"
assert rows["window"]["wait_kind"] == "external"
assert rows["window"]["wait_until"] == "2099-01-31"
assert rows["plain"]["wait_kind"] == "unqualified"
r = await client.get(f"/workplans/{blocked['id']}")
assert r.json()["blocked_kind"] == "external"
# Clearing the window on PATCH drops the qualifier again.
r = await client.patch(f"/tasks/{window['id']}", json={"wait_until": None})
assert r.status_code == 200, r.text
assert r.json()["wait_until"] is None
r = await client.get(f"/tasks/{window['id']}")
assert r.json()["wait_kind"] == "unqualified"
del owner_return, plain
@pytest.mark.asyncio
async def test_state_deps_stubs_carry_the_waiting_task(client):
domain = await create_test_domain(client)
repo = await create_test_repo(client, domain_slug=domain["slug"])
upstream = await create_test_workplan(client, repo["id"], slug="upstream", status="active")
waiting = await create_test_workplan(client, repo["id"], slug="waiting", status="blocked")
upstream_task = await _task(client, upstream["id"], "upstream task")
waiter = await _task(client, waiting["id"], "waiter", status="wait", blocking_reason="upstream")
for body in (
{"from_task_id": waiter["id"], "to_workplan_id": upstream["id"]},
{"from_task_id": waiter["id"], "to_task_id": upstream_task["id"]},
{"to_workplan_id": upstream["id"], "relationship_type": "related"},
):
r = await client.post(f"/workplans/{waiting['id']}/dependencies/", json=body)
assert r.status_code == 201, r.text
deps = {row["id"]: row for row in (await client.get("/state/deps")).json()}
stubs = {
(stub["target_type"], stub["relationship_type"]): stub
for stub in deps[waiting["id"]]["depends_on"]
}
assert stubs[("workstream", "blocks")]["from_task_id"] == waiter["id"]
assert stubs[("workstream", "blocks")]["from_task_title"] == "waiter"
assert stubs[("task", "blocks")]["from_task_id"] == waiter["id"]
assert stubs[("task", "blocks")]["task_id"] == upstream_task["id"]
assert stubs[("workstream", "related")]["from_task_id"] is None
assert stubs[("workstream", "related")]["from_task_title"] is None
# The reverse side is unchanged: blocks stubs name the workplan only.
for stub in deps[upstream["id"]]["blocks"]:
assert stub["workstream_id"] == waiting["id"]
assert stub["from_task_id"] is None