339 lines
11 KiB
Python
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)
|