test(backend): add unit tests for task dependencies + provisioning helpers
This commit is contained in:
64
backend/tests/test_agent_provisioning_utils.py
Normal file
64
backend/tests/test_agent_provisioning_utils.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.services import agent_provisioning
|
||||
|
||||
|
||||
def test_slugify_normalizes_and_trims():
|
||||
assert agent_provisioning._slugify("Hello, World") == "hello-world"
|
||||
assert agent_provisioning._slugify(" A B ") == "a-b"
|
||||
|
||||
|
||||
def test_slugify_falls_back_to_uuid_hex(monkeypatch):
|
||||
class _FakeUuid:
|
||||
hex = "deadbeef"
|
||||
|
||||
monkeypatch.setattr(agent_provisioning, "uuid4", lambda: _FakeUuid())
|
||||
assert agent_provisioning._slugify("!!!") == "deadbeef"
|
||||
|
||||
|
||||
def test_agent_id_from_session_key_parses_agent_prefix():
|
||||
assert agent_provisioning._agent_id_from_session_key(None) is None
|
||||
assert agent_provisioning._agent_id_from_session_key("") is None
|
||||
assert agent_provisioning._agent_id_from_session_key("not-agent") is None
|
||||
assert agent_provisioning._agent_id_from_session_key("agent:") is None
|
||||
assert agent_provisioning._agent_id_from_session_key("agent:riya:main") == "riya"
|
||||
|
||||
|
||||
def test_extract_agent_id_supports_lists_and_dicts():
|
||||
assert agent_provisioning._extract_agent_id(["", " ", "abc"]) == "abc"
|
||||
assert agent_provisioning._extract_agent_id([{"agent_id": "xyz"}]) == "xyz"
|
||||
|
||||
payload = {
|
||||
"defaultAgentId": "dflt",
|
||||
"agents": [{"id": "ignored"}],
|
||||
}
|
||||
assert agent_provisioning._extract_agent_id(payload) == "dflt"
|
||||
|
||||
payload2 = {
|
||||
"agents": [{"id": ""}, {"agentId": "foo"}],
|
||||
}
|
||||
assert agent_provisioning._extract_agent_id(payload2) == "foo"
|
||||
|
||||
|
||||
def test_extract_agent_id_returns_none_for_unknown_shapes():
|
||||
assert agent_provisioning._extract_agent_id("nope") is None
|
||||
assert agent_provisioning._extract_agent_id({"agents": "not-a-list"}) is None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AgentStub:
|
||||
name: str
|
||||
openclaw_session_id: str | None = None
|
||||
heartbeat_config: dict | None = None
|
||||
is_board_lead: bool = False
|
||||
|
||||
|
||||
def test_agent_key_uses_session_key_when_present(monkeypatch):
|
||||
agent = _AgentStub(name="Alice", openclaw_session_id="agent:alice:main")
|
||||
assert agent_provisioning._agent_key(agent) == "alice"
|
||||
|
||||
monkeypatch.setattr(agent_provisioning, "_slugify", lambda value: "slugged")
|
||||
agent2 = _AgentStub(name="Alice", openclaw_session_id=None)
|
||||
assert agent_provisioning._agent_key(agent2) == "slugged"
|
||||
321
backend/tests/test_task_dependencies.py
Normal file
321
backend/tests/test_task_dependencies.py
Normal file
@@ -0,0 +1,321 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import task_dependencies
|
||||
|
||||
|
||||
def test_dedupe_uuid_list_preserves_order_and_removes_duplicates():
|
||||
a = uuid4()
|
||||
b = uuid4()
|
||||
c = uuid4()
|
||||
|
||||
values = [a, b, a, c, b]
|
||||
assert task_dependencies._dedupe_uuid_list(values) == [a, b, c]
|
||||
|
||||
|
||||
def test_blocked_by_dependency_ids_flags_not_done_and_missing_status():
|
||||
a = uuid4()
|
||||
b = uuid4()
|
||||
c = uuid4()
|
||||
|
||||
status_by_id = {
|
||||
a: task_dependencies.DONE_STATUS,
|
||||
b: "in_progress",
|
||||
# c intentionally missing
|
||||
}
|
||||
|
||||
assert task_dependencies.blocked_by_dependency_ids(
|
||||
dependency_ids=[a, b, c],
|
||||
status_by_id=status_by_id,
|
||||
) == [b, c]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("nodes", "edges", "expected"),
|
||||
[
|
||||
# A -> B -> C (acyclic)
|
||||
(
|
||||
[UUID(int=1), UUID(int=2), UUID(int=3)],
|
||||
{UUID(int=1): {UUID(int=2)}, UUID(int=2): {UUID(int=3)}},
|
||||
False,
|
||||
),
|
||||
# A -> B -> C -> A (cycle)
|
||||
(
|
||||
[UUID(int=1), UUID(int=2), UUID(int=3)],
|
||||
{UUID(int=1): {UUID(int=2)}, UUID(int=2): {UUID(int=3)}, UUID(int=3): {UUID(int=1)}},
|
||||
True,
|
||||
),
|
||||
# Self-loop (cycle)
|
||||
(
|
||||
[UUID(int=1)],
|
||||
{UUID(int=1): {UUID(int=1)}},
|
||||
True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_has_cycle(nodes, edges, expected):
|
||||
assert task_dependencies._has_cycle(nodes, edges) is expected
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeSession:
|
||||
exec_results: list[object]
|
||||
executed: list[object] = field(default_factory=list)
|
||||
added: list[object] = field(default_factory=list)
|
||||
|
||||
async def exec(self, _query):
|
||||
if not self.exec_results:
|
||||
raise AssertionError("No more exec_results left for session.exec")
|
||||
return self.exec_results.pop(0)
|
||||
|
||||
async def execute(self, statement):
|
||||
self.executed.append(statement)
|
||||
|
||||
def add(self, value):
|
||||
self.added.append(value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependency_ids_by_task_id_empty_short_circuit():
|
||||
session = _FakeSession(exec_results=[])
|
||||
result = await task_dependencies.dependency_ids_by_task_id(
|
||||
session,
|
||||
board_id=uuid4(),
|
||||
task_ids=[],
|
||||
)
|
||||
assert result == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependency_ids_by_task_id_groups_rows_by_task_id():
|
||||
task_id = uuid4()
|
||||
dep1 = uuid4()
|
||||
dep2 = uuid4()
|
||||
rows = [(task_id, dep1), (task_id, dep2)]
|
||||
|
||||
session = _FakeSession(exec_results=[rows])
|
||||
result = await task_dependencies.dependency_ids_by_task_id(
|
||||
session,
|
||||
board_id=uuid4(),
|
||||
task_ids=[task_id],
|
||||
)
|
||||
assert result == {task_id: [dep1, dep2]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependency_status_by_id_empty_short_circuit():
|
||||
session = _FakeSession(exec_results=[])
|
||||
result = await task_dependencies.dependency_status_by_id(
|
||||
session,
|
||||
board_id=uuid4(),
|
||||
dependency_ids=[],
|
||||
)
|
||||
assert result == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependency_status_by_id_maps_rows():
|
||||
dep = uuid4()
|
||||
session = _FakeSession(exec_results=[[(dep, "done")]])
|
||||
result = await task_dependencies.dependency_status_by_id(
|
||||
session,
|
||||
board_id=uuid4(),
|
||||
dependency_ids=[dep],
|
||||
)
|
||||
assert result == {dep: "done"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocked_by_for_task_uses_passed_dependency_ids():
|
||||
board_id = uuid4()
|
||||
dep1 = uuid4()
|
||||
dep2 = uuid4()
|
||||
|
||||
session = _FakeSession(exec_results=[[(dep1, "done"), (dep2, "inbox")]])
|
||||
blocked = await task_dependencies.blocked_by_for_task(
|
||||
session,
|
||||
board_id=board_id,
|
||||
task_id=uuid4(),
|
||||
dependency_ids=[dep1, dep2],
|
||||
)
|
||||
assert blocked == [dep2]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocked_by_for_task_fetches_dependency_ids_when_not_provided():
|
||||
board_id = uuid4()
|
||||
task_id = uuid4()
|
||||
dep = uuid4()
|
||||
|
||||
# 1) dependency_ids_by_task_id -> {task_id: [dep]}
|
||||
# 2) dependency_status_by_id -> [(dep, "inbox")]
|
||||
session = _FakeSession(exec_results=[[(task_id, dep)], [(dep, "inbox")]])
|
||||
|
||||
blocked = await task_dependencies.blocked_by_for_task(
|
||||
session,
|
||||
board_id=board_id,
|
||||
task_id=task_id,
|
||||
dependency_ids=None,
|
||||
)
|
||||
assert blocked == [dep]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocked_by_for_task_returns_empty_when_no_deps():
|
||||
board_id = uuid4()
|
||||
task_id = uuid4()
|
||||
|
||||
# dependency_ids_by_task_id -> empty rows => no deps
|
||||
session = _FakeSession(exec_results=[[]])
|
||||
blocked = await task_dependencies.blocked_by_for_task(
|
||||
session,
|
||||
board_id=board_id,
|
||||
task_id=task_id,
|
||||
dependency_ids=None,
|
||||
)
|
||||
assert blocked == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_dependency_update_returns_empty_when_no_dependencies():
|
||||
session = _FakeSession(exec_results=[])
|
||||
result = await task_dependencies.validate_dependency_update(
|
||||
session,
|
||||
board_id=uuid4(),
|
||||
task_id=uuid4(),
|
||||
depends_on_task_ids=[],
|
||||
)
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_dependency_update_rejects_self_dependency():
|
||||
task_id = uuid4()
|
||||
session = _FakeSession(exec_results=[])
|
||||
|
||||
with pytest.raises(task_dependencies.HTTPException) as exc:
|
||||
await task_dependencies.validate_dependency_update(
|
||||
session,
|
||||
board_id=uuid4(),
|
||||
task_id=task_id,
|
||||
depends_on_task_ids=[task_id],
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_dependency_update_rejects_missing_dependency_tasks():
|
||||
board_id = uuid4()
|
||||
task_id = uuid4()
|
||||
dep_id = uuid4()
|
||||
|
||||
# existing_ids should not include dep_id
|
||||
session = _FakeSession(exec_results=[set()])
|
||||
|
||||
with pytest.raises(task_dependencies.HTTPException) as exc:
|
||||
await task_dependencies.validate_dependency_update(
|
||||
session,
|
||||
board_id=board_id,
|
||||
task_id=task_id,
|
||||
depends_on_task_ids=[dep_id],
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 404
|
||||
assert exc.value.detail["missing_task_ids"] == [str(dep_id)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_dependency_update_rejects_cycles(monkeypatch):
|
||||
board_id = uuid4()
|
||||
task_a = uuid4()
|
||||
task_b = uuid4()
|
||||
|
||||
# existing_ids contains dependency
|
||||
existing_ids = {task_b}
|
||||
|
||||
# task_ids list on board
|
||||
all_task_ids = [task_a, task_b]
|
||||
|
||||
# existing edges: B depends on A, then set A depends on B => cycle
|
||||
existing_edges = [(task_b, task_a)]
|
||||
|
||||
session = _FakeSession(exec_results=[existing_ids, all_task_ids, existing_edges])
|
||||
|
||||
with pytest.raises(task_dependencies.HTTPException) as exc:
|
||||
await task_dependencies.validate_dependency_update(
|
||||
session,
|
||||
board_id=board_id,
|
||||
task_id=task_a,
|
||||
depends_on_task_ids=[task_b],
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_dependency_update_returns_deduped_ids_when_ok():
|
||||
board_id = uuid4()
|
||||
task_id = uuid4()
|
||||
dep1 = uuid4()
|
||||
dep2 = uuid4()
|
||||
|
||||
existing_ids = {dep1, dep2}
|
||||
all_task_ids = [task_id, dep1, dep2]
|
||||
existing_edges: list[tuple[UUID, UUID]] = []
|
||||
|
||||
session = _FakeSession(exec_results=[existing_ids, all_task_ids, existing_edges])
|
||||
|
||||
normalized = await task_dependencies.validate_dependency_update(
|
||||
session,
|
||||
board_id=board_id,
|
||||
task_id=task_id,
|
||||
depends_on_task_ids=[dep1, dep2, dep1],
|
||||
)
|
||||
|
||||
assert normalized == [dep1, dep2]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replace_task_dependencies_deletes_then_adds(monkeypatch):
|
||||
board_id = uuid4()
|
||||
task_id = uuid4()
|
||||
dep1 = uuid4()
|
||||
dep2 = uuid4()
|
||||
|
||||
async def _fake_validate(*_args, **_kwargs):
|
||||
return [dep1, dep2]
|
||||
|
||||
monkeypatch.setattr(task_dependencies, "validate_dependency_update", _fake_validate)
|
||||
|
||||
session = _FakeSession(exec_results=[])
|
||||
normalized = await task_dependencies.replace_task_dependencies(
|
||||
session,
|
||||
board_id=board_id,
|
||||
task_id=task_id,
|
||||
depends_on_task_ids=[dep1, dep2],
|
||||
)
|
||||
|
||||
assert normalized == [dep1, dep2]
|
||||
assert len(session.executed) == 1
|
||||
assert len(session.added) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependent_task_ids_returns_rows_as_list():
|
||||
board_id = uuid4()
|
||||
dep_task_id = uuid4()
|
||||
dependent_id = uuid4()
|
||||
|
||||
session = _FakeSession(exec_results=[[dependent_id]])
|
||||
result = await task_dependencies.dependent_task_ids(
|
||||
session,
|
||||
board_id=board_id,
|
||||
dependency_task_id=dep_task_id,
|
||||
)
|
||||
assert result == [dependent_id]
|
||||
Reference in New Issue
Block a user