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)