204 lines
7.3 KiB
Python
204 lines
7.3 KiB
Python
import os
|
|
import unittest
|
|
from tempfile import NamedTemporaryFile
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import and_, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
|
|
from app.api.projects import (
|
|
ProjectCreateRequest,
|
|
ProjectMembersAssignRequest,
|
|
assign_project_members,
|
|
create_project,
|
|
)
|
|
from app.models import Base, Notification, Project, User, UserProject
|
|
|
|
|
|
class ProjectsMemberAssignmentApiTest(unittest.IsolatedAsyncioTestCase):
|
|
async def asyncSetUp(self) -> None:
|
|
tmp = NamedTemporaryFile(prefix="pmflow-project-members-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 _seed_users(self, db: AsyncSession) -> tuple[User, User, User]:
|
|
boss = User(
|
|
tenant_id=1,
|
|
username="boss-member-test",
|
|
email="boss-member-test@example.com",
|
|
password_hash="x",
|
|
name="Boss",
|
|
role="BOSS",
|
|
status="active",
|
|
is_active=True,
|
|
)
|
|
member_a = User(
|
|
tenant_id=1,
|
|
username="member-a-test",
|
|
email="member-a-test@example.com",
|
|
password_hash="x",
|
|
name="Member A",
|
|
role="BE",
|
|
status="active",
|
|
is_active=True,
|
|
)
|
|
member_b = User(
|
|
tenant_id=1,
|
|
username="member-b-test",
|
|
email="member-b-test@example.com",
|
|
password_hash="x",
|
|
name="Member B",
|
|
role="FE",
|
|
status="active",
|
|
is_active=True,
|
|
)
|
|
db.add_all([boss, member_a, member_b])
|
|
await db.flush()
|
|
return boss, member_a, member_b
|
|
|
|
async def test_create_project_assigns_requested_members_and_owner(self) -> None:
|
|
async with self.session_factory() as db:
|
|
boss, member_a, member_b = await self._seed_users(db)
|
|
|
|
with (
|
|
patch("app.api.projects.context_cache_service.invalidate_system_context", AsyncMock()),
|
|
patch("app.api.projects.AIPMOKernelService.invalidate_world_model_cache", AsyncMock()),
|
|
):
|
|
payload = await create_project(
|
|
body=ProjectCreateRequest(
|
|
title="成员分配测试项目",
|
|
member_user_ids=[int(member_a.id), int(member_b.id)],
|
|
),
|
|
db=db,
|
|
current_user=boss,
|
|
)
|
|
|
|
project_id = int(payload["id"])
|
|
assigned_rows = (
|
|
await db.execute(
|
|
select(UserProject.user_id).where(
|
|
and_(
|
|
UserProject.tenant_id == 1,
|
|
UserProject.project_id == project_id,
|
|
)
|
|
)
|
|
)
|
|
).scalars().all()
|
|
self.assertEqual(
|
|
sorted(int(uid) for uid in assigned_rows),
|
|
sorted([int(boss.id), int(member_a.id), int(member_b.id)]),
|
|
)
|
|
|
|
async def test_assign_project_members_empty_should_notify_and_raise(self) -> None:
|
|
async with self.session_factory() as db:
|
|
boss, member_a, _ = await self._seed_users(db)
|
|
project = Project(
|
|
tenant_id=1,
|
|
name="P-members-empty",
|
|
title="P-members-empty",
|
|
description="members test",
|
|
owner_id=int(boss.id),
|
|
status="active",
|
|
assigned_role="backend",
|
|
signal_count=0,
|
|
)
|
|
db.add(project)
|
|
await db.flush()
|
|
db.add(
|
|
UserProject(
|
|
tenant_id=1,
|
|
user_id=int(member_a.id),
|
|
project_id=int(project.id),
|
|
)
|
|
)
|
|
await db.commit()
|
|
|
|
with self.assertRaises(HTTPException) as exc_ctx:
|
|
await assign_project_members(
|
|
project_id=int(project.id),
|
|
body=ProjectMembersAssignRequest(member_user_ids=[]),
|
|
db=db,
|
|
current_user=boss,
|
|
)
|
|
self.assertEqual(exc_ctx.exception.status_code, 400)
|
|
|
|
notifications = (
|
|
await db.execute(
|
|
select(Notification).where(
|
|
and_(
|
|
Notification.tenant_id == 1,
|
|
Notification.user_id == int(boss.id),
|
|
Notification.title == "项目成员分配失败",
|
|
)
|
|
)
|
|
)
|
|
).scalars().all()
|
|
self.assertGreaterEqual(len(notifications), 1)
|
|
|
|
async def test_assign_project_members_replaces_member_list(self) -> None:
|
|
async with self.session_factory() as db:
|
|
boss, member_a, member_b = await self._seed_users(db)
|
|
project = Project(
|
|
tenant_id=1,
|
|
name="P-members-replace",
|
|
title="P-members-replace",
|
|
description="members replace test",
|
|
owner_id=int(boss.id),
|
|
status="active",
|
|
assigned_role="backend",
|
|
signal_count=0,
|
|
)
|
|
db.add(project)
|
|
await db.flush()
|
|
db.add(
|
|
UserProject(
|
|
tenant_id=1,
|
|
user_id=int(boss.id),
|
|
project_id=int(project.id),
|
|
)
|
|
)
|
|
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()),
|
|
):
|
|
payload = await assign_project_members(
|
|
project_id=int(project.id),
|
|
body=ProjectMembersAssignRequest(member_user_ids=[int(member_a.id), int(member_b.id)]),
|
|
db=db,
|
|
current_user=boss,
|
|
)
|
|
|
|
self.assertEqual(
|
|
sorted(payload["member_user_ids"]),
|
|
sorted([str(int(member_a.id)), str(int(member_b.id))]),
|
|
)
|
|
assigned_rows = (
|
|
await db.execute(
|
|
select(UserProject.user_id).where(
|
|
and_(
|
|
UserProject.tenant_id == 1,
|
|
UserProject.project_id == int(project.id),
|
|
)
|
|
)
|
|
)
|
|
).scalars().all()
|
|
self.assertEqual(
|
|
sorted(int(uid) for uid in assigned_rows),
|
|
sorted([int(member_a.id), int(member_b.id)]),
|
|
)
|