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)]), )