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