mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 13:00:37 +00:00
Plan proposals live in the conversation (plan-approval flow); items are created open/unassigned and work starts at assignment — the granted, revocable authority. Also closes a verify gap: tail truncation is now caught against the stored head hash.
225 lines
8.5 KiB
Python
225 lines
8.5 KiB
Python
"""Board verbs: state-machine legality and role authority (the capability firebreak)."""
|
|
|
|
import pytest
|
|
|
|
from coworker.teams import Actor, AuthorityError, BoardError, Role, TeamStore
|
|
from coworker.teams.tools import board_tools
|
|
|
|
USER = Actor(id="user", role=Role.USER)
|
|
LEAD = Actor(id="lead-1", role=Role.LEAD)
|
|
WORKER = Actor(id="worker-1", role=Role.WORKER)
|
|
OTHER = Actor(id="worker-2", role=Role.WORKER)
|
|
SPACE = "proj"
|
|
|
|
|
|
@pytest.fixture
|
|
def store(tmp_path):
|
|
store = TeamStore(tmp_path / "teams.db")
|
|
yield store
|
|
store.close()
|
|
|
|
|
|
def assigned_item(store, assignee="worker-1"):
|
|
item = store.create_item(SPACE, LEAD, title="Task", criteria="tests pass")
|
|
store.assign(SPACE, LEAD, item["id"], assignee)
|
|
return item["id"]
|
|
|
|
|
|
# ------------------------------------------------------------------ create_item
|
|
|
|
def test_acceptance_criteria_are_required(store):
|
|
with pytest.raises(BoardError, match="criteria"):
|
|
store.create_item(SPACE, LEAD, title="Vague hope", criteria=" ")
|
|
|
|
|
|
def test_workers_file_items_open_and_unassigned(store):
|
|
mine = assigned_item(store)
|
|
filed = store.create_item(
|
|
SPACE, WORKER, title="Rounding bug in invoices", criteria="repro + fix",
|
|
parent=mine,
|
|
)
|
|
assert filed["state"] == "open"
|
|
assert filed["assignee"] == ""
|
|
assert filed["creator"] == "worker-1"
|
|
# the worker sees its own filing; nothing runs until the lead/user assigns it,
|
|
# and the filer can't assign it to itself
|
|
visible = {item["id"] for item in store.list_items(SPACE, WORKER)}
|
|
assert filed["id"] in visible
|
|
with pytest.raises(AuthorityError):
|
|
store.assign(SPACE, WORKER, filed["id"], "worker-1")
|
|
other_worker = {item["id"] for item in store.list_items(SPACE, OTHER)}
|
|
assert filed["id"] not in other_worker
|
|
|
|
|
|
def test_child_inherits_parent_case(store):
|
|
parent = store.create_item(
|
|
SPACE, LEAD, title="Root", criteria="c", case="findings"
|
|
)
|
|
child = store.create_item(
|
|
SPACE, LEAD, title="Child", criteria="c", parent=parent["id"]
|
|
)
|
|
assert child["case_id"] == "findings"
|
|
assert {"kind": "parent", "item": parent["id"]} in child["links"]
|
|
|
|
|
|
# ------------------------------------------------------------------- transitions
|
|
|
|
def test_full_happy_path(store):
|
|
item_id = assigned_item(store)
|
|
store.transition(SPACE, WORKER, item_id, "in_progress")
|
|
store.transition(SPACE, WORKER, item_id, "review", comment="branch green")
|
|
done = store.transition(SPACE, LEAD, item_id, "done")
|
|
assert done["state"] == "done"
|
|
|
|
|
|
def test_illegal_edges_rejected(store):
|
|
item = store.create_item(SPACE, LEAD, title="T", criteria="c")
|
|
with pytest.raises(BoardError, match="illegal transition"):
|
|
store.transition(SPACE, USER, item["id"], "done")
|
|
with pytest.raises(BoardError, match="illegal transition"):
|
|
store.transition(SPACE, USER, item["id"], "review")
|
|
|
|
|
|
def test_workers_never_mark_done(store):
|
|
item_id = assigned_item(store)
|
|
store.transition(SPACE, WORKER, item_id, "in_progress")
|
|
store.transition(SPACE, WORKER, item_id, "review")
|
|
with pytest.raises(AuthorityError, match="review"):
|
|
store.transition(SPACE, WORKER, item_id, "done")
|
|
|
|
|
|
def test_worker_cannot_touch_someone_elses_item(store):
|
|
item_id = assigned_item(store, assignee="worker-1")
|
|
with pytest.raises(AuthorityError, match="not assigned"):
|
|
store.transition(SPACE, OTHER, item_id, "in_progress")
|
|
|
|
|
|
def test_worker_cannot_cancel(store):
|
|
item_id = assigned_item(store)
|
|
with pytest.raises(AuthorityError):
|
|
store.transition(SPACE, WORKER, item_id, "canceled")
|
|
|
|
|
|
def test_cancel_is_a_board_verb_and_reopen_works(store):
|
|
item_id = assigned_item(store)
|
|
store.transition(SPACE, LEAD, item_id, "canceled")
|
|
reopened = store.transition(SPACE, LEAD, item_id, "open")
|
|
assert reopened["state"] == "open"
|
|
assert reopened["assignee"] == "worker-1" # reassignable — assignment survives
|
|
|
|
|
|
def test_done_is_terminal(store):
|
|
item_id = assigned_item(store)
|
|
store.transition(SPACE, WORKER, item_id, "in_progress")
|
|
store.transition(SPACE, WORKER, item_id, "review")
|
|
store.transition(SPACE, LEAD, item_id, "done")
|
|
with pytest.raises(BoardError, match="illegal transition"):
|
|
store.transition(SPACE, USER, item_id, "in_progress")
|
|
|
|
|
|
def test_rework_loop(store):
|
|
item_id = assigned_item(store)
|
|
store.transition(SPACE, WORKER, item_id, "in_progress")
|
|
store.transition(SPACE, WORKER, item_id, "review")
|
|
store.transition(SPACE, LEAD, item_id, "in_progress", comment="criteria 2 unmet")
|
|
item = store.get_item(SPACE, item_id)
|
|
assert item["state"] == "in_progress"
|
|
assert item["comments"][-1]["body"] == "criteria 2 unmet"
|
|
|
|
|
|
# ---------------------------------------------------------------- assign / link
|
|
|
|
def test_cannot_assign_closed_items(store):
|
|
item_id = assigned_item(store)
|
|
store.transition(SPACE, LEAD, item_id, "canceled")
|
|
with pytest.raises(BoardError, match="reopen"):
|
|
store.assign(SPACE, LEAD, item_id, "worker-2")
|
|
|
|
|
|
def test_workers_cannot_assign_or_link(store):
|
|
item_id = assigned_item(store)
|
|
with pytest.raises(AuthorityError):
|
|
store.assign(SPACE, WORKER, item_id, "worker-2")
|
|
other = store.create_item(SPACE, LEAD, title="B", criteria="c")
|
|
with pytest.raises(AuthorityError):
|
|
store.link(SPACE, WORKER, item_id, "blocks", other["id"])
|
|
|
|
|
|
def test_parent_cycles_rejected(store):
|
|
a = store.create_item(SPACE, LEAD, title="A", criteria="c")
|
|
b = store.create_item(SPACE, LEAD, title="B", criteria="c", parent=a["id"])
|
|
with pytest.raises(BoardError, match="cycle"):
|
|
store.link(SPACE, LEAD, a["id"], "parent", b["id"])
|
|
|
|
|
|
def test_blocked_by_shows_on_the_other_side(store):
|
|
a = store.create_item(SPACE, LEAD, title="A", criteria="c")
|
|
b = store.create_item(SPACE, LEAD, title="B", criteria="c")
|
|
store.link(SPACE, LEAD, a["id"], "blocks", b["id"])
|
|
assert {"kind": "blocked_by", "item": a["id"]} in store.get_item(SPACE, b["id"])[
|
|
"links"
|
|
]
|
|
|
|
|
|
def test_artifact_refs_accumulate_on_the_item(store):
|
|
item_id = assigned_item(store)
|
|
store.transition(SPACE, WORKER, item_id, "in_progress")
|
|
store.comment(SPACE, WORKER, item_id, "fix branch up", refs=["branch:fix/acl"])
|
|
store.transition(
|
|
SPACE, WORKER, item_id, "review",
|
|
comment="done", refs=["branch:fix/acl", "report:posture.html"],
|
|
)
|
|
item = store.get_item(SPACE, item_id)
|
|
assert item["refs"] == ["branch:fix/acl", "report:posture.html"] # deduped, ordered
|
|
store.rebuild(SPACE)
|
|
assert store.get_item(SPACE, item_id)["refs"] == item["refs"]
|
|
|
|
|
|
# ------------------------------------------------------------- worker visibility
|
|
|
|
def test_worker_sees_only_its_slice(store):
|
|
mine = assigned_item(store, assignee="worker-1")
|
|
theirs = assigned_item(store, assignee="worker-2")
|
|
linked = store.create_item(SPACE, LEAD, title="Dep", criteria="c")
|
|
store.link(SPACE, LEAD, linked["id"], "blocks", mine)
|
|
visible = {item["id"] for item in store.list_items(SPACE, WORKER)}
|
|
assert visible == {mine, linked["id"]}
|
|
assert theirs not in visible
|
|
# the user sees everything
|
|
assert len(store.list_items(SPACE, USER)) == 3
|
|
|
|
|
|
def test_worker_comments_only_on_its_slice(store):
|
|
theirs = assigned_item(store, assignee="worker-2")
|
|
with pytest.raises(AuthorityError, match="assigned"):
|
|
store.comment(SPACE, WORKER, theirs, "drive-by")
|
|
|
|
|
|
# ------------------------------------------------------------------- tool layer
|
|
|
|
def test_tool_sets_are_role_filtered(store):
|
|
lead_names = {tool.__name__ for tool in board_tools(store, space=SPACE, actor=LEAD)}
|
|
worker_names = {
|
|
tool.__name__ for tool in board_tools(store, space=SPACE, actor=WORKER)
|
|
}
|
|
assert lead_names == {"create_item", "list_items", "transition", "comment", "assign", "link"}
|
|
assert worker_names == {"create_item", "list_items", "transition", "comment"}
|
|
|
|
|
|
def test_tools_return_errors_instead_of_raising(store):
|
|
tools = {t.__name__: t for t in board_tools(store, space=SPACE, actor=WORKER)}
|
|
result = tools["transition"](item=99, to="in_progress")
|
|
assert "error" in result
|
|
|
|
|
|
def test_create_item_tool_schema_keeps_the_title_parameter(store):
|
|
from coworker.tools.registry import ToolRegistry
|
|
|
|
registry = ToolRegistry()
|
|
registry.register_all(board_tools(store, space=SPACE, actor=LEAD))
|
|
schema = registry.get("create_item").schema
|
|
properties = schema["function"]["parameters"]["properties"]
|
|
# The auto-generator strips dict keys named "title" (pydantic metadata cleanup),
|
|
# which would delete this parameter; the explicit schema must keep it.
|
|
assert "title" in properties
|