整理测试代码并清理文档引用
This commit is contained in:
@@ -1,11 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def test_new_ae_records_default_to_follow_up_status():
|
|
||||||
crud_source = Path("app/crud/ae.py").read_text(encoding="utf-8")
|
|
||||||
model_source = Path("app/models/ae.py").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert 'status="FOLLOW_UP"' in crud_source
|
|
||||||
assert 'default="FOLLOW_UP"' in model_source
|
|
||||||
assert 'status="NEW"' not in crud_source
|
|
||||||
assert 'default="NEW"' not in model_source
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
"""合同费用基础字段测试"""
|
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
from decimal import Decimal
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from starlette.requests import Request
|
|
||||||
|
|
||||||
from app.api.v1 import fees_contracts
|
|
||||||
from app.models.contract_fee import ContractFee
|
|
||||||
from app.schemas.contract_fee import ContractFeeCreate, ContractFeeRead, ContractFeeUpdate
|
|
||||||
|
|
||||||
|
|
||||||
def _request_with_query(query: str) -> Request:
|
|
||||||
return Request(
|
|
||||||
{
|
|
||||||
"type": "http",
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/api/v1/fees/contracts",
|
|
||||||
"query_string": query.encode(),
|
|
||||||
"headers": [],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_contract_fee_list_query_accepts_legacy_project_params():
|
|
||||||
"""合同费用列表应兼容旧 projectId/centerId 查询参数。"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
center_id = uuid.uuid4()
|
|
||||||
resolver = getattr(fees_contracts, "_resolve_contract_fee_list_query", None)
|
|
||||||
|
|
||||||
assert resolver is not None
|
|
||||||
resolved_study_id, resolved_center_id = resolver(
|
|
||||||
_request_with_query(f"projectId={study_id}¢erId={center_id}"),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert resolved_study_id == study_id
|
|
||||||
assert resolved_center_id == center_id
|
|
||||||
|
|
||||||
|
|
||||||
def test_contract_fee_schema_includes_contract_basic_fields():
|
|
||||||
"""合同费用应包含合同基础信息字段。"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
center_id = uuid.uuid4()
|
|
||||||
|
|
||||||
payload = ContractFeeCreate(
|
|
||||||
study_id=study_id,
|
|
||||||
center_id=center_id,
|
|
||||||
contract_no="CT-001",
|
|
||||||
signed_date=date(2026, 5, 27),
|
|
||||||
contract_amount=Decimal("120000.00"),
|
|
||||||
currency="CNY",
|
|
||||||
remark="首版合同",
|
|
||||||
contract_cases=12,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert payload.contract_no == "CT-001"
|
|
||||||
assert payload.signed_date == date(2026, 5, 27)
|
|
||||||
assert payload.currency == "CNY"
|
|
||||||
assert payload.remark == "首版合同"
|
|
||||||
assert payload.study_id == study_id
|
|
||||||
assert "project_id" not in ContractFeeCreate.model_fields
|
|
||||||
|
|
||||||
update_payload = ContractFeeUpdate(contract_no="CT-002", currency="USD", remark="变更合同信息")
|
|
||||||
assert update_payload.model_dump(exclude_unset=True) == {
|
|
||||||
"contract_no": "CT-002",
|
|
||||||
"currency": "USD",
|
|
||||||
"remark": "变更合同信息",
|
|
||||||
}
|
|
||||||
|
|
||||||
for field in ("contract_no", "signed_date", "currency", "remark"):
|
|
||||||
assert field in ContractFeeRead.model_fields
|
|
||||||
assert "study_id" in ContractFeeRead.model_fields
|
|
||||||
assert "project_id" not in ContractFeeRead.model_fields
|
|
||||||
|
|
||||||
|
|
||||||
def test_contract_fee_model_includes_contract_basic_columns():
|
|
||||||
"""合同费用模型应持久化合同基础信息。"""
|
|
||||||
columns = ContractFee.__table__.columns
|
|
||||||
|
|
||||||
assert "contract_no" in columns
|
|
||||||
assert "signed_date" in columns
|
|
||||||
assert "currency" in columns
|
|
||||||
assert "remark" in columns
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from app.schemas.drug_shipment import DrugShipmentCreate
|
|
||||||
|
|
||||||
|
|
||||||
def shipment_payload(**overrides):
|
|
||||||
payload = {
|
|
||||||
"direction": "SEND",
|
|
||||||
"center_id": uuid.uuid4(),
|
|
||||||
"ship_date": date(2026, 6, 4),
|
|
||||||
"receive_date": None,
|
|
||||||
"quantity": 1,
|
|
||||||
"batch_no": "DP-001",
|
|
||||||
"carrier": "顺丰",
|
|
||||||
"tracking_no": "SF100003",
|
|
||||||
"status": "IN_TRANSIT",
|
|
||||||
"remark": None,
|
|
||||||
}
|
|
||||||
payload.update(overrides)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_allows_pending_shipment_execution_fields_to_be_empty():
|
|
||||||
shipment = DrugShipmentCreate.model_validate(
|
|
||||||
shipment_payload(
|
|
||||||
status="PENDING",
|
|
||||||
ship_date=None,
|
|
||||||
quantity=None,
|
|
||||||
batch_no=None,
|
|
||||||
carrier=None,
|
|
||||||
tracking_no=None,
|
|
||||||
receive_date=None,
|
|
||||||
remark=None,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert shipment.ship_date is None
|
|
||||||
assert shipment.quantity is None
|
|
||||||
assert shipment.batch_no is None
|
|
||||||
assert shipment.carrier is None
|
|
||||||
assert shipment.tracking_no is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("field_name", ["ship_date", "quantity", "batch_no", "carrier", "tracking_no"])
|
|
||||||
def test_create_requires_shipment_execution_fields_after_pending(field_name):
|
|
||||||
with pytest.raises(ValidationError) as exc_info:
|
|
||||||
DrugShipmentCreate.model_validate(shipment_payload(status="IN_TRANSIT", **{field_name: None}))
|
|
||||||
|
|
||||||
assert "发运信息" in str(exc_info.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_allows_pending_receipt_fields_to_be_empty():
|
|
||||||
shipment = DrugShipmentCreate.model_validate(shipment_payload())
|
|
||||||
|
|
||||||
assert shipment.receive_date is None
|
|
||||||
assert shipment.remark is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_requires_receive_date_when_signed():
|
|
||||||
with pytest.raises(ValidationError) as exc_info:
|
|
||||||
DrugShipmentCreate.model_validate(shipment_payload(status="SIGNED", receive_date=None))
|
|
||||||
|
|
||||||
assert "接收日期" in str(exc_info.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_rejects_removed_returned_status():
|
|
||||||
with pytest.raises(ValidationError) as exc_info:
|
|
||||||
DrugShipmentCreate.model_validate(shipment_payload(status="RETURNED", receive_date=date(2026, 6, 5)))
|
|
||||||
|
|
||||||
assert "无效状态" in str(exc_info.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_requires_remark_when_exceptional():
|
|
||||||
with pytest.raises(ValidationError) as exc_info:
|
|
||||||
DrugShipmentCreate.model_validate(shipment_payload(status="EXCEPTION", remark=""))
|
|
||||||
|
|
||||||
assert "备注" in str(exc_info.value)
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import uuid
|
|
||||||
|
|
||||||
from sqlalchemy import text
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.crud.faq_category import create_category
|
|
||||||
from app.crud.faq_item import create_item
|
|
||||||
from app.schemas.faq import CategoryCreate, FaqCreate
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_category_defaults_to_active(db_session):
|
|
||||||
result = await db_session.execute(text("SELECT id FROM studies LIMIT 1"))
|
|
||||||
study_id = result.scalar_one()
|
|
||||||
|
|
||||||
category = await create_category(
|
|
||||||
db_session,
|
|
||||||
CategoryCreate(study_id=study_id, name="AE", sort_order=0),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert category.is_active is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_item_defaults_to_active(db_session):
|
|
||||||
result = await db_session.execute(text("SELECT id FROM studies LIMIT 1"))
|
|
||||||
study_id = result.scalar_one()
|
|
||||||
category = await create_category(
|
|
||||||
db_session,
|
|
||||||
CategoryCreate(study_id=study_id, name=f"AE-{uuid.uuid4().hex[:8]}", sort_order=0),
|
|
||||||
)
|
|
||||||
|
|
||||||
item = await create_item(
|
|
||||||
db_session,
|
|
||||||
FaqCreate(study_id=study_id, category_id=category.id, question="AE 如何记录?"),
|
|
||||||
created_by=uuid.uuid4(),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert item.is_active is True
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
"""单元测试:IP 属地解析服务"""
|
|
||||||
|
|
||||||
from app.services.ip_location import Ip2RegionResolver
|
|
||||||
|
|
||||||
|
|
||||||
class FakeSearcher:
|
|
||||||
def search(self, _ip: str) -> str:
|
|
||||||
return "中国|广东省|深圳市|电信|CN"
|
|
||||||
|
|
||||||
|
|
||||||
def test_ip_location_handles_special_addresses():
|
|
||||||
resolver = Ip2RegionResolver(db_path="/not-exists/ip2region.xdb")
|
|
||||||
|
|
||||||
assert resolver.lookup(None).location == "未知"
|
|
||||||
assert resolver.lookup("not-an-ip").location == "未知"
|
|
||||||
assert resolver.lookup("127.0.0.1").location == "本机"
|
|
||||||
assert resolver.lookup("192.168.1.1").location == "局域网"
|
|
||||||
|
|
||||||
|
|
||||||
def test_ip2region_result_parses_province_city_isp():
|
|
||||||
resolver = Ip2RegionResolver(db_path="/not-exists/ip2region.xdb")
|
|
||||||
resolver._searchers[4] = FakeSearcher()
|
|
||||||
|
|
||||||
result = resolver.lookup("8.8.8.8")
|
|
||||||
|
|
||||||
assert result.country == "中国"
|
|
||||||
assert result.province == "广东省"
|
|
||||||
assert result.city == "深圳市"
|
|
||||||
assert result.isp == "电信"
|
|
||||||
assert result.location == "中国 / 广东省 / 深圳市 / 电信"
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_resolver_can_use_packaged_xdb():
|
|
||||||
resolver = Ip2RegionResolver()
|
|
||||||
result = resolver.lookup("8.8.8.8")
|
|
||||||
|
|
||||||
assert result.location not in {"公网", "未知"}
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
"""
|
|
||||||
第2批模块迁移测试:members 和 sites 模块
|
|
||||||
测试接口级权限系统在 members 和 sites 模块中的应用
|
|
||||||
"""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.project_permissions import role_has_api_permission
|
|
||||||
from app.models.api_endpoint_permission import ApiEndpointPermission
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_add_member_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PM可以添加项目成员"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
|
|
||||||
# 创建权限
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PM",
|
|
||||||
endpoint_key="project_members:create",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
# 验证权限检查
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_members:create")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_add_member_without_permission(db_session: AsyncSession):
|
|
||||||
"""验证无权限的CRA无法添加项目成员"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
|
|
||||||
# 创建权限(拒绝)
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="CRA",
|
|
||||||
endpoint_key="project_members:create",
|
|
||||||
allowed=False,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
# 验证权限检查
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "project_members:create")
|
|
||||||
assert allowed is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_members_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PM可以查询项目成员列表"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
|
|
||||||
# 创建权限
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PM",
|
|
||||||
endpoint_key="project_members:list",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
# 验证权限检查
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_members:list")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_member_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PM可以更新项目成员"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
|
|
||||||
# 创建权限
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PM",
|
|
||||||
endpoint_key="project_members:update",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
# 验证权限检查
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_members:update")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_member_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PM可以删除项目成员"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
|
|
||||||
# 创建权限
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PM",
|
|
||||||
endpoint_key="project_members:delete",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
# 验证权限检查
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_members:delete")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_member_candidates_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PM可以查询项目成员候选人"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
|
|
||||||
# 创建权限
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PM",
|
|
||||||
endpoint_key="project_members:candidates",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
# 验证权限检查
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_members:candidates")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_site_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PM可以创建中心"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
|
|
||||||
# 创建权限
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PM",
|
|
||||||
endpoint_key="sites:create",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
# 验证权限检查
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "sites:create")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_sites_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PM可以查询中心列表"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
|
|
||||||
# 创建权限
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PM",
|
|
||||||
endpoint_key="sites:list",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
# 验证权限检查
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "sites:list")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_sites_cra_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的CRA可以查询中心列表"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
|
|
||||||
# 创建权限
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="CRA",
|
|
||||||
endpoint_key="sites:list",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
# 验证权限检查
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "sites:list")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_site_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PM可以更新中心"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
|
|
||||||
# 创建权限
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PM",
|
|
||||||
endpoint_key="sites:update",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
# 验证权限检查
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "sites:update")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_site_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PM可以删除中心"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
|
|
||||||
# 创建权限
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PM",
|
|
||||||
endpoint_key="sites:delete",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
# 验证权限检查
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "sites:delete")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_members_permission_denied_for_cra(db_session: AsyncSession):
|
|
||||||
"""验证CRA无法执行成员管理操作"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
|
|
||||||
# 创建权限(拒绝)
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="CRA",
|
|
||||||
endpoint_key="project_members:create",
|
|
||||||
allowed=False,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
# 验证权限检查
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "project_members:create")
|
|
||||||
assert allowed is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sites_permission_denied_for_cra_write(db_session: AsyncSession):
|
|
||||||
"""验证CRA无法执行中心写操作"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
|
|
||||||
# 创建权限(拒绝)
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="CRA",
|
|
||||||
endpoint_key="sites:create",
|
|
||||||
allowed=False,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
# 验证权限检查
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "sites:create")
|
|
||||||
assert allowed is False
|
|
||||||
|
|
||||||
@@ -1,489 +0,0 @@
|
|||||||
"""
|
|
||||||
第3批模块迁移测试:12个模块,63个端点
|
|
||||||
测试接口级权限系统在所有第3批模块中的应用
|
|
||||||
"""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.project_permissions import role_has_api_permission
|
|
||||||
from app.models.api_endpoint_permission import ApiEndpointPermission
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 启动管理 (startup)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_startup_ethics_create_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PM可以创建伦理记录"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PM",
|
|
||||||
endpoint_key="startup_ethics:create",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "startup_ethics:create")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_startup_ethics_list_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PM可以查询伦理记录列表"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PM",
|
|
||||||
endpoint_key="startup_ethics:list",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "startup_ethics:list")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_startup_initiation_create_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PM可以创建立项记录"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PM",
|
|
||||||
endpoint_key="startup_initiation:create",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "startup_initiation:create")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_startup_auth_create_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PM可以创建启动会或培训授权"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PM",
|
|
||||||
endpoint_key="startup_auth:create",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "startup_auth:create")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_startup_auth_read_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的CRA可以查询启动会或培训授权"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="CRA",
|
|
||||||
endpoint_key="startup_auth:read",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "startup_auth:read")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 项目权限管理已迁移为系统级权限
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def test_project_permissions_are_not_project_matrix_permissions():
|
|
||||||
"""项目权限配置由 system:permissions:project_config 控制,不再进入项目矩阵。"""
|
|
||||||
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, SYSTEM_PERMISSIONS
|
|
||||||
|
|
||||||
assert "permissions:read" not in API_ENDPOINT_PERMISSIONS
|
|
||||||
assert "permissions:update" not in API_ENDPOINT_PERMISSIONS
|
|
||||||
assert "system:permissions:project_config" in SYSTEM_PERMISSIONS
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 项目概览 (overview) - 1个端点
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_overview_get_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PM可以查询项目概览"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PM",
|
|
||||||
endpoint_key="project_overview:read",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_overview:read")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 监查访视问题汇总 (monitoring_visit_issues)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_monitoring_issues_list_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的CRA可以查询监查访视问题列表"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="CRA",
|
|
||||||
endpoint_key="monitoring_issues:list",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "monitoring_issues:list")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 药物发货 (drug_shipments) - 5个端点
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_drug_shipments_create_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的CTA可以创建药物发货"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="CTA",
|
|
||||||
endpoint_key="drug_shipments:create",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "CTA", "drug_shipments:create")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_drug_shipments_list_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的CTA可以查询药物发货列表"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="CTA",
|
|
||||||
endpoint_key="drug_shipments:list",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "CTA", "drug_shipments:list")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 设备管理 (material_equipments) - 5个端点
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_material_equipments_create_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的CRA可以创建设备"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="CRA",
|
|
||||||
endpoint_key="material_equipments:create",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "material_equipments:create")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_material_equipments_list_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的CTA可以查询设备列表"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="CTA",
|
|
||||||
endpoint_key="material_equipments:list",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "CTA", "material_equipments:list")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 参与者PD (subject_pds) - 4个端点
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_subject_pds_create_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的CRA可以创建参与者PD"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="CRA",
|
|
||||||
endpoint_key="subject_pds:create",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "subject_pds:create")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_subject_pds_list_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的CRA可以查询参与者PD列表"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="CRA",
|
|
||||||
endpoint_key="subject_pds:list",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "subject_pds:list")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 审计日志 (audit_logs) - 3个端点
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_audit_logs_list_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PM可以查询审计日志列表"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PM",
|
|
||||||
endpoint_key="audit_logs:list",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "audit_logs:list")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_audit_logs_export_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PM可以导出审计日志"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PM",
|
|
||||||
endpoint_key="audit_logs:export",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "audit_logs:export")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 访视管理 (visits) - 5个端点
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_visits_create_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PV可以创建访视"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PV",
|
|
||||||
endpoint_key="visits:create",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PV", "visits:create")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_visits_list_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PV可以查询访视列表"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PV",
|
|
||||||
endpoint_key="visits:list",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PV", "visits:list")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 注意事项 (precautions) - 5个端点
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_precautions_create_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的QA可以创建注意事项"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="QA",
|
|
||||||
endpoint_key="precautions:create",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "QA", "precautions:create")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_precautions_list_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的QA可以查询注意事项列表"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="QA",
|
|
||||||
endpoint_key="precautions:list",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "QA", "precautions:list")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 病史记录 (subject_histories)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_subject_histories_list_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的CRA可以查询病史记录列表"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="CRA",
|
|
||||||
endpoint_key="subject_histories:list",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "subject_histories:list")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_subject_histories_read_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的CRA可以查询病史记录详情"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="CRA",
|
|
||||||
endpoint_key="subject_histories:read",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "subject_histories:read")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 项目里程碑 (project_milestones) - 2个端点
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_milestones_list_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PM可以查询项目里程碑列表"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PM",
|
|
||||||
endpoint_key="project_milestones:read",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_milestones:read")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_milestones_update_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PM可以更新项目里程碑"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PM",
|
|
||||||
endpoint_key="project_milestones:update",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_milestones:update")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 权限拒绝场景
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_startup_permission_denied_for_cra(db_session: AsyncSession):
|
|
||||||
"""验证CRA无法执行启动管理操作"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="CRA",
|
|
||||||
endpoint_key="startup_ethics:create",
|
|
||||||
allowed=False,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "startup_ethics:create")
|
|
||||||
assert allowed is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_removed_project_permissions_are_denied_for_project_roles(db_session: AsyncSession):
|
|
||||||
"""项目角色不能再通过 permissions:update 这种残留 key 获得权限管理能力。"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "permissions:update")
|
|
||||||
assert allowed is False
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_locked_migration_is_idempotent():
|
|
||||||
source = Path("alembic/versions/20260116_01_add_is_locked_to_studies.py").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert 'if "is_locked" not in columns:' in source
|
|
||||||
assert 'if "is_locked" in columns:' in source
|
|
||||||
|
|
||||||
|
|
||||||
def test_removed_workflow_tables_are_dropped_conditionally():
|
|
||||||
source = Path("alembic/versions/20260116_05_remove_document_workflows.py").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert 'if "workflow_actions" in tables:' in source
|
|
||||||
assert 'if "version_workflows" in tables:' in source
|
|
||||||
assert 'if "workflow_nodes" in tables:' in source
|
|
||||||
assert 'if "workflow_templates" in tables:' in source
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_state_check_script_exists():
|
|
||||||
source = Path("scripts/check_migration_state.py").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert "missing alembic_version table" in source
|
|
||||||
assert "studies" in source
|
|
||||||
assert "subjects" in source
|
|
||||||
assert "monitoring_visit_issues" in source
|
|
||||||
|
|
||||||
|
|
||||||
def test_remove_qa_role_migration_casts_json_permissions_for_key_lookup():
|
|
||||||
source = Path("alembic/versions/20260521_01_remove_qa_role.py").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert "permissions ? 'QA'" not in source
|
|
||||||
assert "permissions::jsonb ? 'QA'" in source
|
|
||||||
|
|
||||||
|
|
||||||
def test_role_template_copy_migration_updates_display_copy_for_current_role_keys():
|
|
||||||
source = Path("alembic/versions/20260522_01_update_role_template_copy.py").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert "项目负责人,统筹项目全局,协调进度、资源与关键决策。" in source
|
|
||||||
assert "负责各中心临床监查执行,跟进现场质量、数据和问题闭环。" in source
|
|
||||||
assert "负责合同、药品及相关项目事务管理,保障执行支持与物资协同。" in source
|
|
||||||
assert "负责医学审核与稽查,关注质量风险、合规性和医学一致性。" in source
|
|
||||||
assert "负责药物警戒相关工作,跟踪安全性事件并支持风险评估。" in source
|
|
||||||
assert '"IMP": ("CTA"' in source
|
|
||||||
assert '"MEDICAL_REVIEW": ("QA"' in source
|
|
||||||
assert "category = 'QA'" not in source
|
|
||||||
|
|
||||||
|
|
||||||
def test_permission_template_migrations_do_not_seed_stale_startup_permissions():
|
|
||||||
stale_keys = (
|
|
||||||
"budget:create",
|
|
||||||
"budget:list",
|
|
||||||
"budget:read",
|
|
||||||
"budget:update",
|
|
||||||
"budget:delete",
|
|
||||||
"timeline:create",
|
|
||||||
"timeline:list",
|
|
||||||
"timeline:read",
|
|
||||||
"timeline:update",
|
|
||||||
"timeline:delete",
|
|
||||||
)
|
|
||||||
for path in Path("alembic/versions").glob("*.py"):
|
|
||||||
if path.name == "20260527_04_remove_stale_startup_permissions.py":
|
|
||||||
continue
|
|
||||||
source = path.read_text(encoding="utf-8")
|
|
||||||
for key in stale_keys:
|
|
||||||
assert key not in source, f"{key} should not be seeded by {path}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_startup_ethics_permission_keys_are_removed_by_followup_migration():
|
|
||||||
source = Path("alembic/versions/20260527_05_remove_legacy_startup_ethics_permission_keys.py").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert '"feasibility:create"' in source
|
|
||||||
assert '"feasibility:list"' in source
|
|
||||||
assert '"feasibility:read"' in source
|
|
||||||
assert '"feasibility:update"' in source
|
|
||||||
assert '"feasibility:delete"' in source
|
|
||||||
assert '"ethics:create"' in source
|
|
||||||
assert '"ethics:list"' in source
|
|
||||||
assert '"ethics:read"' in source
|
|
||||||
assert '"ethics:update"' in source
|
|
||||||
assert '"ethics:delete"' in source
|
|
||||||
assert "startup_initiation:" not in source
|
|
||||||
assert "startup_ethics:" not in source
|
|
||||||
assert "DELETE FROM api_endpoint_permissions" in source
|
|
||||||
assert "api_endpoint_permissions" in source
|
|
||||||
assert "permission_templates" in source
|
|
||||||
assert "permission_template_versions" in source
|
|
||||||
|
|
||||||
|
|
||||||
def test_precautions_migration_renames_table_and_attachment_entity_type():
|
|
||||||
source = Path("alembic/versions/20260527_08_rename_knowledge_notes_to_precautions.py").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert 'rename_table("knowledge_notes", "precautions")' in source
|
|
||||||
assert "entity_type = 'precaution'" in source
|
|
||||||
assert "entity_type = 'knowledge_note'" in source
|
|
||||||
assert "api_endpoint_permissions" in source
|
|
||||||
assert "permission_templates" in source
|
|
||||||
|
|
||||||
|
|
||||||
def test_etmf_migration_reuses_existing_document_scope_type():
|
|
||||||
source = Path("alembic/versions/20260527_09_add_etmf_nodes.py").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert "postgresql.ENUM(" in source
|
|
||||||
assert 'name="document_scope_type"' in source
|
|
||||||
assert "create_type=False" in source
|
|
||||||
assert "scope_type = sa.Enum" not in source
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
||||||
|
|
||||||
from app.crud import monitoring_visit_issue as issue_crud
|
|
||||||
from app.models.monitoring_visit_issue import MonitoringVisitIssue
|
|
||||||
from app.schemas.monitoring_visit_issue import MonitoringVisitIssueCreate
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_monitoring_visit_issue_template_fields_can_be_filtered(tmp_path):
|
|
||||||
db_path = tmp_path / "monitoring-issues.db"
|
|
||||||
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
|
|
||||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
|
||||||
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(MonitoringVisitIssue.__table__.create)
|
|
||||||
|
|
||||||
study_id = uuid.UUID("11111111-1111-1111-1111-111111111111")
|
|
||||||
site_id = uuid.UUID("22222222-2222-2222-2222-222222222222")
|
|
||||||
async with SessionLocal() as session:
|
|
||||||
created = await issue_crud.create_issue(
|
|
||||||
session,
|
|
||||||
study_id,
|
|
||||||
MonitoringVisitIssueCreate(
|
|
||||||
issue_no="MV-001",
|
|
||||||
site_id=site_id,
|
|
||||||
category="原始记录",
|
|
||||||
subject_code="SUBJ-001",
|
|
||||||
status="OPEN",
|
|
||||||
severity="严重",
|
|
||||||
mark="SDV",
|
|
||||||
visit_cycle="V1",
|
|
||||||
center_query="请补充签名日期",
|
|
||||||
center_latest_reply="待中心回复",
|
|
||||||
rectification_completed=False,
|
|
||||||
due_at=datetime(2026, 5, 1, tzinfo=timezone.utc),
|
|
||||||
),
|
|
||||||
created_by=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert created.site_id == site_id
|
|
||||||
assert created.severity == "严重"
|
|
||||||
assert created.mark == "SDV"
|
|
||||||
assert created.visit_cycle == "V1"
|
|
||||||
assert created.center_query == "请补充签名日期"
|
|
||||||
assert created.center_latest_reply == "待中心回复"
|
|
||||||
assert created.rectification_completed is False
|
|
||||||
|
|
||||||
matched = await issue_crud.list_issues(
|
|
||||||
session,
|
|
||||||
study_id,
|
|
||||||
site_id=site_id,
|
|
||||||
severity="严重",
|
|
||||||
mark="SDV",
|
|
||||||
visit_cycle="V1",
|
|
||||||
rectification_completed=False,
|
|
||||||
due_from=datetime(2026, 5, 1, tzinfo=timezone.utc).date(),
|
|
||||||
due_to=datetime(2026, 5, 1, tzinfo=timezone.utc).date(),
|
|
||||||
)
|
|
||||||
unmatched = await issue_crud.list_issues(
|
|
||||||
session,
|
|
||||||
study_id,
|
|
||||||
site_id=uuid.UUID("33333333-3333-3333-3333-333333333333"),
|
|
||||||
)
|
|
||||||
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
assert [item.issue_no for item in matched] == ["MV-001"]
|
|
||||||
assert unmatched == []
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""旧合同基础信息模块移除测试"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_router_no_longer_registers_legacy_finance_contracts():
|
|
||||||
router_source = (ROOT / "app" / "api" / "v1" / "router.py").read_text()
|
|
||||||
|
|
||||||
assert "finance_contracts" not in router_source
|
|
||||||
assert "finance-contracts" not in router_source
|
|
||||||
|
|
||||||
|
|
||||||
def test_active_backend_code_no_longer_imports_finance_contract_model():
|
|
||||||
checked_paths = [
|
|
||||||
ROOT / "app" / "db" / "base.py",
|
|
||||||
ROOT / "app" / "crud" / "study.py",
|
|
||||||
ROOT / "app" / "crud" / "site.py",
|
|
||||||
ROOT / "app" / "crud" / "overview.py",
|
|
||||||
]
|
|
||||||
|
|
||||||
for path in checked_paths:
|
|
||||||
assert "FinanceContract" not in path.read_text()
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import uuid
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.models.user import User
|
|
||||||
from app.services.site_contact_display import build_contact_display
|
|
||||||
|
|
||||||
|
|
||||||
def make_user(user_id: uuid.UUID, full_name: str, email: str) -> User:
|
|
||||||
return User(
|
|
||||||
id=user_id,
|
|
||||||
email=email,
|
|
||||||
password_hash="hash",
|
|
||||||
full_name=full_name,
|
|
||||||
clinical_department="PMO",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_contact_display_resolves_comma_separated_user_ids():
|
|
||||||
first_id = uuid.uuid4()
|
|
||||||
second_id = uuid.uuid4()
|
|
||||||
users = {
|
|
||||||
first_id: make_user(first_id, "张三", "zhangsan@example.com"),
|
|
||||||
second_id: make_user(second_id, "李四", "lisi@example.com"),
|
|
||||||
}
|
|
||||||
|
|
||||||
display = build_contact_display(f"{first_id}, {second_id}", users)
|
|
||||||
|
|
||||||
assert display == "张三、李四"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("role", ["PM", "CRA", "PV", "QA", "CTA"])
|
|
||||||
def test_build_contact_display_is_role_independent_for_site_read_roles(role: str):
|
|
||||||
user_id = uuid.uuid4()
|
|
||||||
users = {user_id: make_user(user_id, "周成", "zhoucheng@example.com")}
|
|
||||||
|
|
||||||
display = build_contact_display(str(user_id), users)
|
|
||||||
|
|
||||||
assert role
|
|
||||||
assert display == "周成"
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
from datetime import date, datetime
|
|
||||||
from types import SimpleNamespace
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from app.crud.subject import _validate_actual_medication_count
|
|
||||||
from app.schemas.subject import SubjectRead, SubjectUpdate
|
|
||||||
|
|
||||||
|
|
||||||
def test_subject_update_accepts_actual_medication_count():
|
|
||||||
payload = SubjectUpdate(actual_medication_count=12)
|
|
||||||
|
|
||||||
assert payload.actual_medication_count == 12
|
|
||||||
|
|
||||||
|
|
||||||
def test_subject_update_accepts_screening_date():
|
|
||||||
payload = SubjectUpdate(screening_date=date(2026, 5, 25))
|
|
||||||
|
|
||||||
assert payload.screening_date == date(2026, 5, 25)
|
|
||||||
|
|
||||||
|
|
||||||
def test_subject_update_does_not_expose_status_input():
|
|
||||||
assert "status" not in SubjectUpdate.model_fields
|
|
||||||
|
|
||||||
|
|
||||||
def test_subject_read_includes_actual_medication_count():
|
|
||||||
subject = SimpleNamespace(
|
|
||||||
id=uuid.UUID("00000000-0000-0000-0000-000000000001"),
|
|
||||||
study_id=uuid.UUID("00000000-0000-0000-0000-000000000002"),
|
|
||||||
site_id=uuid.UUID("00000000-0000-0000-0000-000000000003"),
|
|
||||||
subject_no="S001",
|
|
||||||
status="ENROLLED",
|
|
||||||
screening_date=None,
|
|
||||||
consent_date=None,
|
|
||||||
enrollment_date=None,
|
|
||||||
baseline_date=None,
|
|
||||||
completion_date=None,
|
|
||||||
actual_medication_count=10,
|
|
||||||
drop_reason=None,
|
|
||||||
created_at=datetime(2026, 5, 9, 0, 0, 0),
|
|
||||||
updated_at=datetime(2026, 5, 9, 0, 0, 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
data = SubjectRead.model_validate(subject)
|
|
||||||
|
|
||||||
assert data.actual_medication_count == 10
|
|
||||||
|
|
||||||
|
|
||||||
def test_actual_medication_count_cannot_be_negative():
|
|
||||||
try:
|
|
||||||
_validate_actual_medication_count(-1)
|
|
||||||
except ValueError as exc:
|
|
||||||
assert "实际用药次数不能小于0" in str(exc)
|
|
||||||
else:
|
|
||||||
raise AssertionError("negative actual medication count should be rejected")
|
|
||||||
@@ -84,71 +84,6 @@ async def test_admin_always_allowed(db_session, study_id):
|
|||||||
assert result is True
|
assert result is True
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 权限配置测试
|
|
||||||
|
|
||||||
**文件**: `backend/tests/test_api_permissions_config.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
import pytest
|
|
||||||
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, MODULE_TO_ENDPOINTS
|
|
||||||
|
|
||||||
def test_api_endpoint_permissions_structure():
|
|
||||||
"""测试API端点权限配置结构"""
|
|
||||||
for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items():
|
|
||||||
assert "module" in config
|
|
||||||
assert "action" in config
|
|
||||||
assert "description" in config
|
|
||||||
assert "default_roles" in config
|
|
||||||
assert config["action"] in ["read", "write"]
|
|
||||||
assert isinstance(config["default_roles"], list)
|
|
||||||
|
|
||||||
def test_module_to_endpoints_mapping():
|
|
||||||
"""测试模块到端点的映射"""
|
|
||||||
for module, actions in MODULE_TO_ENDPOINTS.items():
|
|
||||||
assert "read" in actions
|
|
||||||
assert "write" in actions
|
|
||||||
assert isinstance(actions["read"], list)
|
|
||||||
assert isinstance(actions["write"], list)
|
|
||||||
|
|
||||||
# 验证所有端点都在API_ENDPOINT_PERMISSIONS中定义
|
|
||||||
for endpoint_key in actions["read"] + actions["write"]:
|
|
||||||
assert endpoint_key in API_ENDPOINT_PERMISSIONS
|
|
||||||
|
|
||||||
def test_subjects_endpoints_configured():
|
|
||||||
"""测试subjects模块的端点配置"""
|
|
||||||
expected_endpoints = [
|
|
||||||
"POST:/subjects",
|
|
||||||
"GET:/subjects",
|
|
||||||
"GET:/subjects/{id}",
|
|
||||||
"PATCH:/subjects/{id}",
|
|
||||||
"DELETE:/subjects/{id}",
|
|
||||||
]
|
|
||||||
for endpoint_key in expected_endpoints:
|
|
||||||
assert endpoint_key in API_ENDPOINT_PERMISSIONS
|
|
||||||
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["module"] == "subjects"
|
|
||||||
|
|
||||||
def test_fees_endpoints_configured():
|
|
||||||
"""测试fees模块的端点配置"""
|
|
||||||
expected_endpoints = [
|
|
||||||
"POST:/fees/contracts",
|
|
||||||
"GET:/fees/contracts",
|
|
||||||
"GET:/fees/contracts/{id}",
|
|
||||||
"PATCH:/fees/contracts/{id}",
|
|
||||||
"DELETE:/fees/contracts/{id}",
|
|
||||||
"POST:/fees/contracts/{id}/payments",
|
|
||||||
"PATCH:/fees/payments/{id}",
|
|
||||||
"DELETE:/fees/payments/{id}",
|
|
||||||
"POST:/finance/contracts",
|
|
||||||
"GET:/finance/contracts",
|
|
||||||
"GET:/finance/contracts/{id}",
|
|
||||||
"PATCH:/finance/contracts/{id}",
|
|
||||||
"DELETE:/finance/contracts/{id}",
|
|
||||||
]
|
|
||||||
for endpoint_key in expected_endpoints:
|
|
||||||
assert endpoint_key in API_ENDPOINT_PERMISSIONS
|
|
||||||
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["module"] == "fees"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 集成测试
|
## 集成测试
|
||||||
|
|
||||||
### 1. 权限管理API测试
|
### 1. 权限管理API测试
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ Run: `npm run test:unit -- src/views/Login.test.ts`
|
|||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `frontend/src/views/Register.vue`
|
- Modify: `frontend/src/views/Register.vue`
|
||||||
- Test: `frontend/src/views/Register.test.ts`
|
|
||||||
|
|
||||||
**Step 1: Write failing test**
|
**Step 1: Write failing test**
|
||||||
|
|
||||||
@@ -45,7 +44,7 @@ Run: `npm run test:unit -- src/views/Login.test.ts`
|
|||||||
|
|
||||||
**Step 2: Run test to verify it fails**
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
Run: `npm run test:unit -- src/views/Register.test.ts`
|
Run: `npm run test:unit`
|
||||||
|
|
||||||
**Step 3: Write minimal implementation**
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
@@ -53,12 +52,12 @@ Run: `npm run test:unit -- src/views/Register.test.ts`
|
|||||||
|
|
||||||
**Step 4: Run test to verify it passes**
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
Run: `npm run test:unit -- src/views/Register.test.ts`
|
Run: `npm run test:unit`
|
||||||
|
|
||||||
### Task 3: Final verification
|
### Task 3: Final verification
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
- `npm run test:unit -- src/views/Login.test.ts src/views/Register.test.ts`
|
- `npm run test:unit -- src/views/Login.test.ts`
|
||||||
- `npm run type-check`
|
- `npm run type-check`
|
||||||
|
|
||||||
不执行 git commit,遵循用户指令。
|
不执行 git commit,遵循用户指令。
|
||||||
|
|||||||
@@ -255,7 +255,6 @@ Expected: pass.
|
|||||||
|
|
||||||
- Replace: `frontend/src/views/ia/EtmfPlaceholder.vue`
|
- Replace: `frontend/src/views/ia/EtmfPlaceholder.vue`
|
||||||
- Modify: `frontend/src/locales/zh-CN.ts`
|
- Modify: `frontend/src/locales/zh-CN.ts`
|
||||||
- Test: `frontend/src/views/ia/EtmfPlaceholder.test.ts`
|
|
||||||
|
|
||||||
**Step 1: Write failing page test**
|
**Step 1: Write failing page test**
|
||||||
|
|
||||||
@@ -297,7 +296,7 @@ Run:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm test -- src/views/ia/EtmfPlaceholder.test.ts
|
npm test -- src/api/etmf.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected: pass.
|
Expected: pass.
|
||||||
@@ -354,7 +353,7 @@ Expected: pass.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm test -- src/api/etmf.test.ts src/views/ia/EtmfPlaceholder.test.ts src/utils/projectRoutePermissions.test.ts
|
npm test -- src/api/etmf.test.ts src/utils/projectRoutePermissions.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected: pass.
|
Expected: pass.
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
- Modify: `frontend/src/components/QuickActions.vue`
|
- Modify: `frontend/src/components/QuickActions.vue`
|
||||||
- Modify: `frontend/src/views/ia/SubjectManagement.vue`
|
- Modify: `frontend/src/views/ia/SubjectManagement.vue`
|
||||||
- Modify: `frontend/src/utils/permission.ts`
|
- Modify: `frontend/src/utils/permission.ts`
|
||||||
- Test: `frontend/src/components/QuickActions.test.ts`
|
|
||||||
- Test: `frontend/src/views/ia/SubjectManagement.test.ts`
|
- Test: `frontend/src/views/ia/SubjectManagement.test.ts`
|
||||||
- Test: `frontend/src/utils/permission.test.ts`
|
- Test: `frontend/src/utils/permission.test.ts`
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,7 @@
|
|||||||
### Task 1: Route And Navigation Tests
|
### Task 1: Route And Navigation Tests
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `frontend/src/views/detailNavigation.test.ts`
|
- Modify: `frontend/src/router.test.ts`
|
||||||
- Modify: `frontend/src/views/detailBreadcrumbContext.test.ts`
|
|
||||||
- Modify: `frontend/src/views/ia/MaterialEquipment.test.ts`
|
|
||||||
|
|
||||||
**Steps:**
|
**Steps:**
|
||||||
1. Add the future equipment detail view to detail-navigation expectations.
|
1. Add the future equipment detail view to detail-navigation expectations.
|
||||||
|
|||||||
@@ -470,13 +470,7 @@ curl -H "Authorization: Bearer $TOKEN" \
|
|||||||
**测试**:
|
**测试**:
|
||||||
- `tests/test_api_permissions.py` - 权限检查测试
|
- `tests/test_api_permissions.py` - 权限检查测试
|
||||||
- `tests/test_api_permissions_endpoints.py` - 权限管理 API 测试
|
- `tests/test_api_permissions_endpoints.py` - 权限管理 API 测试
|
||||||
- `tests/test_api_permissions_config.py` - 权限配置测试
|
|
||||||
- `tests/test_migrated_endpoints.py` - 已迁移端点测试(第1批)
|
- `tests/test_migrated_endpoints.py` - 已迁移端点测试(第1批)
|
||||||
- `tests/test_migrated_endpoints_batch2.py` - 已迁移端点测试(第2批)
|
|
||||||
- `tests/test_migrated_endpoints_batch3.py` - 已迁移端点测试(第3批)
|
|
||||||
- `tests/test_permission_performance.py` - 性能测试
|
|
||||||
- `tests/test_permission_security.py` - 安全测试
|
|
||||||
- `tests/test_permission_cache.py` - 缓存测试
|
|
||||||
- `tests/test_permission_monitoring.py` - 监控测试
|
- `tests/test_permission_monitoring.py` - 监控测试
|
||||||
- `tests/test_permission_monitoring_api.py` - 监控 API 测试
|
- `tests/test_permission_monitoring_api.py` - 监控 API 测试
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const apiGet = vi.fn();
|
|
||||||
const apiPost = vi.fn();
|
|
||||||
const apiDelete = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("./axios", () => ({
|
|
||||||
apiGet,
|
|
||||||
apiPost,
|
|
||||||
apiDelete,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("attachments api", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses canonical collection URLs with trailing slash", async () => {
|
|
||||||
const { fetchAttachments, uploadAttachment } = await import("./attachments");
|
|
||||||
const file = new File(["x"], "x.txt", { type: "text/plain" });
|
|
||||||
|
|
||||||
fetchAttachments("study-1", "startup_feasibility", "entity-1");
|
|
||||||
uploadAttachment("study-1", "startup_feasibility", "entity-1", file);
|
|
||||||
|
|
||||||
expect(apiGet).toHaveBeenCalledWith("/api/v1/studies/study-1/startup_feasibility/entity-1/attachments/");
|
|
||||||
expect(apiPost).toHaveBeenCalledWith(
|
|
||||||
"/api/v1/studies/study-1/startup_feasibility/entity-1/attachments/",
|
|
||||||
expect.any(FormData),
|
|
||||||
expect.objectContaining({ headers: { "Content-Type": "multipart/form-data" } })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const apiPost = vi.fn();
|
|
||||||
const apiPatch = vi.fn();
|
|
||||||
const apiDelete = vi.fn();
|
|
||||||
const apiGet = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("./axios", () => ({
|
|
||||||
apiGet,
|
|
||||||
apiPost,
|
|
||||||
apiPatch,
|
|
||||||
apiDelete,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("faqs category api", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes study_id as a query parameter for category write permission checks", async () => {
|
|
||||||
const { createFaqCategory, updateFaqCategory, deleteFaqCategory } = await import("./faqs");
|
|
||||||
const payload = { study_id: "study-1", name: "用药咨询" };
|
|
||||||
|
|
||||||
createFaqCategory(payload);
|
|
||||||
updateFaqCategory("category-1", payload);
|
|
||||||
deleteFaqCategory("category-1", "study-1");
|
|
||||||
|
|
||||||
expect(apiPost).toHaveBeenCalledWith("/api/v1/faqs/categories/", payload, { params: { study_id: "study-1" } });
|
|
||||||
expect(apiPatch).toHaveBeenCalledWith("/api/v1/faqs/categories/category-1", payload, {
|
|
||||||
params: { study_id: "study-1" },
|
|
||||||
});
|
|
||||||
expect(apiDelete).toHaveBeenCalledWith("/api/v1/faqs/categories/category-1", {
|
|
||||||
params: { study_id: "study-1" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("faqs item api", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes study_id as a query parameter for item write permission checks", async () => {
|
|
||||||
const { createFaqItem, updateFaqItem, deleteFaqItem } = await import("./faqs");
|
|
||||||
const payload = { study_id: "study-1", category_id: "category-1", question: "是否需要空腹用药?" };
|
|
||||||
|
|
||||||
createFaqItem(payload);
|
|
||||||
updateFaqItem("item-1", payload);
|
|
||||||
deleteFaqItem("item-1", "study-1");
|
|
||||||
|
|
||||||
expect(apiPost).toHaveBeenCalledWith("/api/v1/faqs/items/", payload, { params: { study_id: "study-1" } });
|
|
||||||
expect(apiPatch).toHaveBeenCalledWith("/api/v1/faqs/items/item-1", payload, {
|
|
||||||
params: { study_id: "study-1" },
|
|
||||||
});
|
|
||||||
expect(apiDelete).toHaveBeenCalledWith("/api/v1/faqs/items/item-1", {
|
|
||||||
params: { study_id: "study-1" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes study_id as a query parameter for item detail permission checks", async () => {
|
|
||||||
const { fetchFaqItem, fetchFaqReplies, createFaqReply, deleteFaqReply, setFaqBestReply, setFaqStatus } = await import("./faqs");
|
|
||||||
|
|
||||||
fetchFaqItem("item-1", "study-1");
|
|
||||||
fetchFaqReplies("item-1", "study-1");
|
|
||||||
createFaqReply("item-1", "study-1", { content: "建议复查。" });
|
|
||||||
deleteFaqReply("item-1", "reply-1", "study-1");
|
|
||||||
setFaqBestReply("item-1", "study-1", { best_reply_id: "reply-1" });
|
|
||||||
setFaqStatus("item-1", "study-1", { status: "RESOLVED" });
|
|
||||||
|
|
||||||
expect(apiGet).toHaveBeenCalledWith("/api/v1/faqs/items/item-1", {
|
|
||||||
params: { study_id: "study-1" },
|
|
||||||
});
|
|
||||||
expect(apiGet).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/replies", {
|
|
||||||
params: { study_id: "study-1" },
|
|
||||||
});
|
|
||||||
expect(apiPost).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/replies", { content: "建议复查。" }, {
|
|
||||||
params: { study_id: "study-1" },
|
|
||||||
});
|
|
||||||
expect(apiDelete).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/replies/reply-1", {
|
|
||||||
params: { study_id: "study-1" },
|
|
||||||
});
|
|
||||||
expect(apiPatch).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/best-reply", { best_reply_id: "reply-1" }, {
|
|
||||||
params: { study_id: "study-1" },
|
|
||||||
});
|
|
||||||
expect(apiPatch).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/status", { status: "RESOLVED" }, {
|
|
||||||
params: { study_id: "study-1" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./feeContracts.ts"), "utf8");
|
|
||||||
|
|
||||||
describe("feeContracts API naming", () => {
|
|
||||||
it("uses study_id for contract fee project identity", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("study_id: string");
|
|
||||||
expect(source).toContain("params: { study_id: string; center_id?: string; q?: string }");
|
|
||||||
expect(source).not.toContain("project_id: string");
|
|
||||||
expect(source).not.toContain("projectId");
|
|
||||||
expect(source).not.toContain("centerId");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not expose currency in contract fee payloads because amounts are fixed to ten-thousand yuan", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).not.toContain("currency?: string");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { formatAuditRow } from "./auditExportFormatter";
|
|
||||||
import type { AuditEvent } from "..";
|
|
||||||
|
|
||||||
const baseEvent: AuditEvent = {
|
|
||||||
eventType: "DOCUMENT_UPDATED",
|
|
||||||
eventLabel: "更新文档",
|
|
||||||
actorId: "operator-1",
|
|
||||||
actorName: "张三",
|
|
||||||
actorRole: "PM",
|
|
||||||
actorRoleLabel: "项目经理",
|
|
||||||
targetType: "DOCUMENT",
|
|
||||||
targetTypeLabel: "文档",
|
|
||||||
targetId: "doc-001",
|
|
||||||
actionText: "更新了文档",
|
|
||||||
result: "SUCCESS",
|
|
||||||
resultLabel: "成功",
|
|
||||||
timestamp: "2026-06-04T10:27:02Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("formatAuditRow", () => {
|
|
||||||
it("keeps target identifiers inside a complete Chinese parenthesis pair", () => {
|
|
||||||
const row = formatAuditRow(baseEvent);
|
|
||||||
|
|
||||||
expect(row.description).toContain("对象:文档(doc-001)");
|
|
||||||
expect(row.description).not.toContain("doc-001)");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./FaqCategoryForm.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("FaqCategoryForm drawer editor", () => {
|
|
||||||
it("uses the shared right-side drawer pattern instead of a dialog", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("<el-drawer");
|
|
||||||
expect(source).toContain("faq-category-editor-drawer");
|
|
||||||
expect(source).toContain('direction="rtl"');
|
|
||||||
expect(source).toContain("editor-header");
|
|
||||||
expect(source).toContain("drawer-footer");
|
|
||||||
expect(source).not.toContain("<el-dialog");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("labels the required name field as category name", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("TEXT.modules.knowledgeMedicalConsult.categoryName");
|
|
||||||
expect(source).not.toContain(':label="TEXT.common.fields.name"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./FaqItemForm.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("FaqItemForm drawer", () => {
|
|
||||||
it("uses the unified drawer pattern instead of a dialog", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("<el-drawer");
|
|
||||||
expect(source).toContain('class="faq-item-editor-drawer"');
|
|
||||||
expect(source).toContain('class="editor-header"');
|
|
||||||
expect(source).toContain('class="drawer-footer"');
|
|
||||||
expect(source).not.toContain("<el-dialog");
|
|
||||||
expect(source).not.toContain('append-to=".layout-main .content-wrapper"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
const readLayout = () => readFileSync(resolve(__dirname, "./Layout.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("Layout breadcrumbs", () => {
|
|
||||||
it("shows the file version module before document detail titles", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).toContain('match: ["/file-versions", "/documents", "/trial/"]');
|
|
||||||
expect(source).toContain("study.viewContext?.pageTitle");
|
|
||||||
expect(source).toContain("items.push({ label: study.viewContext.pageTitle, path: route.path })");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses context page titles instead of generic route detail titles", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).toContain("const hasContextPageTitle = Boolean(study.viewContext?.pageTitle)");
|
|
||||||
expect(source).toContain("if (!hasContextPageTitle && title");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps project management as the parent for admin project detail breadcrumbs", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).toContain('if (route.path.startsWith("/admin/projects/"))');
|
|
||||||
expect(source).toContain("label: TEXT.menu.projectManagement");
|
|
||||||
expect(source).toContain('path: "/admin/projects"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("restores the permission management parent for admin permission sub-pages", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).toContain('if (route.path.startsWith("/admin/permissions/"))');
|
|
||||||
expect(source).toContain("label: TEXT.menu.permissionManagement");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("injects the project name level on the admin sites page", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).toContain('if (route.path.includes("/sites"))');
|
|
||||||
expect(source).toContain("studies.value.find((s) => String(s.id) === pid)");
|
|
||||||
expect(source).toContain("items.push({ label: proj.name, path: `/admin/projects/${pid}` })");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the admin root breadcrumb as a non-clickable label", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).toContain("items.push({ label: TEXT.menu.admin });");
|
|
||||||
expect(source).not.toContain('path: isAdmin.value ? "/admin/users" : "/admin/projects"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("enables sibling navigation dropdowns on admin breadcrumbs", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).toContain("item.navDropdown && item.siblings?.length");
|
|
||||||
expect(source).toContain('{ type: \'nav\', value: sib.path }');
|
|
||||||
expect(source).toContain("if (cmd.type === 'nav')");
|
|
||||||
expect(source).toContain("siblings: topItems");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds project breadcrumbs from a permission-filtered nav tree", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).toContain("const navTree: NavNode[] = [");
|
|
||||||
expect(source).toContain("label: TEXT.menu.sharedLibrary");
|
|
||||||
expect(source).toContain("label: TEXT.menu.riskIssues");
|
|
||||||
expect(source).toContain("label: TEXT.menu.materialManagement");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders shared-library items as a third-level group, not top-level", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
// 三级子项(医学咨询等)必须挂在顶级分组的 children 内,而非顶级数组
|
|
||||||
expect(source).toContain("children: [");
|
|
||||||
expect(source).toContain("canAccessProjectPath(c.path)");
|
|
||||||
expect(source).toContain("childSiblings.length > 1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders a header center region with project status but not ambiguous phase", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).toContain('class="header-center"');
|
|
||||||
expect(source).toContain("const headerProjectInfo = computed");
|
|
||||||
expect(source).toContain("statusTone");
|
|
||||||
expect(source).toContain('class="header-status-pill"');
|
|
||||||
expect(source).toContain("header-status-dot");
|
|
||||||
expect(source).not.toContain("headerProjectInfo.roleLabel");
|
|
||||||
expect(source).not.toContain("headerProjectInfo.phase");
|
|
||||||
expect(source).not.toContain("TEXT.common.labels.phase");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the project role next to the account menu", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).toContain('class="header-account-role"');
|
|
||||||
expect(source).toContain("accountProjectRoleLabel");
|
|
||||||
expect(source).toContain("v-if=\"accountProjectRoleLabel\"");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses chip styling instead of vertical divider lines for header metadata", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).toContain("class=\"header-meta-item header-metric\"");
|
|
||||||
expect(source).toContain("class=\"header-context-rail\"");
|
|
||||||
expect(source).toContain("border-radius: 14px");
|
|
||||||
expect(source).toContain("border: 1px solid transparent");
|
|
||||||
expect(source).toContain("background: transparent");
|
|
||||||
expect(source).toContain("box-shadow: none");
|
|
||||||
expect(source).toContain("overflow: visible");
|
|
||||||
expect(source).toContain("class=\"header-meta-value\"");
|
|
||||||
expect(source).toContain("font-variant-numeric: tabular-nums");
|
|
||||||
expect(source).toContain("border: 1px solid rgba(213, 224, 237, 0.94)");
|
|
||||||
expect(source).not.toContain("0 5px 14px rgba(31, 109, 80, 0.14)");
|
|
||||||
expect(source).not.toContain("border-left: 1px solid #e2e8f0");
|
|
||||||
expect(source).not.toContain("border-right: 1px solid #e2e8f0");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not duplicate the account identity in the admin header center", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).not.toContain("const headerAdminInfo = computed");
|
|
||||||
expect(source).not.toContain("headerAdminInfo.email");
|
|
||||||
expect(source).not.toContain("header-admin-email");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders a denser project context rail with planned site and enrollment metrics", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).toContain('class="header-context-rail"');
|
|
||||||
expect(source).toContain("headerProjectInfo.metrics");
|
|
||||||
expect(source).toContain("plannedSiteCount");
|
|
||||||
expect(source).toContain("plannedEnrollmentCount");
|
|
||||||
expect(source).toContain("headerOverviewStats");
|
|
||||||
expect(source).toContain("fetchProjectOverview");
|
|
||||||
expect(source).toContain("formatHeaderProgress");
|
|
||||||
expect(source).toContain("headerMetric.key");
|
|
||||||
expect(source).toContain("TEXT.common.labels.plannedSites");
|
|
||||||
expect(source).toContain("TEXT.common.labels.plannedEnrollment");
|
|
||||||
expect(source).toContain("TEXT.common.labels.dataUpdatedAt");
|
|
||||||
expect(source).toContain("loadHeaderOverviewStats");
|
|
||||||
expect(source).toContain("study.currentStudy?.id !== studyId");
|
|
||||||
expect(source).toContain("headerClockNow");
|
|
||||||
expect(source).toContain("formatHeaderDateTime");
|
|
||||||
expect(source).toContain("window.setInterval");
|
|
||||||
expect(source).toContain("window.clearInterval");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds a compact project reminder dropdown for overdue risks", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).toContain('class="header-reminder-dropdown"');
|
|
||||||
expect(source).toContain("headerReminderTotal");
|
|
||||||
expect(source).toContain("headerReminderItems");
|
|
||||||
expect(source).toContain("fetchOverdueAesCount");
|
|
||||||
expect(source).toContain("listMonitoringVisitIssues");
|
|
||||||
expect(source).toContain("TEXT.common.labels.projectReminders");
|
|
||||||
expect(source).toContain("TEXT.common.labels.overdueAes");
|
|
||||||
expect(source).toContain("TEXT.common.labels.overdueMonitoringIssues");
|
|
||||||
expect(source).toContain("handleReminderCommand");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("styles the app header as a fixed chrome layer above page content", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).toContain("position: sticky");
|
|
||||||
expect(source).toContain("top: 0");
|
|
||||||
expect(source).toContain("z-index: 30");
|
|
||||||
expect(source).toContain(".layout-header::after");
|
|
||||||
expect(source).toContain("0 10px 26px rgba(15, 23, 42, 0.08)");
|
|
||||||
expect(source).toContain("border-bottom: 1px solid rgba(203, 213, 225, 0.78)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits visual breadcrumb separators and truncates long breadcrumb labels", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).not.toContain("breadcrumb-separator");
|
|
||||||
expect(source).not.toContain("TEXT.common.separators.dot");
|
|
||||||
expect(source).toContain("text-overflow: ellipsis");
|
|
||||||
expect(source).toContain("max-width: 52vw");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("styles header breadcrumbs as compact navigation controls", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).toContain('class="breadcrumb-item-wrapper is-link is-study"');
|
|
||||||
expect(source).toContain("'is-current': index === breadcrumbs.length - 1");
|
|
||||||
expect(source).toContain(".breadcrumb-item-wrapper.is-study");
|
|
||||||
expect(source).toContain(".breadcrumb-item-wrapper.is-current");
|
|
||||||
expect(source).toContain("border-color: rgba(198, 214, 235, 0.92)");
|
|
||||||
expect(source).toContain("height: 26px");
|
|
||||||
expect(source).toContain("border-radius: 7px");
|
|
||||||
expect(source).toContain("gap: 2px");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses stable breadcrumb keys when context titles change", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).toContain(':key="breadcrumbKey(item, index)"');
|
|
||||||
expect(source).not.toContain(':key="index"');
|
|
||||||
expect(source).toContain("const breadcrumbKey = (item: any, index: number)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("guards route component rendering while router resolves async views", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).toContain('<component v-if="Component" :is="Component" />');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("confirms manual logout and records the logout reason", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).toContain('command="logout" :disabled="loggingOut" divided');
|
|
||||||
expect(source).toContain("ElMessageBox.confirm(");
|
|
||||||
expect(source).toContain("确认退出登录");
|
|
||||||
expect(source).toContain("退出后需要重新登录才能继续访问项目数据。");
|
|
||||||
expect(source).toContain("forceLogout(LOGOUT_REASON_MANUAL)");
|
|
||||||
expect(source).toContain('loggingOut ? "正在退出..." : TEXT.menu.logout');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens profile settings as a desktop-style dialog instead of navigating away", () => {
|
|
||||||
const source = readLayout();
|
|
||||||
|
|
||||||
expect(source).toContain('v-model="profileDialogVisible"');
|
|
||||||
expect(source).toContain('class="profile-settings-dialog"');
|
|
||||||
expect(source).toContain(':close-on-click-modal="false"');
|
|
||||||
expect(source).toContain(':close-on-press-escape="false"');
|
|
||||||
expect(source).toContain('@close-request="requestProfileDialogClose"');
|
|
||||||
expect(source).toContain('@dirty-change="profileDialogDirty = $event"');
|
|
||||||
expect(source).toContain('@saved="profileDialogVisible = false"');
|
|
||||||
expect(source).toContain("profileDialogVisible.value = true");
|
|
||||||
expect(source).toContain("confirmDiscardProfileChanges");
|
|
||||||
expect(source).not.toContain('router.push("/profile")');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./Layout.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("layout system monitoring navigation", () => {
|
|
||||||
it("shows system monitoring as an admin-only peer module outside permission management", () => {
|
|
||||||
const source = readSource();
|
|
||||||
const permissionMenuStart = source.indexOf('<el-sub-menu v-if="canAccessAdminPermissions" index="permissions">');
|
|
||||||
const permissionMenuEnd = source.indexOf("</el-sub-menu>", permissionMenuStart);
|
|
||||||
const permissionMenu = source.slice(permissionMenuStart, permissionMenuEnd);
|
|
||||||
|
|
||||||
expect(source).toContain('v-if="isAdmin" index="/admin/system-monitoring"');
|
|
||||||
expect(source).toContain("{{ TEXT.menu.systemMonitoring }}");
|
|
||||||
expect(permissionMenu).not.toContain("/admin/system-monitoring");
|
|
||||||
expect(permissionMenu).not.toContain("/admin/permission-monitoring");
|
|
||||||
expect(permissionMenu).not.toContain("/admin/permissions/monitoring");
|
|
||||||
expect(source).not.toContain('index="/admin/permissions/monitoring"');
|
|
||||||
expect(source).toContain('return "/admin/system-monitoring";');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionIpLocations.vue"), "utf8");
|
|
||||||
const readMapSource = () => readFileSync(resolve(__dirname, "./chinaProvinceMap.ts"), "utf8");
|
|
||||||
const readWorldMapSource = () => readFileSync(resolve(__dirname, "./worldMapSource.ts"), "utf8");
|
|
||||||
|
|
||||||
describe("PermissionIpLocations", () => {
|
|
||||||
it("renders IP location visualization without duplicate region detail table", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("fetchIpLocations");
|
|
||||||
expect(source).toContain("unique_ip_count");
|
|
||||||
expect(source).not.toContain("来源地域明细");
|
|
||||||
expect(source).not.toContain("table-card");
|
|
||||||
expect(source).not.toContain("<el-table :data=\"items\"");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders an interactive China map and primary metric cards", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("VChart");
|
|
||||||
expect(source).toContain("sourceMapOption");
|
|
||||||
expect(source).toContain("来源分布");
|
|
||||||
expect(source).toContain("访问次数");
|
|
||||||
expect(source).toContain("访问用户数");
|
|
||||||
expect(source).toContain("来源 IP 数");
|
|
||||||
expect(source).toContain("summary");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults the access source period to 90 days", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("const days = ref(90);");
|
|
||||||
expect(source).not.toContain("const days = ref(7);");
|
|
||||||
expect(source).toContain('<el-radio-button :value="90">90天</el-radio-button>');
|
|
||||||
expect(source).toContain('90: "近 90 天"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not overlay the source distribution card with a loading mask", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain('<section class="distribution-card">');
|
|
||||||
expect(source).toContain('<el-button :loading="loading" size="small" :disabled="loading" @click="loadData">');
|
|
||||||
expect(source).not.toContain('<section v-loading="loading" class="distribution-card">');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports switching source distribution between China and global flow views without the global heat map", () => {
|
|
||||||
const source = readSource();
|
|
||||||
const worldMapSource = readWorldMapSource();
|
|
||||||
|
|
||||||
expect(source).toContain("../assets/world.json");
|
|
||||||
expect(source).toContain('registerMap("ctms-world"');
|
|
||||||
expect(source).toContain('const mapView = ref<"china" | "flow">("china");');
|
|
||||||
expect(source).toContain("mapViewOptions");
|
|
||||||
expect(source).toContain("<el-segmented v-model=\"mapView\"");
|
|
||||||
expect(source).toContain("sourceMapOption");
|
|
||||||
expect(source).toContain('label: "中国"');
|
|
||||||
expect(source).toContain('label: "全球"');
|
|
||||||
expect(source).not.toContain('label: "全球", value: "world"');
|
|
||||||
expect(source).not.toContain("worldData");
|
|
||||||
expect(source).not.toContain("buildMapOption");
|
|
||||||
expect(worldMapSource).toContain("unpkg.com/echarts@4.9.0/map/json/world.json");
|
|
||||||
expect(worldMapSource).toContain("ECharts 世界地图 GeoJSON");
|
|
||||||
expect(worldMapSource).toContain('"United States": "United States"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports a global access flow view pointing sources to the server location", () => {
|
|
||||||
const source = readSource();
|
|
||||||
const worldMapSource = readWorldMapSource();
|
|
||||||
|
|
||||||
expect(source).toContain('const mapView = ref<"china" | "flow">("china");');
|
|
||||||
expect(source).toContain('label: "全球"');
|
|
||||||
expect(source).toContain("LinesChart");
|
|
||||||
expect(source).toContain("EffectScatterChart");
|
|
||||||
expect(source).toContain("GeoComponent");
|
|
||||||
expect(source).toContain("buildFlowMapOption");
|
|
||||||
expect(source).toContain("serverLocation");
|
|
||||||
expect(source).toContain("resolveSourceCoordinate");
|
|
||||||
expect(source).toContain('coordinateSystem: "geo"');
|
|
||||||
expect(source).toContain("coords: [point.coord, serverLocation.coord]");
|
|
||||||
expect(source).toContain("服务器所在地");
|
|
||||||
expect(worldMapSource).toContain("resolveSourceCoordinate");
|
|
||||||
expect(worldMapSource).toContain('"United States": [-95.7129, 37.0902]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses a China access flow map for the China view", () => {
|
|
||||||
const source = readSource();
|
|
||||||
const worldMapSource = readWorldMapSource();
|
|
||||||
|
|
||||||
expect(source).toContain("chinaFlowSourceData");
|
|
||||||
expect(source).toContain('buildFlowMapOption(chinaFlowSourceData.value, "ctms-china", 1.08, "中国访问链路")');
|
|
||||||
expect(source).not.toContain('buildMapOption("ctms-china", provinceData.value, 1.08)');
|
|
||||||
expect(source).toContain('mapView.value === "china" ? chinaFlowSourceData.value');
|
|
||||||
expect(worldMapSource).toContain('"江苏省": [118.7633, 32.0617]');
|
|
||||||
expect(worldMapSource).toContain('"上海市": [121.4737, 31.2304]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the same source rows for the global flow map and the source ranking", () => {
|
|
||||||
const source = readSource();
|
|
||||||
const worldMapSource = readWorldMapSource();
|
|
||||||
|
|
||||||
expect(source).toContain("flowSourceData");
|
|
||||||
expect(source).toContain('mapView.value === "flow" ? flowSourceData.value : rankedSourceRows.value');
|
|
||||||
expect(source).toContain("buildFlowMapOption(flowSourceData.value)");
|
|
||||||
expect(source).not.toContain("buildFlowMapOption(worldData.value)");
|
|
||||||
expect(source).toContain('const buildFlowMapOption = (data: IpLocationStatItem[], mapName = "ctms-world"');
|
|
||||||
expect(source).toContain("coord: resolveSourceCoordinate(item)");
|
|
||||||
expect(source).toContain("name: formatLocation(point)");
|
|
||||||
expect(worldMapSource).toContain("cityCoordinates");
|
|
||||||
expect(worldMapSource).toContain('"重庆市": [106.5516, 29.563]');
|
|
||||||
expect(worldMapSource).toContain('"南京市": [118.7969, 32.0603]');
|
|
||||||
expect(worldMapSource).toContain('"Istanbul": [28.9784, 41.0082]');
|
|
||||||
expect(worldMapSource).toContain('"South Holland": [4.493, 52.0208]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps global flow lines thin and readable", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("const lineWidth = point.abnormal ? 1.1 : 0.8");
|
|
||||||
expect(source).toContain("width: lineWidth");
|
|
||||||
expect(source).toContain("opacity: point.abnormal ? 0.58 : 0.38");
|
|
||||||
expect(source).not.toContain("width: Math.min(5");
|
|
||||||
expect(source).not.toContain("Math.sqrt(point.total_count");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps domestic flow markers compact so nearby China locations stay visible", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("const isDomestic = !params?.data?.abnormal");
|
|
||||||
expect(source).toContain("const maxSize = isDomestic ? 12 : 16");
|
|
||||||
expect(source).toContain("const minSize = isDomestic ? 5 : 7");
|
|
||||||
expect(source).toContain("scale: 1.7");
|
|
||||||
expect(source).toContain("scale: 1.5");
|
|
||||||
expect(source).toContain("symbolSize: 9");
|
|
||||||
expect(source).not.toContain("scale: 3");
|
|
||||||
expect(source).not.toContain("scale: 2.4");
|
|
||||||
expect(source).not.toContain("symbolSize: 14");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows a compact flow legend and keeps the server tooltip address-only", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("source-map-wrap");
|
|
||||||
expect(source).toContain("fullscreen-map-wrap");
|
|
||||||
expect(source).toContain("flow-legend");
|
|
||||||
expect(source).toContain("map-legend");
|
|
||||||
expect(source).toContain("服务器");
|
|
||||||
expect(source).toContain("正常访问点");
|
|
||||||
expect(source).toContain("异常访问点");
|
|
||||||
expect(source).toContain('class="legend-dot server"');
|
|
||||||
expect(source).toContain('class="legend-dot normal"');
|
|
||||||
expect(source).toContain('class="legend-dot abnormal"');
|
|
||||||
expect(source).toContain('if (row.isServer) return "中国 / 上海";');
|
|
||||||
expect(source).not.toContain('if (row.isServer) return `${serverLocation.name}<br/>中国 / 上海`;');
|
|
||||||
expect(source).not.toContain("fullscreen-legend");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports fullscreen preview for the current source map", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("fullscreenVisible");
|
|
||||||
expect(source).toContain("openFullscreenPreview");
|
|
||||||
expect(source).toContain("resizeFullscreenChart");
|
|
||||||
expect(source).toContain("<el-dialog");
|
|
||||||
expect(source).toContain("fullscreen");
|
|
||||||
expect(source).toContain("destroy-on-close");
|
|
||||||
expect(source).toContain(':close-on-click-modal="false"');
|
|
||||||
expect(source).toContain('aria-label="全屏预览"');
|
|
||||||
expect(source).toContain("fullscreenMapKey");
|
|
||||||
expect(source).toContain("fullscreen-source-map");
|
|
||||||
expect(source).toContain("全屏预览");
|
|
||||||
expect(source).toContain("退出全屏");
|
|
||||||
expect(source).toContain(":option=\"sourceMapOption\"");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("raises canvas pixel ratio for sharper fullscreen map rendering", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("getDevicePixelRatio");
|
|
||||||
expect(source).toContain("chartInitOptions");
|
|
||||||
expect(source).toContain("fullscreenChartInitOptions");
|
|
||||||
expect(source).toContain(":init-options=\"chartInitOptions\"");
|
|
||||||
expect(source).toContain(":init-options=\"fullscreenChartInitOptions\"");
|
|
||||||
expect(source).toContain("Math.min(getDevicePixelRatio(), 2)");
|
|
||||||
expect(source).toContain("Math.min(getDevicePixelRatio() * 1.5, 3)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses a flexible full-page layout so the source map fills the remaining viewport", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("height: 100%");
|
|
||||||
expect(source).toContain("min-height: 0");
|
|
||||||
expect(source).toContain("flex: 1 1 auto");
|
|
||||||
expect(source).toContain("grid-template-rows: minmax(0, 1fr)");
|
|
||||||
expect(source).toContain("height: 100%");
|
|
||||||
expect(source).toContain("overflow-y: auto");
|
|
||||||
expect(source).not.toContain("height: 320px");
|
|
||||||
expect(source).not.toContain("min-height: 430px");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prevents horizontal scrolling in the source ranking panel", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain(".rank-panel");
|
|
||||||
expect(source).toContain("overflow: hidden");
|
|
||||||
expect(source).toContain("overflow-x: hidden");
|
|
||||||
expect(source).toContain("overflow-y: auto");
|
|
||||||
expect(source).toContain("box-sizing: border-box");
|
|
||||||
expect(source).toContain("grid-template-columns: 36px minmax(0, 1fr) minmax(72px, max-content)");
|
|
||||||
expect(source).toContain("max-width: 100%");
|
|
||||||
expect(source).toContain("text-overflow: ellipsis");
|
|
||||||
expect(source).toContain("text-align: right");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recreates the chart when switching map modes to avoid stale geo state", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain(':key="mapView"');
|
|
||||||
expect(source).toContain(':update-options="chartUpdateOptions"');
|
|
||||||
expect(source).toContain("const chartUpdateOptions = { notMerge: true }");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resizes the source map after data load and keeps the chart container full width", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain('ref="sourceChartRef"');
|
|
||||||
expect(source).toContain("const sourceChartRef = ref<{ resize?: () => void } | null>(null)");
|
|
||||||
expect(source).toContain("const resizeSourceCharts = async () =>");
|
|
||||||
expect(source).toContain("sourceChartRef.value?.resize?.()");
|
|
||||||
expect(source).toContain("await resizeSourceCharts()");
|
|
||||||
expect(source).toContain("defineExpose({ refresh: loadData, resize: resizeSourceCharts })");
|
|
||||||
expect(source).not.toContain(".source-map :deep(> div)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses source-analysis copy instead of IP-location tab wording", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("访问来源分析");
|
|
||||||
expect(source).toContain("来源分布");
|
|
||||||
expect(source).toContain("热点来源地");
|
|
||||||
expect(source).not.toContain("查看访问来源的地理分布");
|
|
||||||
expect(source).not.toContain("访问来源地理分布热力图");
|
|
||||||
expect(source).not.toContain("按访问次数排序");
|
|
||||||
expect(source).not.toContain("IP 属地分析");
|
|
||||||
expect(source).not.toContain("中国地图");
|
|
||||||
expect(source).not.toContain("热点属地");
|
|
||||||
expect(source).not.toContain("用户分布图");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the standard geojson.cn China map data instead of generated rectangles", () => {
|
|
||||||
const source = readSource();
|
|
||||||
const mapSource = readMapSource();
|
|
||||||
|
|
||||||
expect(source).toContain("../assets/china.json");
|
|
||||||
expect(mapSource).toContain("geojson.cn/api/china/1.6.3/china.json");
|
|
||||||
expect(mapSource).not.toContain("ProvinceBox");
|
|
||||||
expect(mapSource).not.toContain("provinceBoxes");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes heat-map visual legends and keeps map zoom interactions disabled", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).not.toContain("visualMap:");
|
|
||||||
expect(source).not.toContain("VisualMapComponent");
|
|
||||||
expect(source).not.toContain("hasMapData");
|
|
||||||
expect(source).not.toContain("maxValue");
|
|
||||||
expect(source).not.toContain('color: ["#93c5fd", "#60a5fa", "#3b82f6", "#2563eb", "#1d4ed8"]');
|
|
||||||
expect(source).not.toContain("outOfRange:");
|
|
||||||
expect(source).toContain("roam: false");
|
|
||||||
expect(source).not.toContain("scaleLimit");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses a stronger base map style so borders remain visible behind flow lines", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain('areaColor: "#e6edf8"');
|
|
||||||
expect(source).toContain('borderColor: "#aebed2"');
|
|
||||||
expect(source).toContain("borderWidth: 1");
|
|
||||||
expect(source).toContain('shadowColor: "rgba(37, 99, 235, 0.08)"');
|
|
||||||
expect(source).toContain("shadowBlur: 10");
|
|
||||||
expect(source).toContain("shadowOffsetY: 2");
|
|
||||||
expect(source).toContain('areaColor: "#dbeafe", borderColor: "#64748b", borderWidth: 1.3');
|
|
||||||
expect(source).not.toContain('areaColor: "#eef4ff"');
|
|
||||||
expect(source).not.toContain('borderColor: "#dbe7f7"');
|
|
||||||
expect(source).not.toContain("borderWidth: 0.8");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps geo hover interaction while avoiding empty white tooltip panels", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("const formatGeoTooltip = (params: any) =>");
|
|
||||||
expect(source).toContain('return name || "暂无访问数据";');
|
|
||||||
expect(source).toContain('trigger: "item"');
|
|
||||||
expect(source).toContain("confine: true");
|
|
||||||
expect(source).toContain("formatter: formatGeoTooltip");
|
|
||||||
expect(source).toContain("tooltip: {\n show: true,\n formatter: formatGeoTooltip,\n }");
|
|
||||||
expect(source).toContain("formatLineTooltip");
|
|
||||||
expect(source).toContain("formatPointTooltip");
|
|
||||||
expect(source).toContain("tooltip: {\n show: true,\n formatter: formatLineTooltip,\n }");
|
|
||||||
expect(source).toContain("tooltip: {\n show: true,\n formatter: formatPointTooltip,\n }");
|
|
||||||
expect(source).not.toContain("silent: true");
|
|
||||||
expect(source).not.toContain("tooltip: { show: false }");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses narrow metric cards without helper subtitles", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("min-height: 64px");
|
|
||||||
expect(source).toContain("padding: 10px 16px");
|
|
||||||
expect(source).toContain("right: -34px");
|
|
||||||
expect(source).toContain("bottom: -42px");
|
|
||||||
expect(source).toContain("border-radius: 18px");
|
|
||||||
expect(source).not.toContain("<small>{{ card.hint }}</small>");
|
|
||||||
expect(source).not.toContain("hint:");
|
|
||||||
expect(source).not.toContain("权限检查总量");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes the duplicated hero copy above the map", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("ip-locations-toolbar");
|
|
||||||
expect(source).not.toContain("权限监控 / IP属地");
|
|
||||||
expect(source).not.toContain("按访问日志聚合省市、用户与来源 IP");
|
|
||||||
expect(source).not.toContain("悬停省份查看访问次数、访问用户数和来源 IP 数");
|
|
||||||
expect(source).not.toContain("hero-desc");
|
|
||||||
expect(source).not.toContain("eyebrow");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("places the period filter toolbar on the right", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain(".ip-hero");
|
|
||||||
expect(source).toContain("justify-content: space-between");
|
|
||||||
expect(source).toContain("margin-left: auto");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps non-China source labels visible and lists top 10 regions", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain('row.country && row.country !== "中国" ? row.country : ""');
|
|
||||||
expect(source).toContain("row.isp");
|
|
||||||
expect(source).toContain('parts.join(" / ")');
|
|
||||||
expect(source).toContain(".slice(0, 10)");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionTemplateSelector.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("PermissionTemplateSelector", () => {
|
|
||||||
it("renders compact role permission cards without footer placeholder space", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("角色权限概览");
|
|
||||||
expect(source).toContain("align-items: start");
|
|
||||||
expect(source).toContain("padding: 12px 14px");
|
|
||||||
expect(source).not.toContain("card-footer");
|
|
||||||
expect(source).not.toContain("点击编辑权限");
|
|
||||||
expect(source).not.toContain("inactive-label");
|
|
||||||
expect(source).not.toContain("edit-hint");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads current project permissions by role key, not by role label", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain('v-for="template in sortedTemplates"');
|
|
||||||
expect(source).toContain('<span class="card-name">{{ template.name }}</span>');
|
|
||||||
expect(source).toContain("refreshKey?: number;");
|
|
||||||
expect(source).toContain("watch(() => props.refreshKey, loadTemplates);");
|
|
||||||
expect(source).not.toContain("ROLE_LABELS");
|
|
||||||
expect(source).toContain("template.category && props.currentPermissions[template.category]");
|
|
||||||
expect(source).toContain("enabledPercent(template.category)");
|
|
||||||
expect(source).toContain("countCurrentEnabled(template.category)");
|
|
||||||
expect(source).toContain("countCurrentDisabled(template.category)");
|
|
||||||
expect(source).toContain("const perms = props.currentPermissions?.[role];");
|
|
||||||
expect(source).toContain("emit('edit-role', template.category!)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sorts role cards with the shared role template order", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("useRoleTemplateMeta");
|
|
||||||
expect(source).toContain("compareRolesByTemplateOrder");
|
|
||||||
expect(source).toContain("const sortedTemplates = computed");
|
|
||||||
expect(source).toContain("compareRolesByTemplateOrder(a.category, b.category)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not keep hard-coded preset role labels or icons", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).not.toContain('QA: "QA"');
|
|
||||||
expect(source).not.toContain('CTA: "CTA"');
|
|
||||||
expect(source).not.toContain('QA: "✅"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionTrendCharts.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("PermissionTrendCharts", () => {
|
|
||||||
it("uses operation-oriented trend copy", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("系统性能趋势");
|
|
||||||
expect(source).toContain("监测事件趋势");
|
|
||||||
expect(source).toContain("响应时间趋势");
|
|
||||||
expect(source).toContain("缓存命中率");
|
|
||||||
expect(source).toContain("异常拒绝率趋势");
|
|
||||||
expect(source).toContain('name: "异常拒绝率"');
|
|
||||||
|
|
||||||
expect(source).not.toContain("数据趋势");
|
|
||||||
expect(source).not.toContain("查看系统运行监测指标的变化趋势");
|
|
||||||
expect(source).not.toContain("查看权限系统各项指标的变化趋势");
|
|
||||||
expect(source).not.toContain("检查量趋势");
|
|
||||||
expect(source).not.toContain("<h4>拒绝率趋势</h4>");
|
|
||||||
expect(source).not.toContain('name: "拒绝率"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readQuickActionsSource = () => readFileSync(resolve(__dirname, "./QuickActions.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("QuickActions project permissions", () => {
|
|
||||||
it("filters quick action entries with the same route permission contract as sidebar and router", () => {
|
|
||||||
const source = readQuickActionsSource();
|
|
||||||
|
|
||||||
expect(source).toContain("useAuthStore");
|
|
||||||
expect(source).toContain("useStudyStore");
|
|
||||||
expect(source).toContain("getProjectRoutePermission");
|
|
||||||
expect(source).toContain("hasProjectPermission");
|
|
||||||
expect(source).toContain("isSystemAdmin");
|
|
||||||
expect(source).toContain("visibleActions");
|
|
||||||
expect(source).toContain("v-for=\"item in visibleActions\"");
|
|
||||||
expect(source).not.toContain("v-for=\"item in actions\"");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readAttachmentList = () => readFileSync(resolve(__dirname, "./AttachmentList.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("AttachmentList permissions", () => {
|
|
||||||
it("does not require project member list permission to render uploaders", () => {
|
|
||||||
const source = readAttachmentList();
|
|
||||||
|
|
||||||
expect(source).not.toContain("listMembers");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches the contract-fee attachment table layout", () => {
|
|
||||||
const source = readAttachmentList();
|
|
||||||
|
|
||||||
expect(source).toContain('prop="filename" :label="TEXT.common.labels.filename" min-width="360" show-overflow-tooltip');
|
|
||||||
expect(source).toContain(':label="TEXT.common.labels.size" min-width="180"');
|
|
||||||
expect(source).toContain(':label="TEXT.common.labels.uploader" min-width="180"');
|
|
||||||
expect(source).toContain('prop="uploaded_at" :label="TEXT.common.labels.uploadedAt" min-width="180" show-overflow-tooltip');
|
|
||||||
expect(source).toContain(':label="TEXT.common.labels.actions" min-width="180"');
|
|
||||||
expect(source).not.toContain('width="160"');
|
|
||||||
expect(source).toContain('class="attachment-actions"');
|
|
||||||
expect(source).toContain("flex-wrap: nowrap;");
|
|
||||||
expect(source).toContain("gap: 4px;");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can aggregate multiple attachment entity types into one table with an attachment type column", () => {
|
|
||||||
const source = readAttachmentList();
|
|
||||||
|
|
||||||
expect(source).toContain("entityGroups?: AttachmentEntityGroup[]");
|
|
||||||
expect(source).toContain('v-if="hasEntityGroups"');
|
|
||||||
expect(source).toContain('label="附件类型"');
|
|
||||||
expect(source).toContain("scope.row.attachment_type_label");
|
|
||||||
expect(source).toContain("fetchAttachments(props.studyId, group.entityType, props.entityId)");
|
|
||||||
expect(source).toContain("attachment_type_label: group.label");
|
|
||||||
expect(source).toContain("attachment_entity_type: group.entityType");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can reload attachments when the parent refresh key changes", () => {
|
|
||||||
const source = readAttachmentList();
|
|
||||||
|
|
||||||
expect(source).toContain("refreshKey?: number");
|
|
||||||
expect(source).toContain("() => props.refreshKey");
|
|
||||||
expect(source).toContain("load();");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can hide the uploader while keeping the attachment table visible", () => {
|
|
||||||
const source = readAttachmentList();
|
|
||||||
|
|
||||||
expect(source).toContain("hideUploader?: boolean");
|
|
||||||
expect(source).toContain("canShowUploader");
|
|
||||||
expect(source).toContain("!props.hideUploader && canCreate.value");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the default attachment title row when uploader is hidden and no explicit title is provided", () => {
|
|
||||||
const source = readAttachmentList();
|
|
||||||
|
|
||||||
expect(source).toContain("const showHeader = computed(() => !!props.title || canShowUploader.value)");
|
|
||||||
expect(source).toContain('<div v-if="showHeader" class="header">');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the shared upload implementation for table-mode immediate uploads", () => {
|
|
||||||
const source = readAttachmentList();
|
|
||||||
|
|
||||||
expect(source).not.toContain("AttachmentUploader");
|
|
||||||
expect(source).toContain(':http-request="uploadImmediate"');
|
|
||||||
expect(source).toContain("const uploadImmediate = async");
|
|
||||||
expect(source).toContain("await uploadFile(group, file, props.entityId)");
|
|
||||||
expect(source).toContain("ElMessage.success(TEXT.common.messages.uploadSuccess)");
|
|
||||||
expect(source).toContain("load();");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports upload-card mode without rendering the table header", () => {
|
|
||||||
const source = readAttachmentList();
|
|
||||||
|
|
||||||
expect(source).toContain('mode?: "table" | "upload"');
|
|
||||||
expect(source).toContain("displayMode");
|
|
||||||
expect(source).toContain('v-if="displayMode === \'table\'"');
|
|
||||||
expect(source).toContain("v-else");
|
|
||||||
expect(source).toContain('class="attachment-upload-card"');
|
|
||||||
expect(source).toContain(':auto-upload="false"');
|
|
||||||
expect(source).toContain("queuePendingUpload(group.key, file)");
|
|
||||||
expect(source).toContain("点击上传文件");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows create forms to choose attachments before the entity id exists", () => {
|
|
||||||
const source = readAttachmentList();
|
|
||||||
|
|
||||||
expect(source).toContain("const pendingSnapshot = () =>");
|
|
||||||
expect(source).toContain("const uploadPending = async");
|
|
||||||
expect(source).toContain("const targetEntityId = entityId || props.entityId");
|
|
||||||
expect(source).toContain("defineExpose({");
|
|
||||||
expect(source).toContain("pendingSnapshot");
|
|
||||||
expect(source).toContain("uploadPending");
|
|
||||||
expect(source).toContain("pendingMap[group.key] = remainItems");
|
|
||||||
expect(source).toContain("throw new Error(TEXT.common.messages.uploadFailed)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fails pending upload when selected files have no saved entity id", () => {
|
|
||||||
const source = readAttachmentList();
|
|
||||||
|
|
||||||
expect(source).toContain("const hasPendingUploads = computed");
|
|
||||||
expect(source).toContain("if (!targetEntityId && hasPendingUploads.value)");
|
|
||||||
expect(source).toContain("throw new Error(TEXT.common.messages.uploadFailed)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses entity groups as upload groups when an editor has multiple attachment types", () => {
|
|
||||||
const source = readAttachmentList();
|
|
||||||
|
|
||||||
expect(source).toContain("const uploadGroups = computed");
|
|
||||||
expect(source).toContain("props.entityGroups.map((group)");
|
|
||||||
expect(source).toContain("key: group.entityType");
|
|
||||||
expect(source).toContain("entityType: group.entityType");
|
|
||||||
expect(source).toContain("uploadAttachment(props.studyId, group.entityType, targetEntityId, file");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { useRoleTemplateMeta } from "./useRoleTemplateMeta";
|
|
||||||
|
|
||||||
vi.mock("@/api/projectPermissions", () => ({
|
|
||||||
fetchPermissionTemplates: vi.fn().mockResolvedValue({
|
|
||||||
data: [
|
|
||||||
{ category: "PV", name: "PV" },
|
|
||||||
{ category: "QA", name: "QA" },
|
|
||||||
{ category: "CTA", name: "CTA" },
|
|
||||||
{ category: "CRA", name: "CRA" },
|
|
||||||
{ category: "PM", name: "PM" },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./useRoleTemplateMeta.ts"), "utf8");
|
|
||||||
|
|
||||||
describe("useRoleTemplateMeta fallback copy", () => {
|
|
||||||
it("uses QA and CTA as first-class preset role keys", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain('PM: { label: "PM"');
|
|
||||||
expect(source).toContain("项目负责人,统筹项目全局,协调进度、资源与关键决策。");
|
|
||||||
expect(source).toContain("负责各中心临床监查执行,跟进现场质量、数据和问题闭环。");
|
|
||||||
expect(source).toContain("负责合同、药品及相关项目事务管理,保障执行支持与物资协同。");
|
|
||||||
expect(source).toContain('QA: { label: "QA"');
|
|
||||||
expect(source).toContain("负责医学审核与稽查,关注质量风险、合规性和医学一致性。");
|
|
||||||
expect(source).toContain('CTA: { label: "CTA"');
|
|
||||||
expect(source).toContain("负责药物警戒相关工作,跟踪安全性事件并支持风险评估。");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pins PM first before applying the role template list order", async () => {
|
|
||||||
const { compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();
|
|
||||||
await loadRoleTemplates();
|
|
||||||
|
|
||||||
expect(["CRA", "PV", "QA", "PM", "CTA"].sort(compareRolesByTemplateOrder)).toEqual([
|
|
||||||
"PM",
|
|
||||||
"PV",
|
|
||||||
"QA",
|
|
||||||
"CTA",
|
|
||||||
"CRA",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
const readMainCss = () => readFileSync(resolve(__dirname, "./main.css"), "utf8");
|
|
||||||
|
|
||||||
describe("global table styles", () => {
|
|
||||||
it("keeps Element Plus table sizing on the root node without overriding internal layout", () => {
|
|
||||||
const css = readMainCss();
|
|
||||||
|
|
||||||
expect(css).toContain(".el-table {\n color: var(--ctms-text-regular);\n width: 100%;\n max-width: 100%;\n}");
|
|
||||||
expect(css).not.toContain(".el-table__header,\n.el-table__body,\n.el-table__footer");
|
|
||||||
expect(css).not.toContain(".el-table__inner-wrapper,\n.el-table__header-wrapper");
|
|
||||||
expect(css).not.toContain(".el-table colgroup col");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { getAttachmentPermissionKey } from "./attachmentPermissions";
|
|
||||||
|
|
||||||
describe("attachment permission mapping", () => {
|
|
||||||
it("maps attachment entity types to module attachment permissions", () => {
|
|
||||||
expect(getAttachmentPermissionKey("contract_fee_contract", "create")).toBe("fees_contracts:create");
|
|
||||||
expect(getAttachmentPermissionKey("contract_fee_contract", "read")).toBe("fees_contracts:read");
|
|
||||||
expect(getAttachmentPermissionKey("contract_fee_contract", "delete")).toBe("fees_contracts_attachments:delete");
|
|
||||||
expect(getAttachmentPermissionKey("startup_feasibility", "create")).toBe("startup_initiation:create");
|
|
||||||
expect(getAttachmentPermissionKey("startup_feasibility", "read")).toBe("startup_initiation:read");
|
|
||||||
expect(getAttachmentPermissionKey("startup_feasibility", "delete")).toBe("startup_initiation_attachments:delete");
|
|
||||||
expect(getAttachmentPermissionKey("startup_ethics", "create")).toBe("startup_ethics:create");
|
|
||||||
expect(getAttachmentPermissionKey("startup_ethics", "read")).toBe("startup_ethics:read");
|
|
||||||
expect(getAttachmentPermissionKey("startup_ethics", "delete")).toBe("startup_ethics_attachments:delete");
|
|
||||||
expect(getAttachmentPermissionKey("startup_kickoff_ppt", "create")).toBe("startup_auth:create");
|
|
||||||
expect(getAttachmentPermissionKey("startup_kickoff_ppt", "read")).toBe("startup_auth:read");
|
|
||||||
expect(getAttachmentPermissionKey("training_authorization", "delete")).toBe("startup_auth_attachments:delete");
|
|
||||||
expect(getAttachmentPermissionKey("drug_shipment", "create")).toBe("drug_shipments:create");
|
|
||||||
expect(getAttachmentPermissionKey("drug_shipment", "read")).toBe("drug_shipments:read");
|
|
||||||
expect(getAttachmentPermissionKey("drug_shipment", "delete")).toBe("drug_shipments_attachments:delete");
|
|
||||||
expect(getAttachmentPermissionKey("material_equipment", "create")).toBe("material_equipments:update");
|
|
||||||
expect(getAttachmentPermissionKey("material_equipment", "read")).toBe("material_equipments:read");
|
|
||||||
expect(getAttachmentPermissionKey("material_equipment", "delete")).toBe("material_equipments_attachments:delete");
|
|
||||||
expect(getAttachmentPermissionKey("precaution", "create")).toBe("precautions:create");
|
|
||||||
expect(getAttachmentPermissionKey("precaution", "read")).toBe("precautions:read");
|
|
||||||
expect(getAttachmentPermissionKey("precaution", "delete")).toBe("precautions_attachments:delete");
|
|
||||||
expect(getAttachmentPermissionKey("faq_replies", "create")).toBe("faq_reply:create");
|
|
||||||
expect(getAttachmentPermissionKey("faq_replies", "read")).toBe("faq:read");
|
|
||||||
expect(getAttachmentPermissionKey("faq_replies", "delete")).toBe("faq_attachments:delete");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not return generic attachments permissions", () => {
|
|
||||||
expect(getAttachmentPermissionKey("unknown", "read")).toBeNull();
|
|
||||||
expect(getAttachmentPermissionKey("precaution", "read")).not.toBe("attachments:read");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { reactive } from "vue";
|
|
||||||
import { ElMessage } from "element-plus";
|
|
||||||
import { useDrawerDirtyGuard } from "./drawerDirtyGuard";
|
|
||||||
|
|
||||||
vi.mock("element-plus", () => ({
|
|
||||||
ElMessage: {
|
|
||||||
warning: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("useDrawerDirtyGuard", () => {
|
|
||||||
it("allows drawer close when the form has no changes", () => {
|
|
||||||
const form = reactive({ name: "设备A", status: "启用" });
|
|
||||||
const guard = useDrawerDirtyGuard(() => form);
|
|
||||||
const done = vi.fn();
|
|
||||||
|
|
||||||
guard.syncBaseline();
|
|
||||||
guard.beforeClose(done);
|
|
||||||
|
|
||||||
expect(done).toHaveBeenCalledTimes(1);
|
|
||||||
expect(ElMessage.warning).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks drawer close when the form has unsaved changes", () => {
|
|
||||||
const form = reactive({ name: "设备A", status: "启用" });
|
|
||||||
const guard = useDrawerDirtyGuard(() => form);
|
|
||||||
const done = vi.fn();
|
|
||||||
|
|
||||||
guard.syncBaseline();
|
|
||||||
form.name = "设备B";
|
|
||||||
guard.beforeClose(done);
|
|
||||||
|
|
||||||
expect(done).not.toHaveBeenCalled();
|
|
||||||
expect(ElMessage.warning).toHaveBeenCalledWith("请先保存或取消编辑");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type { ProjectPublishSnapshot } from "../types/setupConfig";
|
|
||||||
import { buildProjectDiffRows } from "./setupPublishWorkflow";
|
|
||||||
import { serializeDiffValue } from "./setupDiffRows";
|
|
||||||
|
|
||||||
const emptyProjectSnapshot = (): ProjectPublishSnapshot => ({
|
|
||||||
code: "",
|
|
||||||
name: "",
|
|
||||||
project_full_name: "",
|
|
||||||
sponsor: "",
|
|
||||||
protocol_no: "",
|
|
||||||
lead_unit: "",
|
|
||||||
principal_investigator: "",
|
|
||||||
main_pm: "",
|
|
||||||
research_analysis: "",
|
|
||||||
research_product: "",
|
|
||||||
control_product: "",
|
|
||||||
indication: "",
|
|
||||||
research_population: "",
|
|
||||||
research_design: "",
|
|
||||||
plan_start_date: "",
|
|
||||||
plan_end_date: "",
|
|
||||||
planned_site_count: null,
|
|
||||||
planned_enrollment_count: null,
|
|
||||||
status: "",
|
|
||||||
visit_schedule: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setup publish workflow project diff rows", () => {
|
|
||||||
it("shows visit schedule changes under the summary module", () => {
|
|
||||||
const current = emptyProjectSnapshot();
|
|
||||||
current.visit_schedule = [
|
|
||||||
{
|
|
||||||
visit_code: "V1",
|
|
||||||
baseline_offset_days: 0,
|
|
||||||
window_before_days: 0,
|
|
||||||
window_after_days: 3,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const rows = buildProjectDiffRows(current, emptyProjectSnapshot(), serializeDiffValue);
|
|
||||||
|
|
||||||
expect(rows).toEqual([
|
|
||||||
{
|
|
||||||
moduleLabel: "方案摘要",
|
|
||||||
path: "访视计划",
|
|
||||||
changeType: "修改",
|
|
||||||
localValue: "已配置列表(1项)",
|
|
||||||
serverValue: "已配置列表(0项)",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not treat equivalent visit schedule arrays as changed", () => {
|
|
||||||
const current = emptyProjectSnapshot();
|
|
||||||
const base = emptyProjectSnapshot();
|
|
||||||
current.visit_schedule = [
|
|
||||||
{
|
|
||||||
visit_code: "V1",
|
|
||||||
baseline_offset_days: 0,
|
|
||||||
window_before_days: 0,
|
|
||||||
window_after_days: 3,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
base.visit_schedule = [
|
|
||||||
{
|
|
||||||
visit_code: "V1",
|
|
||||||
baseline_offset_days: 0,
|
|
||||||
window_before_days: 0,
|
|
||||||
window_after_days: 3,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(buildProjectDiffRows(current, base, serializeDiffValue)).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const read = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
|
|
||||||
|
|
||||||
describe("FAQ project permissions", () => {
|
|
||||||
it("maps FAQ, category, and reply actions to backend operation permissions", () => {
|
|
||||||
const source = read("../utils/permission.ts");
|
|
||||||
|
|
||||||
expect(source).toContain('"faq.update": "faq:update"');
|
|
||||||
expect(source).toContain('"faq.delete": "faq:delete"');
|
|
||||||
expect(source).toContain('"faq.category.read": "faq_category:read"');
|
|
||||||
expect(source).toContain('"faq.category.create": "faq_category:create"');
|
|
||||||
expect(source).toContain('"faq.category.update": "faq_category:update"');
|
|
||||||
expect(source).toContain('"faq.category.delete": "faq_category:delete"');
|
|
||||||
expect(source).toContain('"faq.reply.delete": "faq_reply:delete"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes item update and delete permissions separately to the FAQ list", () => {
|
|
||||||
const source = read("./Faq.vue");
|
|
||||||
|
|
||||||
expect(source).toContain('const canReadCategories = computed(() => can("faq.category.read"))');
|
|
||||||
expect(source).toContain('const canUpdateFaq = computed(() => can("faq.update"))');
|
|
||||||
expect(source).toContain('const canDeleteFaq = computed(() => can("faq.delete"))');
|
|
||||||
expect(source).toContain('v-if="canReadCategories"');
|
|
||||||
expect(source).toContain('if (!canReadCategories.value)');
|
|
||||||
expect(source).toContain(':can-update="canUpdateFaq"');
|
|
||||||
expect(source).toContain(':can-delete="canDeleteFaq"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not expose FAQ item delete through creator ownership when backend requires delete permission", () => {
|
|
||||||
const source = read("../components/FaqList.vue");
|
|
||||||
|
|
||||||
expect(source).toContain("canDelete: boolean");
|
|
||||||
expect(source).toContain('v-if="canDelete"');
|
|
||||||
expect(source).not.toContain("row.created_by === auth.user?.id");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens FAQ detail from any row cell while preserving action button click isolation", () => {
|
|
||||||
const source = read("../components/FaqList.vue");
|
|
||||||
|
|
||||||
expect(source).toContain('@row-click="onRowClick"');
|
|
||||||
expect(source).toContain("const onRowClick = (row: FaqItem) =>");
|
|
||||||
expect(source).not.toContain('column?.property !== "question"');
|
|
||||||
expect(source).toContain('@click.stop="remove(scope.row)"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("splits FAQ category create update and delete controls by backend operation permissions", () => {
|
|
||||||
const source = read("../components/FaqCategoryPanel.vue");
|
|
||||||
|
|
||||||
expect(source).toContain('const canCreateCategory = computed(() => can("faq.category.create"))');
|
|
||||||
expect(source).toContain('const canUpdateCategory = computed(() => can("faq.category.update"))');
|
|
||||||
expect(source).toContain('const canDeleteCategory = computed(() => can("faq.category.delete"))');
|
|
||||||
expect(source).toContain(':can-create="canCreateCategory"');
|
|
||||||
expect(source).toContain(':can-update="canUpdateCategory"');
|
|
||||||
expect(source).toContain(':can-delete="canDeleteCategory"');
|
|
||||||
expect(source).not.toContain("const canDelete = computed(() => isAdmin.value)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses FAQ update and reply delete permissions for detail actions that hit protected endpoints", () => {
|
|
||||||
const source = read("./FaqDetail.vue");
|
|
||||||
|
|
||||||
expect(source).toContain('return can("faq.update")');
|
|
||||||
expect(source).toContain('return can("faq.reply.delete")');
|
|
||||||
expect(source).toContain('const canReadCategories = computed(() => can("faq.category.read"))');
|
|
||||||
expect(source).toContain('const canReadMembers = computed(() => can("project.members.list"))');
|
|
||||||
expect(source).toContain("if (canReadCategories.value)");
|
|
||||||
expect(source).toContain("if (!canReadMembers.value)");
|
|
||||||
expect(source).not.toContain("item.value.created_by === auth.user?.id");
|
|
||||||
expect(source).not.toContain("reply.created_by === auth.user?.id");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -88,4 +88,11 @@ describe("Login protocol agreement", () => {
|
|||||||
expect(source).toContain("已安全退出");
|
expect(source).toContain("已安全退出");
|
||||||
expect(source).toContain(".logout-notice");
|
expect(source).toContain(".logout-notice");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the login card wide enough on desktop while remaining responsive", () => {
|
||||||
|
const source = readLoginView();
|
||||||
|
|
||||||
|
expect(source).toContain("width: clamp(480px, 34vw, 560px);");
|
||||||
|
expect(source).toContain("max-width: calc(100vw - 40px);");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -305,9 +305,8 @@ const onSubmit = async () => {
|
|||||||
.login-container {
|
.login-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
width: 100%;
|
width: clamp(480px, 34vw, 560px);
|
||||||
max-width: 440px;
|
max-width: calc(100vw - 40px);
|
||||||
padding: 0 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readProfileView = () => readFileSync(resolve(__dirname, "./ProfileSettings.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("profile settings autocomplete", () => {
|
|
||||||
it("disables browser autofill for profile name, department, and current password", () => {
|
|
||||||
const source = readProfileView();
|
|
||||||
|
|
||||||
expect(source).toContain('<el-form ref="formRef" :model="form" :rules="rules" label-width="112px" class="form" autocomplete="off">');
|
|
||||||
expect(source).toContain('autocomplete="off"');
|
|
||||||
expect(source).toContain('autocomplete="new-password"');
|
|
||||||
expect(source).toContain('name="ctms-profile-current-password"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("profile settings layout", () => {
|
|
||||||
it("uses a settings-window layout, grouped sections, and aligned actions", () => {
|
|
||||||
const source = readProfileView();
|
|
||||||
|
|
||||||
expect(source).not.toContain("<el-card");
|
|
||||||
expect(source).toContain('class="profile-layout"');
|
|
||||||
expect(source).toContain('class="avatar-panel"');
|
|
||||||
expect(source).toContain('aria-label="关闭个人中心"');
|
|
||||||
expect(source).toContain("@click=\"emit('close-request')\"");
|
|
||||||
expect(source).toContain('saved: []');
|
|
||||||
expect(source).toContain('emit("saved")');
|
|
||||||
expect(source).toContain('class="form-section"');
|
|
||||||
expect(source).toContain('class="form-section form-section--password"');
|
|
||||||
expect(source).toContain("grid-template-columns: 260px minmax(0, 1fr);");
|
|
||||||
expect(source).toContain("min-height: 620px;");
|
|
||||||
expect(source).toContain("padding-left: 112px;");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("profile avatar upload", () => {
|
|
||||||
it("limits avatar uploads to supported image MIME types", () => {
|
|
||||||
const source = readProfileView();
|
|
||||||
|
|
||||||
expect(source).toContain('accept="image/png,image/jpeg,image/gif,image/webp"');
|
|
||||||
expect(source).toContain(':before-upload="beforeAvatarUpload"');
|
|
||||||
expect(source).toContain('const avatarAcceptedTypes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])');
|
|
||||||
expect(source).toContain("avatarAcceptedTypes.has(file.type)");
|
|
||||||
expect(source).toContain("TEXT.modules.profile.avatarTypeInvalid");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readRegisterView = () => readFileSync(resolve(__dirname, "./Register.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("Register protocol agreement", () => {
|
|
||||||
it("opens readable service terms and privacy policy dialogs from the agreement text", () => {
|
|
||||||
const source = readRegisterView();
|
|
||||||
|
|
||||||
expect(source).toContain('@click.stop.prevent="openProtocolDialog(\'terms\')"');
|
|
||||||
expect(source).toContain('@click.stop.prevent="openProtocolDialog(\'privacy\')"');
|
|
||||||
expect(source).toContain('v-model="protocolDialogVisible"');
|
|
||||||
expect(source).toContain("activeProtocolTitle");
|
|
||||||
expect(source).toContain("activeProtocolSections");
|
|
||||||
expect(source).toContain("closeProtocolDialog");
|
|
||||||
expect(source).toContain("serviceTermsSections");
|
|
||||||
expect(source).toContain("privacyPolicySections");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows an in-page pending admin approval notice after successful registration", () => {
|
|
||||||
const source = readRegisterView();
|
|
||||||
const successFlagIndex = source.indexOf("registrationSubmitted.value = true");
|
|
||||||
const resetIndex = source.indexOf("Object.assign(form");
|
|
||||||
|
|
||||||
expect(source).toContain('v-if="registrationSubmitted"');
|
|
||||||
expect(source).toContain("注册申请已提交");
|
|
||||||
expect(source).toContain("待管理员审核通过后方可登录");
|
|
||||||
expect(source).toContain("管理员审核通过前,请勿重复提交注册申请");
|
|
||||||
expect(source).toContain("registrationSubmitted");
|
|
||||||
expect(source).not.toContain("approval-login-link");
|
|
||||||
expect(successFlagIndex).toBeGreaterThan(-1);
|
|
||||||
expect(resetIndex).toBeGreaterThan(-1);
|
|
||||||
expect(successFlagIndex).toBeLessThan(resetIndex);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./StudyHome.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("StudyHome role labels", () => {
|
|
||||||
it("uses permission template names for the current project role label", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("useRoleTemplateMeta");
|
|
||||||
expect(source).toContain("const { roleLabel: displayRoleLabel, loadRoleTemplates } = useRoleTemplateMeta();");
|
|
||||||
expect(source).toContain("const roleLabel = computed(() => displayRoleLabel(projectRole.value));");
|
|
||||||
expect(source).toContain("loadRoleTemplates();");
|
|
||||||
expect(source).not.toContain("displayEnum(TEXT.enums.userRole, projectRole.value)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gates the finance KPI by the contract fee read permission", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain('v-if="canReadFinanceContracts"');
|
|
||||||
expect(source).toContain('const canReadFinanceContracts = computed');
|
|
||||||
expect(source).toContain("isApiPermissionAllowed");
|
|
||||||
expect(source).toContain('study.currentPermissions?.[role]?.["fees_contracts:read"]');
|
|
||||||
expect(source).toContain('canReadFinanceContracts.value ? fetchFinanceSummary(studyId) : Promise.resolve(null)');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readProjectMembers = () => readFileSync(resolve(__dirname, "./ProjectMembers.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("ProjectMembers user directory access", () => {
|
|
||||||
it("does not load the admin-only global user directory", () => {
|
|
||||||
const source = readProjectMembers();
|
|
||||||
|
|
||||||
expect(source).not.toContain("fetchUsers");
|
|
||||||
expect(source).not.toContain("../../api/users");
|
|
||||||
expect(source).toContain('const canListMembers = computed(() => permission.can("project.members.list"));');
|
|
||||||
expect(source).toContain('const canListMemberCandidates = computed(() => permission.can("project.members.candidates"));');
|
|
||||||
expect(source).toContain('const canCreateMember = computed(() => permission.can("project.members.create"));');
|
|
||||||
expect(source).toContain('const canUpdateMember = computed(() => permission.can("project.members.update"));');
|
|
||||||
expect(source).toContain('const canDeleteMember = computed(() => permission.can("project.members.delete"));');
|
|
||||||
expect(source).toContain("if (!canListMembers.value");
|
|
||||||
expect(source).toContain("if (!canListMemberCandidates.value");
|
|
||||||
expect(source).toContain("return;");
|
|
||||||
expect(source).toContain('v-if="canAddMember"');
|
|
||||||
expect(source).toContain("listMemberCandidates(projectId.value, { limit: 500 })");
|
|
||||||
expect(source).not.toContain("project.members.manage");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the member API embedded user data when the global directory is unavailable", () => {
|
|
||||||
const source = readProjectMembers();
|
|
||||||
|
|
||||||
expect(source).toContain("users.value = members.value");
|
|
||||||
expect(source).toContain(".map((member) => member.user)");
|
|
||||||
expect(source).toContain("users.value.find((u) => u.id === m.user_id) || m.user");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prevents project managers from editing themselves or assigning higher roles", () => {
|
|
||||||
const source = readProjectMembers();
|
|
||||||
|
|
||||||
expect(source).toContain("const roleRank: Record<string, number>");
|
|
||||||
expect(source).toContain("if (row.user_id === auth.user?.id) return false;");
|
|
||||||
expect(source).toContain("if (row.user?.is_admin) return false;");
|
|
||||||
expect(source).toContain("const canAssignRole = (role: string)");
|
|
||||||
expect(source).toContain(":disabled=\"!canAssignRole(role.value)\"");
|
|
||||||
expect(source).toContain(':disabled="!canEditMember(scope.row)');
|
|
||||||
expect(source).toContain(':disabled="!canDeleteProjectMember(scope.row)"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses permission template names for project role display options", () => {
|
|
||||||
const source = readProjectMembers();
|
|
||||||
|
|
||||||
expect(source).toContain("useRoleTemplateMeta");
|
|
||||||
expect(source).toContain("const { roleLabel, roleOptionsFor, loadRoleTemplates } = useRoleTemplateMeta();");
|
|
||||||
expect(source).toContain("const roleOptions = computed(() => roleOptionsFor(ROLE_KEYS));");
|
|
||||||
expect(source).toContain("{{ roleLabel(scope.row.role_in_study) }}");
|
|
||||||
expect(source).not.toContain("displayEnum(TEXT.enums.userRole, scope.row.role_in_study)");
|
|
||||||
expect(source).not.toContain(':label="TEXT.enums.userRole.PM"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers QA and CTA as project member role presets", () => {
|
|
||||||
const source = readProjectMembers();
|
|
||||||
|
|
||||||
expect(source).toContain('"QA"');
|
|
||||||
expect(source).toContain('"CTA"');
|
|
||||||
expect(source).toContain("QA: 60");
|
|
||||||
expect(source).toContain("CTA: 40");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSiteForm = () => readFileSync(resolve(__dirname, "./SiteForm.vue"), "utf8");
|
|
||||||
const readSiteTypes = () => readFileSync(resolve(__dirname, "../../types/api.ts"), "utf8");
|
|
||||||
|
|
||||||
describe("Admin site form phone field", () => {
|
|
||||||
it("hydrates and submits the phone field", () => {
|
|
||||||
const formSource = readSiteForm();
|
|
||||||
const typeSource = readSiteTypes();
|
|
||||||
|
|
||||||
expect(formSource).toContain("form.phone = props.site.phone || (props.site as any)?.contact_phone || \"\"");
|
|
||||||
expect(formSource).toContain("phone: form.phone");
|
|
||||||
expect(formSource).toContain('v-model="form.phone"');
|
|
||||||
expect(typeSource).toContain("phone?: string | null;");
|
|
||||||
expect(typeSource).toContain("contact_phone?: string | null;");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Admin site PM user loading", () => {
|
|
||||||
it("uses project member embedded user data instead of the global user directory", () => {
|
|
||||||
const sitesSource = readFileSync(resolve(__dirname, "./Sites.vue"), "utf8");
|
|
||||||
const formSource = readSiteForm();
|
|
||||||
|
|
||||||
expect(sitesSource).not.toContain("../../api/users");
|
|
||||||
expect(sitesSource).not.toContain("fetchUsers");
|
|
||||||
expect(sitesSource).toContain(".map((member) => member.user)");
|
|
||||||
expect(formSource).toContain("m.user?.full_name");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./SystemMonitoringPage.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("SystemMonitoringPage", () => {
|
|
||||||
it("uses a fixed viewport shell so monitoring tabs manage their own scrolling", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("display: flex");
|
|
||||||
expect(source).toContain("height: calc(100dvh - 48px)");
|
|
||||||
expect(source).toContain("min-height: 0");
|
|
||||||
expect(source).toContain("overflow: hidden");
|
|
||||||
expect(source).not.toContain("min-height: calc(100dvh - 52px)");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readResetForm = () => readFileSync(resolve(__dirname, "./UserResetPassword.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("Admin reset password autofill", () => {
|
|
||||||
it("disables browser autofill for confirm username and manual password", () => {
|
|
||||||
const source = readResetForm();
|
|
||||||
|
|
||||||
expect(source).toContain('autocomplete="off" name="ctms-reset-confirm-username"');
|
|
||||||
expect(source).toContain('autocomplete="new-password"');
|
|
||||||
expect(source).toContain('name="ctms-reset-temp-password"');
|
|
||||||
expect(source).toContain('name="ctms-reset-manual-password"');
|
|
||||||
expect(source).toContain(":prop=\"form.mode === 'auto' ? 'tempPassword' : 'manualPassword'\"");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readUsersView = () => readFileSync(resolve(__dirname, "./Users.vue"), "utf8");
|
|
||||||
const readUserForm = () => readFileSync(resolve(__dirname, "./UserForm.vue"), "utf8");
|
|
||||||
const readLocale = () => readFileSync(resolve(__dirname, "../../locales/zh-CN.ts"), "utf8");
|
|
||||||
|
|
||||||
describe("Admin users table labels", () => {
|
|
||||||
it("shows clinical_department as 科室 in the account management table", () => {
|
|
||||||
const viewSource = readUsersView();
|
|
||||||
const localeSource = readLocale();
|
|
||||||
|
|
||||||
expect(viewSource).toContain(':label="TEXT.modules.adminUsers.clinicalDepartmentLabel"');
|
|
||||||
expect(viewSource).not.toContain(':label="TEXT.common.fields.clinicalDepartment"');
|
|
||||||
expect(localeSource).toContain('clinicalDepartmentLabel: "科室"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Admin users table layout", () => {
|
|
||||||
it("uses a wider name column, even remaining columns, and compact icon actions", () => {
|
|
||||||
const viewSource = readUsersView();
|
|
||||||
|
|
||||||
expect(viewSource).toContain('table-layout="fixed"');
|
|
||||||
expect(viewSource).toContain(':label="TEXT.common.fields.name" width="360"');
|
|
||||||
expect(viewSource).not.toContain('width="20%"');
|
|
||||||
expect(viewSource).not.toContain('width="180"');
|
|
||||||
expect(viewSource).not.toContain('width="160"');
|
|
||||||
expect(viewSource).not.toContain('width="100"');
|
|
||||||
expect(viewSource).not.toContain('fixed="right"');
|
|
||||||
expect(viewSource).toContain('class="action-btn"');
|
|
||||||
expect(viewSource).toContain("width: 28px;");
|
|
||||||
expect(viewSource).toContain("gap: 4px;");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows names without avatar icons in the account management table", () => {
|
|
||||||
const viewSource = readUsersView();
|
|
||||||
|
|
||||||
expect(viewSource).not.toContain("user-avatar");
|
|
||||||
expect(viewSource).not.toContain("getInitials(scope.row.full_name)");
|
|
||||||
expect(viewSource).not.toContain("linear-gradient(135deg, #3f8f6b, #2d7a5a)");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Admin users summary header", () => {
|
|
||||||
it("keeps the management summary header compact", () => {
|
|
||||||
const viewSource = readUsersView();
|
|
||||||
|
|
||||||
expect(viewSource).toContain("padding: 10px 16px;");
|
|
||||||
expect(viewSource).toContain("padding: 9px 12px;");
|
|
||||||
expect(viewSource).toContain("width: 32px;");
|
|
||||||
expect(viewSource).toContain("height: 32px;");
|
|
||||||
expect(viewSource).toContain("font-size: 20px;");
|
|
||||||
expect(viewSource).toContain("font-size: 11px;");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Admin users filters", () => {
|
|
||||||
it("applies keyword automatically and status filters from the first page", () => {
|
|
||||||
const viewSource = readUsersView();
|
|
||||||
|
|
||||||
expect(viewSource).toContain("@keyup.enter=\"applyFilters\"");
|
|
||||||
expect(viewSource).toContain("@change=\"applyFilters\"");
|
|
||||||
expect(viewSource).not.toContain("@click=\"applyFilters\"");
|
|
||||||
expect(viewSource).not.toContain(":icon=\"Refresh\"");
|
|
||||||
expect(viewSource).not.toContain("Refresh } from");
|
|
||||||
expect(viewSource).toContain("watch(searchKeyword, () => {");
|
|
||||||
expect(viewSource).toContain("scheduleKeywordSearch();");
|
|
||||||
expect(viewSource).toContain("keywordSearchTimer = setTimeout(() => {");
|
|
||||||
expect(viewSource).toContain("}, 300);");
|
|
||||||
expect(viewSource).toContain("page.value = 1");
|
|
||||||
expect(viewSource).toContain("keyword: searchKeyword.value || undefined");
|
|
||||||
expect(viewSource).toContain("status: statusFilter.value || undefined");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Admin user form autocomplete", () => {
|
|
||||||
it("prevents browser credential autofill when creating a user", () => {
|
|
||||||
const formSource = readUserForm();
|
|
||||||
|
|
||||||
expect(formSource).toContain('autocomplete="off"');
|
|
||||||
expect(formSource).toContain('name="ctms-create-initial-password"');
|
|
||||||
expect(formSource).toContain('autocomplete="new-password"');
|
|
||||||
expect(formSource).not.toContain("current_password");
|
|
||||||
expect(formSource).not.toContain("confirmPassword");
|
|
||||||
expect(formSource).not.toContain("TEXT.modules.profile.currentPassword");
|
|
||||||
expect(formSource).not.toContain("TEXT.modules.profile.confirmPassword");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Admin user create form password", () => {
|
|
||||||
it("requires an initial password before creating an account", () => {
|
|
||||||
const formSource = readUserForm();
|
|
||||||
|
|
||||||
expect(formSource).toContain('v-if="!user" :label="TEXT.modules.adminUsers.passwordInitial" prop="password"');
|
|
||||||
expect(formSource).toContain("TEXT.modules.adminUsers.passwordInitialRequired");
|
|
||||||
expect(formSource).toContain("/^(?=.*[A-Za-z])(?=.*\\d).{8,}$/");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Admin user edit form defaults", () => {
|
|
||||||
it("hydrates the selected user immediately when the dialog is mounted open", () => {
|
|
||||||
const formSource = readUserForm();
|
|
||||||
|
|
||||||
expect(formSource).toContain("immediate: true");
|
|
||||||
expect(formSource).not.toContain("current_password");
|
|
||||||
expect(formSource).not.toContain("confirmPassword");
|
|
||||||
expect(formSource).not.toContain("TEXT.modules.profile.currentPassword");
|
|
||||||
expect(formSource).not.toContain("TEXT.modules.profile.confirmPassword");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
const readView = (path: string) => readFileSync(resolve(__dirname, path), "utf8");
|
|
||||||
|
|
||||||
describe("detail breadcrumb contexts", () => {
|
|
||||||
it("uses contract numbers for contract fee detail breadcrumbs", () => {
|
|
||||||
const source = readView("./fees/ContractFeeDetail.vue");
|
|
||||||
|
|
||||||
expect(source).toContain("study.setViewContext({");
|
|
||||||
expect(source).toContain("siteName");
|
|
||||||
expect(source).toContain("pageTitle: detail.contract_no || TEXT.menu.feeContracts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses shipment tracking or batch numbers for drug shipment detail breadcrumbs", () => {
|
|
||||||
const source = readView("./drug/ShipmentDetail.vue");
|
|
||||||
|
|
||||||
expect(source).toContain("study.setViewContext({");
|
|
||||||
expect(source).toContain("siteName: detail.site_name || TEXT.common.fallback");
|
|
||||||
expect(source).toContain("pageTitle: detail.tracking_no || detail.batch_no || TEXT.modules.drugShipments.detailTitle");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses equipment names for material equipment detail breadcrumbs", () => {
|
|
||||||
const source = readView("./materials/MaterialEquipmentDetail.vue");
|
|
||||||
|
|
||||||
expect(source).toContain("study.setViewContext({");
|
|
||||||
expect(source).toContain("pageTitle: detail.name || TEXT.modules.materialEquipment.detailTitle");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses subject numbers and centers for subject detail breadcrumbs", () => {
|
|
||||||
const source = readView("./subjects/SubjectDetail.vue");
|
|
||||||
|
|
||||||
expect(source).toContain("study.setViewContext({");
|
|
||||||
expect(source).toContain("siteName: siteMap.value[detail.site_id] || TEXT.common.fallback");
|
|
||||||
expect(source).toContain("pageTitle: detail.subject_no || TEXT.modules.subjectManagement.screeningNo");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses startup record identifiers for startup detail breadcrumbs", () => {
|
|
||||||
const feasibility = readView("./startup/FeasibilityDetail.vue");
|
|
||||||
const ethics = readView("./startup/EthicsDetail.vue");
|
|
||||||
const kickoff = readView("./startup/KickoffDetail.vue");
|
|
||||||
const training = readView("./startup/TrainingDetail.vue");
|
|
||||||
|
|
||||||
expect(feasibility).toContain("pageTitle: detail.project_no ||");
|
|
||||||
expect(ethics).toContain("pageTitle: detail.approval_no ||");
|
|
||||||
expect(kickoff).toContain("pageTitle: siteInfo.name ? `${siteInfo.name} ${TEXT.modules.startupMeetingAuth.kickoffTab}`");
|
|
||||||
expect(training).toContain("pageTitle: detail.name || TEXT.modules.startupMeetingAuth.trainingDetailTitle");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses knowledge item titles for knowledge detail breadcrumbs", () => {
|
|
||||||
const precaution = readView("./knowledge/PrecautionDetail.vue");
|
|
||||||
const faq = readView("./FaqDetail.vue");
|
|
||||||
|
|
||||||
expect(precaution).toContain("pageTitle: detail.title || TEXT.modules.knowledgeNotes.detailTitle");
|
|
||||||
expect(faq).toContain("const questionTitle = String(faqData.question || \"\").trim()");
|
|
||||||
expect(faq).toContain("pageTitle: questionTitle ? questionTitle.slice(0, 24) : TEXT.modules.knowledgeMedicalConsult.detailTitle");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses project names for admin project detail breadcrumbs", () => {
|
|
||||||
const source = readView("./admin/ProjectDetail.vue");
|
|
||||||
|
|
||||||
expect(source).toContain("studyStore.setViewContext({");
|
|
||||||
expect(source).toContain("pageTitle: project.value.name || TEXT.modules.adminProjects.detailTitle");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
const readView = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
|
|
||||||
|
|
||||||
const detailViews = [
|
|
||||||
"./fees/ContractFeeDetail.vue",
|
|
||||||
"./drug/ShipmentDetail.vue",
|
|
||||||
"./materials/MaterialEquipmentDetail.vue",
|
|
||||||
"./documents/DocumentDetail.vue",
|
|
||||||
"./subjects/SubjectDetail.vue",
|
|
||||||
"./knowledge/PrecautionDetail.vue",
|
|
||||||
"./startup/FeasibilityDetail.vue",
|
|
||||||
"./startup/EthicsDetail.vue",
|
|
||||||
"./startup/KickoffDetail.vue",
|
|
||||||
"./startup/TrainingDetail.vue",
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("detail page navigation", () => {
|
|
||||||
it("uses header breadcrumbs instead of page-local back actions", () => {
|
|
||||||
for (const viewPath of detailViews) {
|
|
||||||
const source = readView(viewPath);
|
|
||||||
|
|
||||||
expect(source, viewPath).not.toContain("TEXT.common.actions.back");
|
|
||||||
expect(source, viewPath).not.toContain('@click="goBack"');
|
|
||||||
expect(source, viewPath).not.toContain("const goBack =");
|
|
||||||
expect(source, viewPath).not.toContain("function goBack");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./DocumentList.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("DocumentList project permissions", () => {
|
|
||||||
it("hides document create/update/delete controls and guards direct mutation calls by backend permissions", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain('v-if="canCreate"');
|
|
||||||
expect(source).toContain('v-if="canUpdate"');
|
|
||||||
expect(source).toContain('v-if="canDelete"');
|
|
||||||
expect(source).toContain("if (!canCreate.value)");
|
|
||||||
expect(source).toContain("if (!canUpdate.value)");
|
|
||||||
expect(source).toContain("if (!canDelete.value)");
|
|
||||||
expect(source).toContain("if (isInactiveSite(row.site_id))");
|
|
||||||
expect(source).toContain('@click.stop="openEdit(row)"');
|
|
||||||
expect(source).not.toContain(':disabled="!canCreate"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./DrugShipmentEditorDrawer.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("DrugShipmentEditorDrawer attachments", () => {
|
|
||||||
it("uses a compact upload card instead of the full attachment table", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("AttachmentList");
|
|
||||||
expect(source).toContain('ref="attachmentPanelRef"');
|
|
||||||
expect(source).toContain('entity-type="drug_shipment"');
|
|
||||||
expect(source).toContain(':mode="\'upload\'"');
|
|
||||||
expect(source).toContain(':center-upload-card-content="true"');
|
|
||||||
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
|
|
||||||
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.shipmentId)");
|
|
||||||
expect(source).toContain("uploadPending");
|
|
||||||
expect(source).not.toContain("uploadAttachment");
|
|
||||||
expect(source).not.toContain("pendingFiles");
|
|
||||||
expect(source).not.toContain("upload-card-label");
|
|
||||||
expect(source).not.toContain("<el-table");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("DrugShipmentEditorDrawer shipment validation", () => {
|
|
||||||
it("keeps shipment execution fields optional while status is pending", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("requiresShipmentDetails");
|
|
||||||
expect(source).toContain('status !== "PENDING"');
|
|
||||||
expect(source).not.toContain('ship_date: [{ required: true');
|
|
||||||
expect(source).not.toContain('batch_no: [{ required: true');
|
|
||||||
expect(source).not.toContain('carrier: [{ required: true');
|
|
||||||
expect(source).not.toContain('tracking_no: [{ required: true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not expose the removed returned status", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).not.toContain("RETURNED");
|
|
||||||
expect(source).not.toContain("已回收");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requires receive date when shipment is signed", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("requiresReceiveDate");
|
|
||||||
expect(source).toContain('status === "SIGNED"');
|
|
||||||
expect(source).not.toContain('receive_date: [{ required: true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps remark optional unless shipment is exceptional", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("requiresRemark");
|
|
||||||
expect(source).toContain('status === "EXCEPTION"');
|
|
||||||
expect(source).not.toContain('remark: [{ required: true');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./ShipmentDetail.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("ShipmentDetail edit drawer", () => {
|
|
||||||
it("shows an edit action and opens the shipment editor drawer on the detail page", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("<DrugShipmentEditorDrawer");
|
|
||||||
expect(source).toContain('v-if="canUpdateShipment"');
|
|
||||||
expect(source).toContain("editorVisible.value = true");
|
|
||||||
expect(source).toContain("const handleEditorSaved = () =>");
|
|
||||||
expect(source).toContain('"drug_shipments:update"');
|
|
||||||
expect(source).toContain(":readonly=\"isReadOnly\"");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the upload button in the detail attachment table", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("<AttachmentList");
|
|
||||||
expect(source).toContain(":hide-uploader=\"true\"");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readDetail = () => readFileSync(resolve(__dirname, "./ContractFeeDetail.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("ContractFeeDetail permissions", () => {
|
|
||||||
it("suppresses optional site lookup permission errors on the detail page", () => {
|
|
||||||
const source = readDetail();
|
|
||||||
|
|
||||||
expect(source).toContain('fetchSites(studyId.value, { limit: 500 }, { suppressErrorMessage: true })');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens the edit drawer on the detail page without navigating back to the list", () => {
|
|
||||||
const source = readDetail();
|
|
||||||
|
|
||||||
expect(source).toContain("<ContractFeeEditorDrawer");
|
|
||||||
expect(source).toContain("editorVisible.value = true");
|
|
||||||
expect(source).toContain("const handleEditorSaved = () =>");
|
|
||||||
expect(source).not.toContain('router.push({ path: "/fees/contracts"');
|
|
||||||
expect(source).not.toContain("editContractId");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders contract fee attachments as one aggregated table with attachment type labels", () => {
|
|
||||||
const source = readDetail();
|
|
||||||
|
|
||||||
expect(source).toContain("<AttachmentList");
|
|
||||||
expect(source).toContain(":entity-groups=\"attachmentGroups\"");
|
|
||||||
expect(source).toContain(":refresh-key=\"attachmentRefreshKey\"");
|
|
||||||
expect(source).toContain("attachmentRefreshKey.value += 1");
|
|
||||||
expect(source).not.toContain("<FeeAttachmentPanel");
|
|
||||||
expect(source).toContain('entityType: "contract_fee_contract"');
|
|
||||||
expect(source).toContain('entityType: "contract_fee_voucher"');
|
|
||||||
expect(source).toContain('entityType: "contract_fee_invoice"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not show attachment group descriptions on the detail page", () => {
|
|
||||||
const source = readDetail();
|
|
||||||
|
|
||||||
expect(source).not.toContain("attachmentContractDesc");
|
|
||||||
expect(source).not.toContain("attachmentVoucherDesc");
|
|
||||||
expect(source).not.toContain("attachmentInvoiceDesc");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps payment table columns evenly distributed with clear labels", () => {
|
|
||||||
const source = readDetail();
|
|
||||||
|
|
||||||
expect(source).toContain('v-for="(payment, index) in detail.payments"');
|
|
||||||
expect(source).toContain("TEXT.modules.feeContracts.paymentSeq");
|
|
||||||
expect(source).toContain("TEXT.modules.feeContracts.paidFlag");
|
|
||||||
expect(source).toContain("TEXT.modules.feeContracts.verifiedFlag");
|
|
||||||
expect(source).toContain("tl-panel-amount");
|
|
||||||
expect(source).not.toContain('width="20%"');
|
|
||||||
expect(source).not.toContain(".payment-table :deep(col:nth-child(-n + 5))");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses ten-thousand yuan as the only amount unit and hides currency", () => {
|
|
||||||
const source = readDetail();
|
|
||||||
|
|
||||||
expect(source).toContain("formatAmountWan(detail.contract_amount)");
|
|
||||||
expect(source).toContain("formatAmountWan(payment.amount)");
|
|
||||||
expect(source).toContain('<span class="stat-tile-unit">万</span>');
|
|
||||||
expect(source).toContain('<span class="amount-unit-sm">万</span>');
|
|
||||||
expect(source).not.toContain("TEXT.common.fields.currency");
|
|
||||||
expect(source).not.toContain("detail.currency");
|
|
||||||
expect(source).not.toContain("currencyDefault");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readDrawer = () => readFileSync(resolve(__dirname, "./ContractFeeEditorDrawer.vue"), "utf8");
|
|
||||||
const readLocale = () => readFileSync(resolve(__dirname, "../../locales/zh-CN.ts"), "utf8");
|
|
||||||
|
|
||||||
describe("ContractFeeEditorDrawer attachments", () => {
|
|
||||||
it("uses upload-card mode for attachments instead of the detail table", () => {
|
|
||||||
const source = readDrawer();
|
|
||||||
|
|
||||||
expect(source).toContain('ref="attachmentPanelRef"');
|
|
||||||
expect(source).toContain("<AttachmentList");
|
|
||||||
expect(source).toContain(":entity-groups=\"attachmentGroups\"");
|
|
||||||
expect(source).not.toContain("PendingAttachmentUpload");
|
|
||||||
expect(source).not.toContain("FeeAttachmentPanel");
|
|
||||||
expect(source).not.toContain("attachmentContractDesc");
|
|
||||||
expect(source).not.toContain("attachmentVoucherDesc");
|
|
||||||
expect(source).not.toContain("attachmentInvoiceDesc");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uploads selected attachments only after the form save succeeds", () => {
|
|
||||||
const source = readDrawer();
|
|
||||||
|
|
||||||
expect(source).toContain("const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null)");
|
|
||||||
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
|
|
||||||
expect(source).toContain("attachments: attachmentPanelRef.value?.pendingSnapshot() || []");
|
|
||||||
expect(source.indexOf("await attachmentPanelRef.value?.uploadPending(savedId)")).toBeLessThan(
|
|
||||||
source.indexOf('emit("update:modelValue", false)')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows attachment upload cards while creating a new contract fee", () => {
|
|
||||||
const source = readDrawer();
|
|
||||||
|
|
||||||
expect(source).toContain('class="form-section"');
|
|
||||||
expect(source).toContain(':entity-id="form.id"');
|
|
||||||
expect(source).toContain("<AttachmentList");
|
|
||||||
expect(source).toContain(':center-upload-card-content="true"');
|
|
||||||
expect(source).toContain(':upload-card-columns="3"');
|
|
||||||
expect(source).not.toContain(':hide-upload-card-labels="true"');
|
|
||||||
expect(source).not.toContain('<div v-if="form.id" class="form-group">');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses centered three-column upload cards with contract-specific copy", () => {
|
|
||||||
const source = readDrawer();
|
|
||||||
|
|
||||||
expect(source).toContain('uploadText: "点击上传合同"');
|
|
||||||
expect(source).toContain('uploadText: "点击上传凭证"');
|
|
||||||
expect(source).toContain('uploadText: "点击上传发票"');
|
|
||||||
expect(source).toContain(':center-upload-card-content="true"');
|
|
||||||
expect(source).toContain(':upload-card-columns="3"');
|
|
||||||
expect(source).not.toContain(':hide-upload-card-labels="true"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("labels the section as attachments instead of contract attachments", () => {
|
|
||||||
const source = readLocale();
|
|
||||||
|
|
||||||
expect(source).toContain('attachmentTitle: "附件"');
|
|
||||||
expect(source).not.toContain('attachmentTitle: "合同附件"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses a payment grid that keeps date pickers inside their columns", () => {
|
|
||||||
const source = readDrawer();
|
|
||||||
|
|
||||||
expect(source).toContain('class="field-grid field-grid--3"');
|
|
||||||
expect(source).toContain('class="status-row"');
|
|
||||||
expect(source).toContain('class="status-datepicker"');
|
|
||||||
expect(source).toContain("grid-template-columns: 1fr 1fr 1fr;");
|
|
||||||
expect(source).toContain("min-width: 0;");
|
|
||||||
expect(source).toContain(".status-datepicker :deep(.el-date-editor.el-input)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses ten-thousand yuan as the only amount unit and removes currency selection", () => {
|
|
||||||
const source = readDrawer();
|
|
||||||
|
|
||||||
expect(source).toContain(':label="`${TEXT.modules.feeContracts.contractAmount}(万元)`"');
|
|
||||||
expect(source).toContain(':label="`${TEXT.common.fields.amount}(万元)`"');
|
|
||||||
expect(source).toContain("contract_amount: Number(detail.contract_amount || 0) / 10000");
|
|
||||||
expect(source).toContain("amount: Number(payment.amount || 0) / 10000");
|
|
||||||
expect(source).toContain("contract_amount: Number(form.contract_amount || 0) * 10000");
|
|
||||||
expect(source).toContain("amount: Number(payment.amount || 0) * 10000");
|
|
||||||
expect(source).not.toContain("prop=\"currency\"");
|
|
||||||
expect(source).not.toContain("form.currency");
|
|
||||||
expect(source).not.toContain("payload.currency");
|
|
||||||
expect(source).not.toContain("currency:");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { TEXT } from "../../locales";
|
|
||||||
|
|
||||||
describe("contract fee attachment labels", () => {
|
|
||||||
it("uses a concise attachment section title", () => {
|
|
||||||
expect(TEXT.modules.feeContracts.attachmentTitle).toBe("附件");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses a clear payment sequence column label", () => {
|
|
||||||
expect(TEXT.modules.feeContracts.paymentSeqColumn).toBe("付款期次");
|
|
||||||
expect(TEXT.modules.feeContracts.paymentSeq).toBe("第");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const read = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
|
|
||||||
|
|
||||||
describe("Contract fee project permissions", () => {
|
|
||||||
it("hides list create/delete controls when the matching backend operation is not allowed", () => {
|
|
||||||
const source = read("./ContractFees.vue");
|
|
||||||
|
|
||||||
expect(source).toContain('v-if="canCreate"');
|
|
||||||
expect(source).toContain('v-if="canDelete"');
|
|
||||||
expect(source).toContain("if (!canCreate.value)");
|
|
||||||
expect(source).toContain("if (!canDelete.value)");
|
|
||||||
expect(source).not.toContain(':disabled="!canCreate"');
|
|
||||||
expect(source).not.toContain(':disabled="!canDelete || isInactiveSite(scope.row.center_id)"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides detail edit controls and blocks direct navigation without update permission", () => {
|
|
||||||
const source = read("./ContractFeeDetail.vue");
|
|
||||||
|
|
||||||
expect(source).toContain('v-if="canWrite"');
|
|
||||||
expect(source).toContain("if (!canWrite.value)");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./EtmfPlaceholder.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("EtmfPlaceholder project permissions", () => {
|
|
||||||
it("hides eTMF create controls and guards dialog submissions with document create permission", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain('v-if="canCreate"');
|
|
||||||
expect(source).toContain('v-if="canCreate && selectedNode"');
|
|
||||||
expect(source).toContain("if (!canCreate.value)");
|
|
||||||
expect(source).not.toContain(':disabled="!canCreate"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipment.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("MaterialEquipment project permissions", () => {
|
|
||||||
it("projects material equipment create update and delete permissions into all write actions", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("useAuthStore");
|
|
||||||
expect(source).toContain("isSystemAdmin");
|
|
||||||
expect(source).toContain("isApiPermissionAllowed");
|
|
||||||
expect(source).toContain('["material_equipments:create"]');
|
|
||||||
expect(source).toContain('["material_equipments:update"]');
|
|
||||||
expect(source).toContain('["material_equipments:delete"]');
|
|
||||||
expect(source).toContain('v-if="canCreate"');
|
|
||||||
expect(source).toContain('v-if="canUpdate"');
|
|
||||||
expect(source).toContain('v-if="canDelete"');
|
|
||||||
expect(source).toContain("if (!canDelete.value)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the visible table columns evenly distributed", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain('class="equipment-table"');
|
|
||||||
expect(source).toContain('style="width: 100%"');
|
|
||||||
expect(source).toContain('table-layout="fixed"');
|
|
||||||
expect(source).toContain('label="操作"');
|
|
||||||
expect(source).not.toMatch(/<el-table-column[^>]+prop="(?:name|specModel|unit|brand)"[^>]+(?:width|min-width)=/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens the detail page from row clicks instead of a detail action button", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain('@row-click="onRowClick"');
|
|
||||||
expect(source).toContain(':row-class-name="equipmentRowClass"');
|
|
||||||
expect(source).toContain('name: "MaterialEquipmentDetail"');
|
|
||||||
expect(source).toContain("equipmentId: row.id");
|
|
||||||
expect(source).not.toContain(">详情</el-button>");
|
|
||||||
expect(source).toContain('@click.stop="openEdit(row)"');
|
|
||||||
expect(source).toContain('@click.stop="removeRow(row)"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uploads qualification files as equipment attachments after saving the drawer form", () => {
|
|
||||||
const source = readSource();
|
|
||||||
const calibrationIndex = source.indexOf("校准设置");
|
|
||||||
const qualificationIndex = source.indexOf("附件");
|
|
||||||
|
|
||||||
expect(calibrationIndex).toBeGreaterThan(-1);
|
|
||||||
expect(qualificationIndex).toBeGreaterThan(calibrationIndex);
|
|
||||||
expect(source).toContain("AttachmentList");
|
|
||||||
expect(source).toContain('ref="attachmentPanelRef"');
|
|
||||||
expect(source).toContain('entity-type="material_equipment"');
|
|
||||||
expect(source).toContain(':entity-id="editingId"');
|
|
||||||
expect(source).toContain(':mode="\'upload\'"');
|
|
||||||
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
|
|
||||||
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(equipmentId)");
|
|
||||||
expect(source).toContain("uploadPending");
|
|
||||||
expect(source).toContain("material_equipment");
|
|
||||||
expect(source).not.toContain("uploadAttachment");
|
|
||||||
expect(source).not.toContain("pendingFiles");
|
|
||||||
expect(source).not.toContain("qualificationRows");
|
|
||||||
expect(source).not.toContain("material_equipment_production_permit");
|
|
||||||
expect(source).not.toContain("material_equipment_tech_index");
|
|
||||||
expect(source).not.toContain("资质文件");
|
|
||||||
expect(source).not.toContain("生产许可证");
|
|
||||||
expect(source).not.toContain("技术指标");
|
|
||||||
expect(source).not.toContain("handleUploadChange");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
|
|
||||||
|
|
||||||
describe("precautions project permissions", () => {
|
|
||||||
it("splits list create and delete controls by backend operation permissions", () => {
|
|
||||||
const source = readSource("./Precautions.vue");
|
|
||||||
|
|
||||||
expect(source).toContain('const canCreatePrecaution = computed(() => can("precautions.create"))');
|
|
||||||
expect(source).toContain('const canDeletePrecaution = computed(() => can("precautions.delete"))');
|
|
||||||
expect(source).toContain('v-if="canCreatePrecaution"');
|
|
||||||
expect(source).toContain('v-if="canDeletePrecaution"');
|
|
||||||
expect(source).toContain("if (!canDeletePrecaution.value)");
|
|
||||||
expect(source).not.toContain("canWritePrecautions");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("precautions drawer editor", () => {
|
|
||||||
it("opens create form in a drawer instead of routing to the form page", () => {
|
|
||||||
const source = readSource("./Precautions.vue");
|
|
||||||
|
|
||||||
expect(source).toContain("PrecautionEditorDrawer");
|
|
||||||
expect(source).toContain("precautionDrawerVisible");
|
|
||||||
expect(source).not.toContain('router.push("/knowledge/precautions/new")');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses selects for site and level fields in the create drawer", () => {
|
|
||||||
const source = readSource("../knowledge/PrecautionEditorDrawer.vue");
|
|
||||||
|
|
||||||
expect(source).toContain('<el-select v-model="form.site_name"');
|
|
||||||
expect(source).toContain('v-for="site in sites"');
|
|
||||||
expect(source).toContain(':value="site.name"');
|
|
||||||
expect(source).toContain('<el-select v-model="form.level"');
|
|
||||||
expect(source).toContain("const levelOptions = precautionLevelOptions");
|
|
||||||
expect(source).toContain('v-for="level in levelOptions"');
|
|
||||||
expect(source).not.toContain('<el-input v-model="form.site_name"');
|
|
||||||
expect(source).not.toContain('<el-input v-model="form.level"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("moves precaution attachment upload into the editor drawer", () => {
|
|
||||||
const drawer = readSource("../knowledge/PrecautionEditorDrawer.vue");
|
|
||||||
const detail = readSource("../knowledge/PrecautionDetail.vue");
|
|
||||||
|
|
||||||
expect(drawer).toContain("AttachmentList");
|
|
||||||
expect(drawer).toContain('ref="attachmentPanelRef"');
|
|
||||||
expect(drawer).toContain(':entity-type="\'precaution\'"');
|
|
||||||
expect(drawer).toContain(':mode="\'upload\'"');
|
|
||||||
expect(drawer).toContain("attachments: attachmentPanelRef.value?.pendingSnapshot() || []");
|
|
||||||
expect(drawer).toContain("await attachmentPanelRef.value?.uploadPending(id)");
|
|
||||||
expect(detail).toContain(":hide-uploader=\"true\"");
|
|
||||||
expect(detail).toContain(":refresh-key=\"attachmentRefreshKey\"");
|
|
||||||
expect(detail).toContain("attachmentRefreshKey.value += 1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./ProjectMilestones.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("ProjectMilestones project permissions", () => {
|
|
||||||
it("hides edit controls and blocks saves without project milestone update permission", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain('const canUpdateMilestones = computed(() => can("project.milestones.update"))');
|
|
||||||
expect(source).toContain('v-if="canUpdateMilestones"');
|
|
||||||
expect(source).toContain("if (!canUpdateMilestones.value)");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readRiskIssueMonitoringVisits = () => readFileSync(resolve(__dirname, "./RiskIssueMonitoringVisits.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("RiskIssueMonitoringVisits permissions", () => {
|
|
||||||
it("gates monitoring visit issue actions by concrete API permissions", () => {
|
|
||||||
const source = readRiskIssueMonitoringVisits();
|
|
||||||
|
|
||||||
expect(source).toContain("useAuthStore");
|
|
||||||
expect(source).toContain("isSystemAdmin");
|
|
||||||
expect(source).toContain("getProjectRole");
|
|
||||||
expect(source).toContain("isApiPermissionAllowed");
|
|
||||||
[
|
|
||||||
"monitoring_issues:read",
|
|
||||||
"monitoring_issues:read",
|
|
||||||
"monitoring_issues:create",
|
|
||||||
"monitoring_issues:update",
|
|
||||||
"monitoring_issues:delete",
|
|
||||||
].forEach((operationKey) => {
|
|
||||||
expect(source).toContain(operationKey);
|
|
||||||
});
|
|
||||||
expect(source).toContain('v-if="canCreateIssue"');
|
|
||||||
expect(source).toContain('v-if="canListIssues"');
|
|
||||||
expect(source).toContain('v-if="canReadIssue"');
|
|
||||||
expect(source).toContain('v-if="canUpdateIssue"');
|
|
||||||
expect(source).toContain('v-if="canDeleteIssue"');
|
|
||||||
expect(source).toContain('v-if="canSaveIssue"');
|
|
||||||
expect(source).not.toContain(':disabled="!canSaveIssue"');
|
|
||||||
expect(source).toContain("const canSaveIssue = computed(() => (formMode.value === \"edit\" ? canUpdateIssue.value : canCreateIssue.value));");
|
|
||||||
expect(source).toContain('ElMessage.warning("权限不足")');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readRiskIssueSae = () => readFileSync(resolve(__dirname, "./RiskIssueSae.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("RiskIssueSae status", () => {
|
|
||||||
it("only exposes follow-up and closed statuses in the risk issue status column", () => {
|
|
||||||
const source = readRiskIssueSae();
|
|
||||||
|
|
||||||
expect(source).toContain("const aeStatusOptions = [");
|
|
||||||
expect(source).toContain('{ value: "FOLLOW_UP", label: TEXT.enums.aeStatus.FOLLOW_UP }');
|
|
||||||
expect(source).toContain('{ value: "CLOSED", label: TEXT.enums.aeStatus.CLOSED }');
|
|
||||||
expect(source).toContain("normalizeAeStatus");
|
|
||||||
expect(source).not.toContain("Object.entries(TEXT.enums.aeStatus)");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./StartupFeasibilityEthics.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("StartupFeasibilityEthics project permissions", () => {
|
|
||||||
it("hides initiation and ethics create/delete actions by their backend operation permissions", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain('canUseApiPermission("startup_initiation:create")');
|
|
||||||
expect(source).toContain('canUseApiPermission("startup_initiation:read")');
|
|
||||||
expect(source).toContain('canUseApiPermission("startup_initiation:update")');
|
|
||||||
expect(source).toContain('canUseApiPermission("startup_initiation:delete")');
|
|
||||||
expect(source).toContain('canUseApiPermission("startup_ethics:read")');
|
|
||||||
expect(source).toContain('canUseApiPermission("startup_ethics:create")');
|
|
||||||
expect(source).toContain('canUseApiPermission("startup_ethics:update")');
|
|
||||||
expect(source).toContain('canUseApiPermission("startup_ethics:delete")');
|
|
||||||
expect(source).toContain('v-if="canReadFeasibility"');
|
|
||||||
expect(source).toContain('v-if="canReadEthics"');
|
|
||||||
expect(source).toContain("if (!canReadFeasibility.value)");
|
|
||||||
expect(source).toContain("if (!canReadEthics.value)");
|
|
||||||
expect(source).toContain('v-if="activeTab === \'feasibility\' && canCreateFeasibility"');
|
|
||||||
expect(source).toContain('v-if="canUpdateFeasibility"');
|
|
||||||
expect(source).toContain('v-if="canDeleteFeasibility"');
|
|
||||||
expect(source).toContain('v-if="activeTab === \'ethics\' && canCreateEthics"');
|
|
||||||
expect(source).toContain('v-if="canUpdateEthics"');
|
|
||||||
expect(source).toContain('v-if="canDeleteEthics"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("StartupFeasibilityEthics drawer editors", () => {
|
|
||||||
it("opens initiation and ethics create forms in drawers instead of routing to form pages", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("FeasibilityEditorDrawer");
|
|
||||||
expect(source).toContain("EthicsEditorDrawer");
|
|
||||||
expect(source).toContain("feasibilityDrawerVisible");
|
|
||||||
expect(source).toContain("ethicsDrawerVisible");
|
|
||||||
expect(source).not.toContain('router.push("/startup/feasibility/new")');
|
|
||||||
expect(source).not.toContain('router.push("/startup/ethics/new")');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens initiation and ethics edit drawers from table action columns", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain(':record-id="editingFeasibilityId"');
|
|
||||||
expect(source).toContain(':record-id="editingEthicsId"');
|
|
||||||
expect(source).toContain("const editingFeasibilityId = ref<string | undefined>();");
|
|
||||||
expect(source).toContain("const editingEthicsId = ref<string | undefined>();");
|
|
||||||
expect(source).toContain("@click.stop=\"openFeasibilityEditor(scope.row)\"");
|
|
||||||
expect(source).toContain("@click.stop=\"openEthicsEditor(scope.row)\"");
|
|
||||||
expect(source).toContain("editingFeasibilityId.value = row?.id;");
|
|
||||||
expect(source).toContain("editingEthicsId.value = row?.id;");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readStageNode = () => readFileSync(resolve(__dirname, "./StageNode.vue"), "utf8");
|
|
||||||
const readCenterProgressRow = () => readFileSync(resolve(__dirname, "./CenterProgressRow.vue"), "utf8");
|
|
||||||
const readProjectOverview = () => readFileSync(resolve(__dirname, "../ProjectOverview.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("project overview stage progress styling", () => {
|
|
||||||
it("makes the in-progress stage visually distinct from pending stages", () => {
|
|
||||||
const source = readStageNode();
|
|
||||||
|
|
||||||
expect(source).toContain(".stage-in_progress .stage-dot");
|
|
||||||
expect(source).toContain("border: 3px solid #2f5f86");
|
|
||||||
expect(source).toContain(".stage-in_progress .stage-dot::after");
|
|
||||||
expect(source).toContain(".stage-in_progress .stage-dot::before");
|
|
||||||
expect(source).toContain("animation: progress-halo");
|
|
||||||
expect(source).toContain(".stage-in_progress .stage-label");
|
|
||||||
expect(source).toContain("font-weight: 800");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("highlights the active connector and legend consistently", () => {
|
|
||||||
const rowSource = readCenterProgressRow();
|
|
||||||
const overviewSource = readProjectOverview();
|
|
||||||
|
|
||||||
expect(rowSource).toContain(".connector-in_progress");
|
|
||||||
expect(rowSource).toContain("height: 3px");
|
|
||||||
expect(rowSource).toContain("linear-gradient(90deg, #2f5f86");
|
|
||||||
expect(overviewSource).toContain(".legend-dot.active::after");
|
|
||||||
expect(overviewSource).toContain("background: #2f5f86");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./PrecautionDetail.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("PrecautionDetail project permissions", () => {
|
|
||||||
it("hides edit controls unless the backend update operation is allowed", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain('const canUpdatePrecaution = computed(() => can("precautions.update"))');
|
|
||||||
expect(source).toContain('v-if="canUpdatePrecaution"');
|
|
||||||
expect(source).toContain("if (!canUpdatePrecaution.value)");
|
|
||||||
expect(source).not.toContain("canWritePrecautions");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("PrecautionDetail drawer editor", () => {
|
|
||||||
it("opens edit in the shared drawer editor instead of routing to the form page", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("PrecautionEditorDrawer");
|
|
||||||
expect(source).toContain("precautionEditorVisible");
|
|
||||||
expect(source).toContain("@success=\"handlePrecautionEditorSuccess\"");
|
|
||||||
expect(source).not.toContain("router.push(`/knowledge/precautions/${precautionId}/edit`)");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./PrecautionForm.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("PrecautionForm project permissions", () => {
|
|
||||||
it("uses create permission for new records and update permission for edits", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain('const canCreatePrecaution = computed(() => can("precautions.create"))');
|
|
||||||
expect(source).toContain('const canUpdatePrecaution = computed(() => can("precautions.update"))');
|
|
||||||
expect(source).toContain("const canSavePrecaution = computed(() => (isEdit.value ? canUpdatePrecaution.value : canCreatePrecaution.value))");
|
|
||||||
expect(source).toContain("if (!canSavePrecaution.value)");
|
|
||||||
expect(source).not.toContain("canWritePrecautions");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses selects for site and level fields", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain('<el-select v-model="form.site_name"');
|
|
||||||
expect(source).toContain('v-for="site in sites"');
|
|
||||||
expect(source).toContain(':value="site.name"');
|
|
||||||
expect(source).toContain('<el-select v-model="form.level"');
|
|
||||||
expect(source).toContain("const levelOptions = precautionLevelOptions");
|
|
||||||
expect(source).toContain('v-for="level in levelOptions"');
|
|
||||||
expect(source).not.toContain('<el-input v-model="form.site_name"');
|
|
||||||
expect(source).not.toContain('<el-input v-model="form.level"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows attachments to be selected before creating a precaution", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("<AttachmentList");
|
|
||||||
expect(source).toContain('ref="attachmentPanelRef"');
|
|
||||||
expect(source).toContain('entity-type="precaution"');
|
|
||||||
expect(source).toContain(':entity-id="precautionId || \'\'"');
|
|
||||||
expect(source).toContain(':mode="\'upload\'"');
|
|
||||||
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
|
|
||||||
expect(source).not.toContain('v-if="isEdit && precautionId"');
|
|
||||||
expect(source).not.toContain("StateEmpty");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipmentDetail.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("MaterialEquipmentDetail edit drawer", () => {
|
|
||||||
it("shows an edit action and opens the equipment editor drawer on the detail page", () => {
|
|
||||||
const source = readSource();
|
|
||||||
|
|
||||||
expect(source).toContain("<MaterialEquipmentEditorDrawer");
|
|
||||||
expect(source).toContain('v-if="canUpdateEquipment"');
|
|
||||||
expect(source).toContain("editorVisible.value = true");
|
|
||||||
expect(source).toContain("const handleEditorSaved = () =>");
|
|
||||||
expect(source).toContain('"material_equipments:update"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("places attachments below calibration settings and renders them as one attachment table", () => {
|
|
||||||
const source = readSource();
|
|
||||||
const calibrationIndex = source.indexOf("校准设置");
|
|
||||||
const qualificationIndex = source.indexOf("TEXT.common.labels.attachments");
|
|
||||||
|
|
||||||
expect(calibrationIndex).toBeGreaterThan(-1);
|
|
||||||
expect(qualificationIndex).toBeGreaterThan(calibrationIndex);
|
|
||||||
expect(source).toContain("<AttachmentList");
|
|
||||||
expect(source).toContain('entity-type="material_equipment"');
|
|
||||||
expect(source).toContain(':hide-uploader="true"');
|
|
||||||
expect(source).toContain(':refresh-key="attachmentRefreshKey"');
|
|
||||||
expect(source).toContain("attachmentRefreshKey.value += 1");
|
|
||||||
expect(source).not.toContain("fetchAttachments");
|
|
||||||
expect(source).not.toContain("deleteAttachment");
|
|
||||||
expect(source).not.toContain("formatFileSize");
|
|
||||||
expect(source).not.toContain("qualificationAttachments");
|
|
||||||
expect(source).not.toContain('class="attachment-table qualification-table"');
|
|
||||||
expect(source).not.toContain("previewVisible");
|
|
||||||
expect(source).not.toContain("getDownloadUrl");
|
|
||||||
expect(source).not.toContain("removeAttachment");
|
|
||||||
expect(source).toContain("material_equipment");
|
|
||||||
expect(source).not.toContain("material_equipment_production_permit");
|
|
||||||
expect(source).not.toContain("material_equipment_tech_index");
|
|
||||||
expect(source).not.toContain("资质文件");
|
|
||||||
expect(source).not.toContain("生产许可证");
|
|
||||||
expect(source).not.toContain("技术指标");
|
|
||||||
expect(source).not.toContain('label="生产许可证">{{ displayValue(detail.production_permit_file_name) }}');
|
|
||||||
expect(source).not.toContain('label="技术指标">{{ displayValue(detail.tech_index_file_name) }}');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipmentEditorDrawer.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("MaterialEquipmentEditorDrawer attachments", () => {
|
|
||||||
it("queues qualification files and uploads them as business attachments on save", () => {
|
|
||||||
const source = readSource();
|
|
||||||
const calibrationIndex = source.indexOf("校准设置");
|
|
||||||
const qualificationIndex = source.indexOf("附件");
|
|
||||||
|
|
||||||
expect(calibrationIndex).toBeGreaterThan(-1);
|
|
||||||
expect(qualificationIndex).toBeGreaterThan(calibrationIndex);
|
|
||||||
expect(source).toContain("AttachmentList");
|
|
||||||
expect(source).toContain('ref="attachmentPanelRef"');
|
|
||||||
expect(source).toContain('entity-type="material_equipment"');
|
|
||||||
expect(source).toContain(':mode="\'upload\'"');
|
|
||||||
expect(source).toContain(':center-upload-card-content="true"');
|
|
||||||
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
|
|
||||||
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.equipmentId)");
|
|
||||||
expect(source).toContain("uploadPending");
|
|
||||||
expect(source).toContain("material_equipment");
|
|
||||||
expect(source).not.toContain("uploadAttachment");
|
|
||||||
expect(source).not.toContain("pendingFiles");
|
|
||||||
expect(source).not.toContain("qualificationRows");
|
|
||||||
expect(source).not.toContain("material_equipment_production_permit");
|
|
||||||
expect(source).not.toContain("material_equipment_tech_index");
|
|
||||||
expect(source).not.toContain("资质文件");
|
|
||||||
expect(source).not.toContain("生产许可证");
|
|
||||||
expect(source).not.toContain("技术指标");
|
|
||||||
expect(source).not.toContain("handleUploadChange");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readView = (relativePath: string) =>
|
|
||||||
readFileSync(resolve(__dirname, relativePath), "utf8");
|
|
||||||
|
|
||||||
const normalizeWhitespace = (source: string) => source.replace(/\s+/g, " ").trim();
|
|
||||||
|
|
||||||
describe("route view overlays are mounted only when visible", () => {
|
|
||||||
it("gates high-risk admin and subject overlays behind visible flags", () => {
|
|
||||||
const checks = [
|
|
||||||
{
|
|
||||||
file: "./admin/ProjectMembers.vue",
|
|
||||||
snippets: ['<el-dialog v-if="addVisible"', 'v-model="addVisible"'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "./admin/Sites.vue",
|
|
||||||
snippets: ['<SiteForm', 'v-if="projectId && formVisible"'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "./admin/SiteForm.vue",
|
|
||||||
snippets: ['<el-dialog v-if="visibleProxy"', 'v-model="visibleProxy"'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "./admin/SiteCraBinding.vue",
|
|
||||||
snippets: ['<el-dialog v-if="visibleProxy"', 'v-model="visibleProxy"'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "./subjects/SubjectDetail.vue",
|
|
||||||
snippets: [
|
|
||||||
'<el-dialog v-if="historyDialogVisible"',
|
|
||||||
'<VisitEditorDrawer',
|
|
||||||
'<el-drawer v-if="adherenceDrawerVisible"',
|
|
||||||
'<el-drawer v-if="aeDrawerVisible"',
|
|
||||||
'<el-drawer v-if="pdDrawerVisible"',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "./ia/ProjectMilestones.vue",
|
|
||||||
snippets: ['<el-drawer v-if="editorVisible"', 'v-model="editorVisible"'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "./ia/MaterialEquipment.vue",
|
|
||||||
snippets: ['<el-drawer v-if="drawerVisible"', 'v-model="drawerVisible"'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "./ia/RiskIssueMonitoringVisits.vue",
|
|
||||||
snippets: [
|
|
||||||
'<el-drawer v-if="formDialogVisible"',
|
|
||||||
'v-model="formDialogVisible"',
|
|
||||||
'<el-dialog v-if="viewDialogVisible"',
|
|
||||||
'v-model="viewDialogVisible"',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "../components/attachments/AttachmentList.vue",
|
|
||||||
snippets: ['<el-dialog v-if="previewVisible"', 'append-to-body', 'v-model="previewVisible"'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "./admin/ProjectDetail.vue",
|
|
||||||
snippets: [
|
|
||||||
'<el-dialog v-if="publishConfirmVisible"',
|
|
||||||
'v-model="publishConfirmVisible"',
|
|
||||||
'v-if="rollbackDialogVisible"',
|
|
||||||
'v-model="rollbackDialogVisible"',
|
|
||||||
'<el-drawer v-if="projectMilestoneEditorVisible"',
|
|
||||||
'<el-drawer v-if="siteMilestoneEditorVisible"',
|
|
||||||
'<el-drawer v-if="siteEnrollmentEditorVisible"',
|
|
||||||
'const getRollbackAxisLeftStyle = (axisX?: number | null) => ({',
|
|
||||||
':style="getRollbackAxisLeftStyle(lane.x)"',
|
|
||||||
':style="getRollbackAxisLeftStyle(row.axisX)"',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const check of checks) {
|
|
||||||
const source = normalizeWhitespace(readView(check.file));
|
|
||||||
for (const snippet of check.snippets) {
|
|
||||||
expect(source, `${check.file} should include ${snippet}`).toContain(normalizeWhitespace(snippet));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows mask close on drawers while guarding dirty editor forms", () => {
|
|
||||||
const checks = [
|
|
||||||
{
|
|
||||||
file: "./ia/DrugShipments.vue",
|
|
||||||
snippets: [':close-on-click-modal="true"', ':before-close="drawerDirtyGuard.beforeClose"'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "./ia/MaterialEquipment.vue",
|
|
||||||
snippets: [':close-on-click-modal="true"', ':before-close="drawerDirtyGuard.beforeClose"'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "./ia/ProjectMilestones.vue",
|
|
||||||
snippets: [':close-on-click-modal="true"', ':before-close="editorDirtyGuard.beforeClose"'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "./ia/RiskIssueMonitoringVisits.vue",
|
|
||||||
snippets: [':close-on-click-modal="true"', ':before-close="formDirtyGuard.beforeClose"'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "./admin/ProjectDetail.vue",
|
|
||||||
snippets: [
|
|
||||||
':before-close="projectMilestoneEditorDirtyGuard.beforeClose"',
|
|
||||||
':before-close="siteMilestoneEditorDirtyGuard.beforeClose"',
|
|
||||||
':before-close="siteEnrollmentEditorDirtyGuard.beforeClose"',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "./admin/PermissionManagement.vue",
|
|
||||||
snippets: [':close-on-click-modal="true"', ':before-close="handleTemplateDrawerBeforeClose"'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "./documents/DocumentList.vue",
|
|
||||||
snippets: [':close-on-click-modal="true"', ':before-close="editorDirtyGuard.beforeClose"'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "./documents/DocumentDetail.vue",
|
|
||||||
snippets: [
|
|
||||||
':close-on-click-modal="true"',
|
|
||||||
':before-close="editorDirtyGuard.beforeClose"',
|
|
||||||
':before-close="uploadDirtyGuard.beforeClose"',
|
|
||||||
':before-close="distributeDirtyGuard.beforeClose"',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "./admin/AuditLogs.vue",
|
|
||||||
snippets: [':close-on-click-modal="true"'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const check of checks) {
|
|
||||||
const source = normalizeWhitespace(readView(check.file));
|
|
||||||
for (const snippet of check.snippets) {
|
|
||||||
expect(source, `${check.file} should include ${snippet}`).toContain(normalizeWhitespace(snippet));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readForm = () => readFileSync(resolve(__dirname, "./KickoffForm.vue"), "utf8");
|
|
||||||
const readDetail = () => readFileSync(resolve(__dirname, "./KickoffDetail.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("Kickoff member permissions", () => {
|
|
||||||
it("does not load project members in the form unless the role can read project members", () => {
|
|
||||||
const source = readForm();
|
|
||||||
|
|
||||||
expect(source).toContain('can("project.members.list")');
|
|
||||||
expect(source).toContain("if (canReadMembers.value)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not load project members in the detail page unless the role can read project members", () => {
|
|
||||||
const source = readDetail();
|
|
||||||
|
|
||||||
expect(source).toContain('can("project.members.list")');
|
|
||||||
expect(source).toContain("if (canReadMembers.value)");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const read = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
|
|
||||||
|
|
||||||
describe("startup auth editor drawer attachments", () => {
|
|
||||||
it("uses upload cards for kickoff attachments instead of full attachment tables", () => {
|
|
||||||
const source = read("./KickoffEditorDrawer.vue");
|
|
||||||
|
|
||||||
expect(source).toContain("AttachmentList");
|
|
||||||
expect(source).toContain('ref="attachmentPanelRef"');
|
|
||||||
expect(source).toContain(':mode="\'upload\'"');
|
|
||||||
expect(source).toContain(":entity-groups=\"kickoffAttachmentGroups\"");
|
|
||||||
expect(source).toContain("attachments: attachmentPanelRef.value?.pendingSnapshot() || []");
|
|
||||||
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.meetingId)");
|
|
||||||
expect(source).toContain('entityType: "startup_kickoff_minutes"');
|
|
||||||
expect(source).toContain('entityType: "startup_kickoff_signin"');
|
|
||||||
expect(source).toContain('entityType: "startup_kickoff_ppt"');
|
|
||||||
expect(source).toContain('entityType: "startup_kickoff"');
|
|
||||||
expect(source).not.toContain("PendingAttachmentUpload");
|
|
||||||
expect(source).not.toContain("attendees");
|
|
||||||
expect(source).not.toContain("parseAttendees");
|
|
||||||
expect(source).not.toContain("attendeesPlaceholder");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not expose attachments in the training authorization editor", () => {
|
|
||||||
const source = read("./TrainingEditorDrawer.vue");
|
|
||||||
|
|
||||||
expect(source).not.toContain("PendingAttachmentUpload");
|
|
||||||
expect(source).not.toContain("AttachmentList");
|
|
||||||
expect(source).not.toContain("training_authorization");
|
|
||||||
expect(source).not.toContain("attachments:");
|
|
||||||
expect(source).not.toContain("uploadPending");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports batch creating and single-row updating training authorizations in the same drawer", () => {
|
|
||||||
const source = read("./TrainingEditorDrawer.vue");
|
|
||||||
|
|
||||||
expect(source).toContain("recordId?: string");
|
|
||||||
expect(source).toContain('editorTitle');
|
|
||||||
expect(source).toContain("createTrainingAuthorization");
|
|
||||||
expect(source).toContain("updateTrainingAuthorization");
|
|
||||||
expect(source).toContain("const isCreateMode = computed(() => !props.recordId)");
|
|
||||||
expect(source).toContain("const canCreateAuth = computed(() => can(\"startup.auth.create\"))");
|
|
||||||
expect(source).toContain("const canSaveAuth = computed(() => (isCreateMode.value ? canCreateAuth.value : canUpdateAuth.value))");
|
|
||||||
expect(source).toContain("batchRows");
|
|
||||||
expect(source).toContain("addBatchRow");
|
|
||||||
expect(source).toContain("removeBatchRow");
|
|
||||||
expect(source).toContain("for (const row of batchRows.value)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps training authorization editor compact and hides site input while preserving site context", () => {
|
|
||||||
const source = read("./TrainingEditorDrawer.vue");
|
|
||||||
|
|
||||||
expect(source).not.toContain(':label="TEXT.common.fields.site"');
|
|
||||||
expect(source).not.toContain('prop="site_name"');
|
|
||||||
expect(source).toContain("site_name: props.initialSiteName || form.site_name || null");
|
|
||||||
expect(source).toContain('class="compact-row-grid"');
|
|
||||||
expect(source).toContain('class="compact-field compact-field--name"');
|
|
||||||
expect(source).toContain('class="compact-field compact-field--status"');
|
|
||||||
expect(source).toContain('class="compact-field compact-field--remark"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses a single-layer batch editor layout for training authorization creation", () => {
|
|
||||||
const source = read("./TrainingEditorDrawer.vue");
|
|
||||||
|
|
||||||
expect(source).toContain('class="form-section training-batch-editor"');
|
|
||||||
expect(source).toContain('class="batch-editor"');
|
|
||||||
expect(source).toContain('class="batch-entry"');
|
|
||||||
expect(source).toContain('class="status-inline"');
|
|
||||||
expect(source).toContain('class="status-date"');
|
|
||||||
expect(source).not.toContain('class="batch-row"');
|
|
||||||
expect(source).not.toContain(".batch-row {");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const read = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
|
|
||||||
|
|
||||||
describe("startup auth form/detail permissions", () => {
|
|
||||||
it("maps startup auth actions to backend operation permissions", () => {
|
|
||||||
const source = read("../../utils/permission.ts");
|
|
||||||
|
|
||||||
expect(source).toContain('"startup.auth.create": "startup_auth:create"');
|
|
||||||
expect(source).toContain('"startup.auth.update": "startup_auth:update"');
|
|
||||||
expect(source).toContain('"startup.auth.delete": "startup_auth:delete"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses create/update permissions in kickoff and training forms", () => {
|
|
||||||
const kickoffForm = read("./KickoffForm.vue");
|
|
||||||
const trainingForm = read("./TrainingForm.vue");
|
|
||||||
|
|
||||||
expect(kickoffForm).toContain('const canCreateAuth = computed(() => can("startup.auth.create"))');
|
|
||||||
expect(kickoffForm).toContain('const canUpdateAuth = computed(() => can("startup.auth.update"))');
|
|
||||||
expect(kickoffForm).toContain("const canSaveAuth = computed(() => (isEdit.value ? canUpdateAuth.value : canCreateAuth.value))");
|
|
||||||
expect(kickoffForm).toContain("if (!canSaveAuth.value)");
|
|
||||||
expect(trainingForm).toContain('const canCreateAuth = computed(() => can("startup.auth.create"))');
|
|
||||||
expect(trainingForm).toContain('const canUpdateAuth = computed(() => can("startup.auth.update"))');
|
|
||||||
expect(trainingForm).toContain("const canSaveAuth = computed(() => (isEdit.value ? canUpdateAuth.value : canCreateAuth.value))");
|
|
||||||
expect(trainingForm).toContain("if (!canSaveAuth.value)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens kickoff detail edits in a drawer and keeps training actions split by backend operations", () => {
|
|
||||||
const source = read("./KickoffDetail.vue");
|
|
||||||
|
|
||||||
expect(source).toContain("<KickoffEditorDrawer");
|
|
||||||
expect(source).toContain("<TrainingEditorDrawer");
|
|
||||||
expect(source).toContain("editorVisible.value = true");
|
|
||||||
expect(source).toContain("trainingEditorVisible.value = true");
|
|
||||||
expect(source).toContain("const handleEditorSaved = () =>");
|
|
||||||
expect(source).toContain(":refresh-key=\"attachmentRefreshKey\"");
|
|
||||||
expect(source).toContain("attachmentRefreshKey.value += 1");
|
|
||||||
expect(source).toContain("const handleTrainingEditorSaved = () =>");
|
|
||||||
expect(source).toContain('const canCreateAuth = computed(() => can("startup.auth.create"))');
|
|
||||||
expect(source).toContain('const canUpdateAuth = computed(() => can("startup.auth.update"))');
|
|
||||||
expect(source).toContain('const canDeleteAuth = computed(() => can("startup.auth.delete"))');
|
|
||||||
expect(source).toContain('v-if="canUpdateAuth"');
|
|
||||||
expect(source).toContain('v-if="canCreateAuth || canUpdateAuth || canDeleteAuth"');
|
|
||||||
expect(source).toContain("if (!canCreateAuth.value)");
|
|
||||||
expect(source).toContain("if (!canUpdateAuth.value)");
|
|
||||||
expect(source).toContain("if (!canDeleteAuth.value)");
|
|
||||||
expect(source).not.toContain("trainingRowDraft");
|
|
||||||
expect(source).not.toContain("isTrainingEditing");
|
|
||||||
expect(source).not.toContain("saveTraining");
|
|
||||||
expect(source).not.toContain("cancelTrainingEdit");
|
|
||||||
expect(source).not.toContain("editMode");
|
|
||||||
expect(source).not.toContain("saveEdit");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens training detail edits in a drawer without navigating to the old edit page", () => {
|
|
||||||
const source = read("./TrainingDetail.vue");
|
|
||||||
|
|
||||||
expect(source).toContain("<TrainingEditorDrawer");
|
|
||||||
expect(source).toContain("editorVisible.value = true");
|
|
||||||
expect(source).toContain("const handleEditorSaved = () =>");
|
|
||||||
expect(source).toContain('const canUpdateAuth = computed(() => can("startup.auth.update"))');
|
|
||||||
expect(source).toContain('v-if="canUpdateAuth"');
|
|
||||||
expect(source).toContain("if (!canUpdateAuth.value)");
|
|
||||||
expect(source).not.toContain("AttachmentList");
|
|
||||||
expect(source).not.toContain("training_authorization");
|
|
||||||
expect(source).not.toContain("attachmentRefreshKey");
|
|
||||||
expect(source).not.toContain("router.push(`/startup/training/${recordId}/edit`)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders kickoff attachments as one aggregated table with attachment type labels", () => {
|
|
||||||
const source = read("./KickoffDetail.vue");
|
|
||||||
|
|
||||||
expect(source).toContain(":entity-groups=\"kickoffAttachmentGroups\"");
|
|
||||||
expect(source).not.toContain("v-for=\"group in kickoffAttachmentGroups\"");
|
|
||||||
expect(source).toContain('label: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel');
|
|
||||||
expect(source).toContain('label: TEXT.modules.startupMeetingAuth.kickoffSignInLabel');
|
|
||||||
expect(source).toContain('label: TEXT.modules.startupMeetingAuth.kickoffPptLabel');
|
|
||||||
expect(source).toContain('label: TEXT.modules.startupMeetingAuth.kickoffOtherLabel');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows legacy kickoff and training forms to queue attachments before saving", () => {
|
|
||||||
const kickoffForm = read("./KickoffForm.vue");
|
|
||||||
const trainingForm = read("./TrainingForm.vue");
|
|
||||||
|
|
||||||
expect(kickoffForm).toContain("<AttachmentList");
|
|
||||||
expect(kickoffForm).toContain('ref="attachmentPanelRef"');
|
|
||||||
expect(kickoffForm).toContain(":entity-groups=\"kickoffAttachmentGroups\"");
|
|
||||||
expect(kickoffForm).toContain(':entity-id="meetingId || \'\'"');
|
|
||||||
expect(kickoffForm).toContain(':mode="\'upload\'"');
|
|
||||||
expect(kickoffForm).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
|
|
||||||
expect(kickoffForm).not.toContain('v-for="group in kickoffAttachmentGroups"');
|
|
||||||
expect(kickoffForm).not.toContain("StateEmpty");
|
|
||||||
|
|
||||||
expect(trainingForm).toContain("<AttachmentList");
|
|
||||||
expect(trainingForm).toContain('ref="attachmentPanelRef"');
|
|
||||||
expect(trainingForm).toContain('entity-type="training_authorization"');
|
|
||||||
expect(trainingForm).toContain(':entity-id="recordId || \'\'"');
|
|
||||||
expect(trainingForm).toContain(':mode="\'upload\'"');
|
|
||||||
expect(trainingForm).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
|
|
||||||
expect(trainingForm).not.toContain('v-if="isEdit && recordId"');
|
|
||||||
expect(trainingForm).not.toContain("StateEmpty");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows training and authorization dates under positive status in the training table", () => {
|
|
||||||
const source = read("./KickoffDetail.vue");
|
|
||||||
|
|
||||||
expect(source).toContain("status-dot--yes");
|
|
||||||
expect(source).toContain("status-dot--no");
|
|
||||||
expect(source).toContain("scope.row.trained && scope.row.trained_date");
|
|
||||||
expect(source).toContain("scope.row.authorized && scope.row.authorized_date");
|
|
||||||
expect(source).toContain('class="status-date"');
|
|
||||||
expect(source).toContain("scope.row.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no");
|
|
||||||
expect(source).toContain("scope.row.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("loads training authorizations for the current kickoff site only", () => {
|
|
||||||
const source = read("./KickoffDetail.vue");
|
|
||||||
|
|
||||||
expect(source).toContain("if (!studyId || !siteInfo.name) return;");
|
|
||||||
expect(source).toContain("listTrainingAuthorizations(studyId, { site_name: siteInfo.name })");
|
|
||||||
expect(source).not.toContain("listTrainingAuthorizations(studyId);");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const read = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
|
|
||||||
|
|
||||||
describe("startup initiation and ethics form/detail permissions", () => {
|
|
||||||
it("uses startup initiation create/update permissions in feasibility form and detail", () => {
|
|
||||||
const formSource = read("./FeasibilityForm.vue");
|
|
||||||
const detailSource = read("./FeasibilityDetail.vue");
|
|
||||||
|
|
||||||
expect(formSource).toContain('const canCreateInitiation = computed(() => can("startup.initiation.create"))');
|
|
||||||
expect(formSource).toContain('const canUpdateInitiation = computed(() => can("startup.initiation.update"))');
|
|
||||||
expect(formSource).toContain("const canSaveInitiation = computed(() => (isEdit.value ? canUpdateInitiation.value : canCreateInitiation.value))");
|
|
||||||
expect(formSource).toContain("if (!canSaveInitiation.value)");
|
|
||||||
expect(detailSource).toContain('const canUpdateInitiation = computed(() => can("startup.initiation.update"))');
|
|
||||||
expect(detailSource).toContain('v-if="canUpdateInitiation"');
|
|
||||||
expect(detailSource).toContain("if (!canUpdateInitiation.value)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses startup ethics create/update permissions in ethics form and detail", () => {
|
|
||||||
const formSource = read("./EthicsForm.vue");
|
|
||||||
const detailSource = read("./EthicsDetail.vue");
|
|
||||||
|
|
||||||
expect(formSource).toContain('const canCreateEthics = computed(() => can("startup.ethics.create"))');
|
|
||||||
expect(formSource).toContain('const canUpdateEthics = computed(() => can("startup.ethics.update"))');
|
|
||||||
expect(formSource).toContain("const canSaveEthics = computed(() => (isEdit.value ? canUpdateEthics.value : canCreateEthics.value))");
|
|
||||||
expect(formSource).toContain("if (!canSaveEthics.value)");
|
|
||||||
expect(detailSource).toContain('const canUpdateEthics = computed(() => can("startup.ethics.update"))');
|
|
||||||
expect(detailSource).toContain('v-if="canUpdateEthics"');
|
|
||||||
expect(detailSource).toContain("if (!canUpdateEthics.value)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps detail attachment areas display-only and moves upload into editor drawers", () => {
|
|
||||||
const feasibilityDetail = read("./FeasibilityDetail.vue");
|
|
||||||
const ethicsDetail = read("./EthicsDetail.vue");
|
|
||||||
const feasibilityDrawer = read("./FeasibilityEditorDrawer.vue");
|
|
||||||
const ethicsDrawer = read("./EthicsEditorDrawer.vue");
|
|
||||||
|
|
||||||
expect(feasibilityDetail).toContain(":hide-uploader=\"true\"");
|
|
||||||
expect(ethicsDetail).toContain(":hide-uploader=\"true\"");
|
|
||||||
expect(feasibilityDrawer).toContain("AttachmentList");
|
|
||||||
expect(feasibilityDrawer).toContain('ref="attachmentPanelRef"');
|
|
||||||
expect(feasibilityDrawer).toContain('entity-type="startup_feasibility"');
|
|
||||||
expect(feasibilityDrawer).toContain(":entity-id=\"recordId || ''\"");
|
|
||||||
expect(feasibilityDrawer).toContain(':mode="\'upload\'"');
|
|
||||||
expect(feasibilityDrawer).toContain("attachments: attachmentPanelRef.value?.pendingSnapshot() || []");
|
|
||||||
expect(feasibilityDrawer).toContain("await attachmentPanelRef.value?.uploadPending(id)");
|
|
||||||
expect(feasibilityDrawer).not.toContain("PendingAttachmentUpload");
|
|
||||||
expect(ethicsDrawer).toContain("AttachmentList");
|
|
||||||
expect(ethicsDrawer).toContain('ref="attachmentPanelRef"');
|
|
||||||
expect(ethicsDrawer).toContain('entity-type="startup_ethics"');
|
|
||||||
expect(ethicsDrawer).toContain(":entity-id=\"recordId || ''\"");
|
|
||||||
expect(ethicsDrawer).toContain(':mode="\'upload\'"');
|
|
||||||
expect(ethicsDrawer).toContain("attachments: attachmentPanelRef.value?.pendingSnapshot() || []");
|
|
||||||
expect(ethicsDrawer).toContain("await attachmentPanelRef.value?.uploadPending(id)");
|
|
||||||
expect(ethicsDrawer).not.toContain("PendingAttachmentUpload");
|
|
||||||
expect(feasibilityDrawer).not.toContain('class="attachment-group"');
|
|
||||||
expect(ethicsDrawer).not.toContain('class="attachment-group"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows legacy feasibility and ethics forms to queue attachments before the record exists", () => {
|
|
||||||
const feasibilityForm = read("./FeasibilityForm.vue");
|
|
||||||
const ethicsForm = read("./EthicsForm.vue");
|
|
||||||
|
|
||||||
expect(feasibilityForm).toContain("<AttachmentList");
|
|
||||||
expect(feasibilityForm).toContain('ref="attachmentPanelRef"');
|
|
||||||
expect(feasibilityForm).toContain('entity-type="startup_feasibility"');
|
|
||||||
expect(feasibilityForm).toContain(':entity-id="recordId || \'\'"');
|
|
||||||
expect(feasibilityForm).toContain(':mode="\'upload\'"');
|
|
||||||
expect(feasibilityForm).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
|
|
||||||
expect(feasibilityForm).not.toContain('v-if="isEdit && recordId"');
|
|
||||||
expect(feasibilityForm).not.toContain("StateEmpty");
|
|
||||||
|
|
||||||
expect(ethicsForm).toContain("<AttachmentList");
|
|
||||||
expect(ethicsForm).toContain('ref="attachmentPanelRef"');
|
|
||||||
expect(ethicsForm).toContain('entity-type="startup_ethics"');
|
|
||||||
expect(ethicsForm).toContain(':entity-id="recordId || \'\'"');
|
|
||||||
expect(ethicsForm).toContain(':mode="\'upload\'"');
|
|
||||||
expect(ethicsForm).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
|
|
||||||
expect(ethicsForm).not.toContain('v-if="isEdit && recordId"');
|
|
||||||
expect(ethicsForm).not.toContain("StateEmpty");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSubjectEditorDrawer = () => readFileSync(resolve(__dirname, "./SubjectEditorDrawer.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("SubjectEditorDrawer screening date editing", () => {
|
|
||||||
it("keeps screening date editable when editing an existing participant", () => {
|
|
||||||
const source = readSubjectEditorDrawer();
|
|
||||||
const screeningDateField = source.slice(
|
|
||||||
source.indexOf('v-model="form.screening_date"'),
|
|
||||||
source.indexOf('v-model="form.consent_date"')
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screeningDateField).toContain(':disabled="isReadOnly"');
|
|
||||||
expect(screeningDateField).not.toContain(':disabled="isEdit || isReadOnly"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("submits screening date in the edit payload", () => {
|
|
||||||
const source = readSubjectEditorDrawer();
|
|
||||||
const editPayload = source.slice(
|
|
||||||
source.indexOf("await updateSubject(studyId.value, props.subjectId, {"),
|
|
||||||
source.indexOf("});", source.indexOf("await updateSubject(studyId.value, props.subjectId, {"))
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(editPayload).toContain("screening_date: form.screening_date || null");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const readSubjectForm = () => readFileSync(resolve(__dirname, "./SubjectForm.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("SubjectForm screening date editing", () => {
|
|
||||||
it("keeps screening date editable when editing an existing participant", () => {
|
|
||||||
const source = readSubjectForm();
|
|
||||||
const screeningDateField = source.slice(
|
|
||||||
source.indexOf('v-model="form.screening_date"'),
|
|
||||||
source.indexOf('v-model="form.consent_date"')
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screeningDateField).toContain(':disabled="isReadOnly"');
|
|
||||||
expect(screeningDateField).not.toContain(':disabled="isEdit || isReadOnly"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("submits screening date in the edit payload", () => {
|
|
||||||
const source = readSubjectForm();
|
|
||||||
const editPayload = source.slice(
|
|
||||||
source.indexOf("const payload = {"),
|
|
||||||
source.indexOf("};", source.indexOf("const payload = {"))
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(editPayload).toContain("screening_date: form.screening_date || null");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user