PMFlow/backend/tests/test_projects_delete_api.py

339 lines
11 KiB
Python

import os
import unittest
from tempfile import NamedTemporaryFile
from unittest.mock import AsyncMock, patch
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.api.projects import delete_project
from app.models import (
AIOrchestrationRun,
AIWorkerExecution,
Base,
Budget,
ConversationSession,
CostTracking,
Experiment,
IngestEvent,
OperationExecutionLedger,
OperationLog,
Project,
SignalSourceRegistry,
Task,
TruthNode,
User,
UserProject,
WorkItem,
)
class DeleteProjectApiTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
tmp = NamedTemporaryFile(prefix="pmflow-delete-project-test-", suffix=".db", delete=False)
tmp.close()
self.db_path = tmp.name
self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True)
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def asyncTearDown(self) -> None:
await self.engine.dispose()
if getattr(self, "db_path", None):
try:
os.remove(self.db_path)
except FileNotFoundError:
pass
async def test_delete_project_cleans_fk_dependencies(self) -> None:
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="boss-delete-project",
email="boss-delete-project@example.com",
password_hash="x",
name="Boss",
role="BOSS",
status="active",
is_active=True,
)
db.add(user)
await db.flush()
project = Project(
tenant_id=1,
name="P-Delete",
title="P-Delete",
description="for delete test",
status="active",
owner_id=int(user.id),
signal_count=0,
)
db.add(project)
await db.flush()
truth_node = TruthNode(
tenant_id=1,
node_type="task",
project_id=int(project.id),
created_by=int(user.id),
title="TN",
content={"k": "v"},
version=1,
status="active",
)
db.add(truth_node)
await db.flush()
task = Task(
tenant_id=1,
truth_node_id=int(truth_node.id),
project_id=int(project.id),
assignee_id=int(user.id),
title="task-a",
description="d",
required_skill_tag_ids=[1],
status="todo",
priority="p1",
)
db.add(task)
await db.flush()
cost_tracking = CostTracking(
tenant_id=1,
truth_node_id=int(truth_node.id),
task_id=int(task.id),
period="project",
estimated_tokens=1,
actual_tokens=1,
estimated_cost_usd=0.1,
actual_cost_usd=0.1,
roi_analysis={},
optimization_suggestions={},
)
db.add(cost_tracking)
registry = SignalSourceRegistry(
tenant_id=1,
project_id=int(project.id),
source_kind="github",
source_name="repo",
source_identifier="github:org/repo",
)
db.add(registry)
await db.flush()
worker_execution = AIWorkerExecution(
tenant_id=1,
project_id=int(project.id),
signal_source_registry_id=int(registry.id),
trace_id="trace-worker-delete",
worker_role_key="pm_dispatcher",
worker_role_name="PM Dispatcher",
)
db.add(worker_execution)
orchestration_run = AIOrchestrationRun(
tenant_id=1,
trace_id="trace-run-delete",
run_type="signal_source_onboarding",
project_id=int(project.id),
response_mode="fast",
status="queued",
worker_total=0,
worker_completed=0,
worker_failed=0,
)
db.add(orchestration_run)
ingest_event = IngestEvent(
tenant_id=1,
channel="feishu",
event_key="delete-project-event",
status="done",
)
db.add(ingest_event)
await db.flush()
work_item = WorkItem(
tenant_id=1,
ingest_event_id=int(ingest_event.id),
source_system="feishu",
source_message_id="msg-delete-project",
state="received",
project_id=int(project.id),
)
db.add(work_item)
operation_log = OperationLog(
tenant_id=1,
user_id=int(user.id),
project_id=int(project.id),
entity_type="project",
entity_id=str(project.id),
action="delete",
)
db.add(operation_log)
operation_ledger = OperationExecutionLedger(
tenant_id=1,
operation_code="project.delete",
project_id=int(project.id),
idempotency_key="project-delete-key",
status="processing",
)
db.add(operation_ledger)
experiment = Experiment(
tenant_id=1,
title="exp",
status="draft",
project_id=int(project.id),
)
db.add(experiment)
budget = Budget(
tenant_id=1,
budget_type="project",
project_id=int(project.id),
period="monthly",
budget_amount_usd=100.0,
alert_threshold_percent=80.0,
hard_limit_enabled=True,
current_usage_usd=10.0,
status="active",
override_history={},
)
db.add(budget)
conversation = ConversationSession(
tenant_id=1,
sender="ou_delete_project",
current_project_id=int(project.id),
last_intent="delete_or_archive",
)
db.add(conversation)
user_project = UserProject(
tenant_id=1,
user_id=int(user.id),
project_id=int(project.id),
)
db.add(user_project)
await db.commit()
with (
patch("app.api.projects.context_cache_service.invalidate_system_context", AsyncMock()),
patch("app.api.projects.AIPMOKernelService.invalidate_world_model_cache", AsyncMock()),
):
result = await delete_project(
project_id=int(project.id),
db=db,
current_user=user,
)
self.assertEqual(result, {"message": "Project deleted"})
project_row = (
await db.execute(
select(Project).where(
and_(Project.tenant_id == 1, Project.id == int(project.id))
)
)
).scalar_one_or_none()
self.assertIsNone(project_row)
worker_rows = (
await db.execute(
select(AIWorkerExecution).where(
and_(
AIWorkerExecution.tenant_id == 1,
AIWorkerExecution.project_id == int(project.id),
)
)
)
).scalars().all()
self.assertEqual(len(worker_rows), 0)
run_rows = (
await db.execute(
select(AIOrchestrationRun).where(
and_(
AIOrchestrationRun.tenant_id == 1,
AIOrchestrationRun.project_id == int(project.id),
)
)
)
).scalars().all()
self.assertEqual(len(run_rows), 0)
registry_rows = (
await db.execute(
select(SignalSourceRegistry).where(
and_(
SignalSourceRegistry.tenant_id == 1,
SignalSourceRegistry.project_id == int(project.id),
)
)
)
).scalars().all()
self.assertEqual(len(registry_rows), 0)
user_project_rows = (
await db.execute(
select(UserProject).where(
and_(
UserProject.tenant_id == 1,
UserProject.project_id == int(project.id),
)
)
)
).scalars().all()
self.assertEqual(len(user_project_rows), 0)
task_rows = (
await db.execute(
select(Task).where(and_(Task.tenant_id == 1, Task.project_id == int(project.id)))
)
).scalars().all()
self.assertEqual(len(task_rows), 0)
refreshed_cost_tracking = (
await db.execute(select(CostTracking).where(CostTracking.id == int(cost_tracking.id)))
).scalar_one()
self.assertIsNone(refreshed_cost_tracking.task_id)
refreshed_work_item = (
await db.execute(select(WorkItem).where(WorkItem.id == int(work_item.id)))
).scalar_one()
self.assertIsNone(refreshed_work_item.project_id)
refreshed_operation_log = (
await db.execute(select(OperationLog).where(OperationLog.id == int(operation_log.id)))
).scalar_one()
self.assertIsNone(refreshed_operation_log.project_id)
refreshed_ledger = (
await db.execute(
select(OperationExecutionLedger).where(OperationExecutionLedger.id == int(operation_ledger.id))
)
).scalar_one()
self.assertIsNone(refreshed_ledger.project_id)
refreshed_experiment = (
await db.execute(select(Experiment).where(Experiment.id == int(experiment.id)))
).scalar_one()
self.assertIsNone(refreshed_experiment.project_id)
refreshed_budget = (
await db.execute(select(Budget).where(Budget.id == int(budget.id)))
).scalar_one()
self.assertIsNone(refreshed_budget.project_id)
refreshed_conversation = (
await db.execute(select(ConversationSession).where(ConversationSession.id == int(conversation.id)))
).scalar_one()
self.assertIsNone(refreshed_conversation.current_project_id)