import uuid from dataclasses import dataclass import pytest from fastapi import HTTPException from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from app.api.v1.study_active_roles import _normalize_active_roles, update_active_roles @dataclass class UserStub: id: uuid.UUID is_admin: bool = False def test_normalize_active_roles_allows_renamed_qa_project_role(): assert _normalize_active_roles(["PM", "QA", "CTA", "QA", "", None]) == ["PM", "QA", "CTA"] def test_normalize_active_roles_still_rejects_system_admin_role(): with pytest.raises(HTTPException) as exc_info: _normalize_active_roles(["ADMIN"]) assert exc_info.value.status_code == 422 assert exc_info.value.detail == "ADMIN 不能作为项目角色" def test_project_pm_update_keeps_pm_active_role(): assert _normalize_active_roles(["PV", "CRA"], preserve_pm=True) == ["PM", "PV", "CRA"] def test_system_admin_update_can_remove_pm_active_role(): assert _normalize_active_roles(["PV", "CRA"], preserve_pm=False) == ["PV", "CRA"] @pytest.mark.asyncio async def test_project_pm_cannot_remove_pm_from_active_roles(db_session: AsyncSession): user_id = uuid.uuid4() study_id = uuid.uuid4() await db_session.execute( text( """ INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles) VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles) """ ), { "id": str(study_id), "code": f"PROJECT-PM-ACTIVE-ROLE-{study_id.hex[:8]}", "name": "PROJECT-PM-ACTIVE-ROLE", "status": "ACTIVE", "is_locked": False, "visit_schedule": "[]", "active_roles": '["PM","PV","CRA"]', }, ) await db_session.commit() result = await update_active_roles( study_id=study_id, payload={"active_roles": ["PV", "CRA"]}, current_user=UserStub(id=user_id), db=db_session, ) assert result["active_roles"] == ["PM", "PV", "CRA"] @pytest.mark.asyncio async def test_cannot_remove_active_role_assigned_to_active_member(db_session: AsyncSession): user_id = uuid.uuid4() member_id = uuid.uuid4() study_id = uuid.uuid4() await db_session.execute( text( """ INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles) VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles) """ ), { "id": str(study_id), "code": f"PROJECT-ROLE-IN-USE-{study_id.hex[:8]}", "name": "PROJECT-ROLE-IN-USE", "status": "ACTIVE", "is_locked": False, "visit_schedule": "[]", "active_roles": '["PM","PV","CRA"]', }, ) await db_session.execute( text( """ INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status) VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status) """ ), { "id": str(user_id), "email": f"{user_id.hex}@example.com", "password_hash": "hash", "full_name": "PV User", "clinical_department": "Clinical", "is_admin": False, "status": "ACTIVE", }, ) await db_session.execute( text( """ INSERT INTO study_members (id, study_id, user_id, role_in_study, is_active) VALUES (:id, :study_id, :user_id, :role, :active) """ ), { "id": str(member_id), "study_id": str(study_id), "user_id": str(user_id), "role": "PV", "active": True, }, ) await db_session.commit() with pytest.raises(HTTPException) as exc_info: await update_active_roles( study_id=study_id, payload={"active_roles": ["CRA"]}, current_user=UserStub(id=uuid.uuid4(), is_admin=True), db=db_session, ) assert exc_info.value.status_code == 409 assert exc_info.value.detail == "角色 PV 仍有成员使用,不能停用"