415 lines
15 KiB
Python
415 lines
15 KiB
Python
|
|
"""Unified Task System (F.14) tests.
|
||
|
|
|
||
|
|
Covers polymorphic assignment, entity links, subtasks, agent task creation,
|
||
|
|
goal decomposition, progress aggregation, success criteria evaluation and
|
||
|
|
migration of legacy fields.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import uuid
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from httpx import AsyncClient
|
||
|
|
|
||
|
|
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
class TestPolymorphicAssignment:
|
||
|
|
"""Polymorphic assignee (user/agent/group)."""
|
||
|
|
|
||
|
|
async def test_create_task_with_agent_assignee(self, tasks_client: AsyncClient, db_session):
|
||
|
|
"""POST /tasks with assignee_type=agent stores assignee_id."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_client(tasks_client, "admin@tenanta.com")
|
||
|
|
agent_id = str(uuid.uuid4())
|
||
|
|
resp = await tasks_client.post(
|
||
|
|
"/api/v1/tasks",
|
||
|
|
json={
|
||
|
|
"title": "Agent task",
|
||
|
|
"assignee_type": "agent",
|
||
|
|
"assignee_id": agent_id,
|
||
|
|
},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
assert resp.status_code == 201
|
||
|
|
data = resp.json()
|
||
|
|
assert data["assignee_type"] == "agent"
|
||
|
|
assert data["assignee_id"] == agent_id
|
||
|
|
|
||
|
|
async def test_create_task_with_group_assignee(self, tasks_client: AsyncClient, db_session):
|
||
|
|
"""POST /tasks with assignee_type=group stores assignee_id."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_client(tasks_client, "admin@tenanta.com")
|
||
|
|
group_id = str(uuid.uuid4())
|
||
|
|
resp = await tasks_client.post(
|
||
|
|
"/api/v1/tasks",
|
||
|
|
json={
|
||
|
|
"title": "Group task",
|
||
|
|
"assignee_type": "group",
|
||
|
|
"assignee_id": group_id,
|
||
|
|
},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
assert resp.status_code == 201
|
||
|
|
data = resp.json()
|
||
|
|
assert data["assignee_type"] == "group"
|
||
|
|
assert data["assignee_id"] == group_id
|
||
|
|
|
||
|
|
async def test_assign_task_polymorphic(self, tasks_client: AsyncClient, db_session):
|
||
|
|
"""POST /tasks/{id}/assign with assignee_type=agent."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_client(tasks_client, "admin@tenanta.com")
|
||
|
|
created = await tasks_client.post(
|
||
|
|
"/api/v1/tasks",
|
||
|
|
json={"title": "Assign me"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
task_id = created.json()["id"]
|
||
|
|
agent_id = str(uuid.uuid4())
|
||
|
|
resp = await tasks_client.post(
|
||
|
|
f"/api/v1/tasks/{task_id}/assign",
|
||
|
|
json={"assignee_type": "agent", "assignee_id": agent_id},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
data = resp.json()
|
||
|
|
assert data["assignee_type"] == "agent"
|
||
|
|
assert data["assignee_id"] == agent_id
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
class TestEntityLinks:
|
||
|
|
"""Polymorphic entity links (entity_type + entity_id)."""
|
||
|
|
|
||
|
|
async def test_create_task_with_entity_link(self, tasks_client: AsyncClient, db_session):
|
||
|
|
"""POST /tasks with entity_type=company stores entity_id."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_client(tasks_client, "admin@tenanta.com")
|
||
|
|
company_id = str(uuid.uuid4())
|
||
|
|
resp = await tasks_client.post(
|
||
|
|
"/api/v1/tasks",
|
||
|
|
json={
|
||
|
|
"title": "Company task",
|
||
|
|
"entity_type": "company",
|
||
|
|
"entity_id": company_id,
|
||
|
|
},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
assert resp.status_code == 201
|
||
|
|
data = resp.json()
|
||
|
|
assert data["entity_type"] == "company"
|
||
|
|
assert data["entity_id"] == company_id
|
||
|
|
|
||
|
|
async def test_filter_tasks_by_entity(self, tasks_client: AsyncClient, db_session):
|
||
|
|
"""GET /tasks?entity_type=&entity_id= filters by entity."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_client(tasks_client, "admin@tenanta.com")
|
||
|
|
company_id = str(uuid.uuid4())
|
||
|
|
await tasks_client.post(
|
||
|
|
"/api/v1/tasks",
|
||
|
|
json={"title": "Company task", "entity_type": "company", "entity_id": company_id},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
await tasks_client.post(
|
||
|
|
"/api/v1/tasks",
|
||
|
|
json={"title": "Other task"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
resp = await tasks_client.get(
|
||
|
|
f"/api/v1/tasks?entity_type=company&entity_id={company_id}",
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
items = resp.json()["items"]
|
||
|
|
assert len(items) == 1
|
||
|
|
assert items[0]["entity_type"] == "company"
|
||
|
|
assert items[0]["entity_id"] == company_id
|
||
|
|
|
||
|
|
async def test_legacy_contact_id_mirrors_entity(self, tasks_client: AsyncClient, db_session):
|
||
|
|
"""POST /tasks with contact_id sets entity_type='contact'."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_client(tasks_client, "admin@tenanta.com")
|
||
|
|
contact_id = str(uuid.uuid4())
|
||
|
|
resp = await tasks_client.post(
|
||
|
|
"/api/v1/tasks",
|
||
|
|
json={"title": "Contact task", "contact_id": contact_id},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
assert resp.status_code == 201
|
||
|
|
data = resp.json()
|
||
|
|
assert data["contact_id"] == contact_id
|
||
|
|
assert data["entity_type"] == "contact"
|
||
|
|
assert data["entity_id"] == contact_id
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
class TestSubtasks:
|
||
|
|
"""Subtasks (parent_task_id self-reference)."""
|
||
|
|
|
||
|
|
async def test_create_subtask(self, tasks_client: AsyncClient, db_session):
|
||
|
|
"""POST /tasks/{id}/subtasks creates a subtask."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_client(tasks_client, "admin@tenanta.com")
|
||
|
|
parent = await tasks_client.post(
|
||
|
|
"/api/v1/tasks",
|
||
|
|
json={"title": "Parent"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
parent_id = parent.json()["id"]
|
||
|
|
resp = await tasks_client.post(
|
||
|
|
f"/api/v1/tasks/{parent_id}/subtasks",
|
||
|
|
json={"title": "Child"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
assert resp.status_code == 201
|
||
|
|
data = resp.json()
|
||
|
|
assert data["parent_task_id"] == parent_id
|
||
|
|
|
||
|
|
async def test_list_subtasks(self, tasks_client: AsyncClient, db_session):
|
||
|
|
"""GET /tasks/{id}/subtasks lists children."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_client(tasks_client, "admin@tenanta.com")
|
||
|
|
parent = await tasks_client.post(
|
||
|
|
"/api/v1/tasks",
|
||
|
|
json={"title": "Parent"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
parent_id = parent.json()["id"]
|
||
|
|
await tasks_client.post(
|
||
|
|
f"/api/v1/tasks/{parent_id}/subtasks",
|
||
|
|
json={"title": "Child 1"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
await tasks_client.post(
|
||
|
|
f"/api/v1/tasks/{parent_id}/subtasks",
|
||
|
|
json={"title": "Child 2"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
resp = await tasks_client.get(
|
||
|
|
f"/api/v1/tasks/{parent_id}/subtasks",
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
assert len(resp.json()) == 2
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
class TestDependencies:
|
||
|
|
"""Task dependencies (depends_on)."""
|
||
|
|
|
||
|
|
async def test_add_and_remove_dependency(self, tasks_client: AsyncClient, db_session):
|
||
|
|
"""POST/DELETE /tasks/{id}/dependencies."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_client(tasks_client, "admin@tenanta.com")
|
||
|
|
t1 = await tasks_client.post("/api/v1/tasks", json={"title": "Task 1"}, headers=ORIGIN_HEADER)
|
||
|
|
t2 = await tasks_client.post("/api/v1/tasks", json={"title": "Task 2"}, headers=ORIGIN_HEADER)
|
||
|
|
t1_id, t2_id = t1.json()["id"], t2.json()["id"]
|
||
|
|
resp = await tasks_client.post(
|
||
|
|
f"/api/v1/tasks/{t1_id}/dependencies",
|
||
|
|
json={"depends_on": t2_id},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
assert t2_id in resp.json()["depends_on"]
|
||
|
|
resp = await tasks_client.delete(
|
||
|
|
f"/api/v1/tasks/{t1_id}/dependencies/{t2_id}",
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
assert t2_id not in resp.json()["depends_on"]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
class TestAgentTaskCreation:
|
||
|
|
"""Agent task creation (task_type='agent_subtask')."""
|
||
|
|
|
||
|
|
async def test_create_agent_subtask(self, tasks_client: AsyncClient, db_session):
|
||
|
|
"""POST /tasks with task_type=agent_subtask."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_client(tasks_client, "admin@tenanta.com")
|
||
|
|
resp = await tasks_client.post(
|
||
|
|
"/api/v1/tasks",
|
||
|
|
json={
|
||
|
|
"title": "Agent subtask",
|
||
|
|
"task_type": "agent_subtask",
|
||
|
|
"assignee_type": "agent",
|
||
|
|
"assignee_id": str(uuid.uuid4()),
|
||
|
|
},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
assert resp.status_code == 201
|
||
|
|
data = resp.json()
|
||
|
|
assert data["task_type"] == "agent_subtask"
|
||
|
|
assert data["assignee_type"] == "agent"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
class TestGoalDecomposition:
|
||
|
|
"""Goal decomposition into milestones/todos."""
|
||
|
|
|
||
|
|
async def test_decompose_goal(self, tasks_client: AsyncClient, db_session):
|
||
|
|
"""POST /tasks/{id}/decompose creates subtasks."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_client(tasks_client, "admin@tenanta.com")
|
||
|
|
goal = await tasks_client.post(
|
||
|
|
"/api/v1/tasks",
|
||
|
|
json={"title": "Big Goal", "task_type": "goal"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
goal_id = goal.json()["id"]
|
||
|
|
resp = await tasks_client.post(
|
||
|
|
f"/api/v1/tasks/{goal_id}/decompose",
|
||
|
|
json=[
|
||
|
|
{"title": "Milestone 1", "milestone": True},
|
||
|
|
{"title": "Todo 1"},
|
||
|
|
],
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
data = resp.json()
|
||
|
|
assert data["goal"]["task_type"] == "goal"
|
||
|
|
assert len(data["subtasks"]) == 2
|
||
|
|
types = {s["task_type"] for s in data["subtasks"]}
|
||
|
|
assert "milestone" in types
|
||
|
|
assert "todo" in types
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
class TestProgressAggregation:
|
||
|
|
"""Parent progress aggregated from child task status."""
|
||
|
|
|
||
|
|
async def test_progress_aggregates_from_children(self, tasks_client: AsyncClient, db_session):
|
||
|
|
"""Parent progress = % of done children."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_client(tasks_client, "admin@tenanta.com")
|
||
|
|
parent = await tasks_client.post(
|
||
|
|
"/api/v1/tasks",
|
||
|
|
json={"title": "Parent", "task_type": "goal"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
parent_id = parent.json()["id"]
|
||
|
|
c1 = await tasks_client.post(
|
||
|
|
f"/api/v1/tasks/{parent_id}/subtasks",
|
||
|
|
json={"title": "Child 1"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
c2 = await tasks_client.post(
|
||
|
|
f"/api/v1/tasks/{parent_id}/subtasks",
|
||
|
|
json={"title": "Child 2"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
# Mark one child done → parent progress 50%
|
||
|
|
await tasks_client.post(
|
||
|
|
f"/api/v1/tasks/{c1.json()['id']}/status",
|
||
|
|
json={"status": "done"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
parent_resp = await tasks_client.get(f"/api/v1/tasks/{parent_id}", headers=ORIGIN_HEADER)
|
||
|
|
assert parent_resp.json()["progress"] == 50
|
||
|
|
# Mark second child done → parent progress 100%
|
||
|
|
await tasks_client.post(
|
||
|
|
f"/api/v1/tasks/{c2.json()['id']}/status",
|
||
|
|
json={"status": "done"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
parent_resp = await tasks_client.get(f"/api/v1/tasks/{parent_id}", headers=ORIGIN_HEADER)
|
||
|
|
assert parent_resp.json()["progress"] == 100
|
||
|
|
|
||
|
|
async def test_parent_status_propagates_to_review(self, tasks_client: AsyncClient, db_session):
|
||
|
|
"""All children done → parent auto review."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_client(tasks_client, "admin@tenanta.com")
|
||
|
|
parent = await tasks_client.post(
|
||
|
|
"/api/v1/tasks",
|
||
|
|
json={"title": "Parent", "task_type": "goal"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
parent_id = parent.json()["id"]
|
||
|
|
c1 = await tasks_client.post(
|
||
|
|
f"/api/v1/tasks/{parent_id}/subtasks",
|
||
|
|
json={"title": "Child 1"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
await tasks_client.post(
|
||
|
|
f"/api/v1/tasks/{c1.json()['id']}/status",
|
||
|
|
json={"status": "done"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
parent_resp = await tasks_client.get(f"/api/v1/tasks/{parent_id}", headers=ORIGIN_HEADER)
|
||
|
|
assert parent_resp.json()["status"] == "review"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
class TestSuccessCriteria:
|
||
|
|
"""Success criteria evaluation for goals."""
|
||
|
|
|
||
|
|
async def test_goal_done_when_criteria_met(self, tasks_client: AsyncClient, db_session):
|
||
|
|
"""Goal with all_done criteria becomes done at 100% progress."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_client(tasks_client, "admin@tenanta.com")
|
||
|
|
goal = await tasks_client.post(
|
||
|
|
"/api/v1/tasks",
|
||
|
|
json={
|
||
|
|
"title": "Goal",
|
||
|
|
"task_type": "goal",
|
||
|
|
"success_criteria": {"all_done": True},
|
||
|
|
},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
goal_id = goal.json()["id"]
|
||
|
|
c1 = await tasks_client.post(
|
||
|
|
f"/api/v1/tasks/{goal_id}/subtasks",
|
||
|
|
json={"title": "Child 1"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
await tasks_client.post(
|
||
|
|
f"/api/v1/tasks/{c1.json()['id']}/status",
|
||
|
|
json={"status": "done"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
goal_resp = await tasks_client.get(f"/api/v1/tasks/{goal_id}", headers=ORIGIN_HEADER)
|
||
|
|
assert goal_resp.json()["status"] == "done"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
class TestLifecycleStatuses:
|
||
|
|
"""New lifecycle statuses."""
|
||
|
|
|
||
|
|
async def test_all_statuses_accepted(self, tasks_client: AsyncClient, db_session):
|
||
|
|
"""All lifecycle statuses are accepted by the API."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_client(tasks_client, "admin@tenanta.com")
|
||
|
|
task = await tasks_client.post(
|
||
|
|
"/api/v1/tasks",
|
||
|
|
json={"title": "Status task"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
task_id = task.json()["id"]
|
||
|
|
for status in ["open", "in_progress", "review", "blocked", "done", "cancelled"]:
|
||
|
|
resp = await tasks_client.post(
|
||
|
|
f"/api/v1/tasks/{task_id}/status",
|
||
|
|
json={"status": status},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
assert resp.json()["status"] == status
|
||
|
|
|
||
|
|
async def test_invalid_status_rejected(self, tasks_client: AsyncClient, db_session):
|
||
|
|
"""Invalid status returns 422."""
|
||
|
|
await seed_tenant_and_users(db_session)
|
||
|
|
await login_client(tasks_client, "admin@tenanta.com")
|
||
|
|
task = await tasks_client.post(
|
||
|
|
"/api/v1/tasks",
|
||
|
|
json={"title": "Status task"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
task_id = task.json()["id"]
|
||
|
|
resp = await tasks_client.post(
|
||
|
|
f"/api/v1/tasks/{task_id}/status",
|
||
|
|
json={"status": "invalid"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
assert resp.status_code == 422
|