Files
leocrm/tests/test_unified_tasks.py
T

415 lines
15 KiB
Python
Raw Normal View History

"""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