import uuid from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, Mock import pytest from app.models.notification import Notification from app.models.collaboration import CollaborationEditRequest from app.services import collaboration_service, notification_service, project_reminder_service def test_generic_notification_table_has_recipient_dedupe_and_state_indexes(): table = Notification.__table__ assert table.name == "notifications" assert {"study_id", "recipient_id", "category", "action_path", "source_type", "source_id", "read_at", "resolved_at"} <= set(table.columns.keys()) assert any(constraint.name == "uq_notifications_recipient_dedupe" for constraint in table.constraints) assert {index.name for index in table.indexes} >= { "ix_notifications_recipient_study_state", "ix_notifications_source", } @pytest.mark.asyncio async def test_recipient_notification_creation_is_deduplicated_per_recipient(): existing_id = uuid.uuid4() new_id = uuid.uuid4() result = SimpleNamespace(all=lambda: [existing_id]) db = SimpleNamespace(scalars=AsyncMock(return_value=result), add=Mock()) await notification_service.create_recipient_notifications( db, study_id=uuid.uuid4(), recipient_ids=[existing_id, new_id, new_id], category="COLLABORATION_EDIT_REQUEST", priority="NORMAL", title="新的编辑权限申请", message="申请编辑文件", action_path="/knowledge/collaboration?editRequestFile=file-id", source_type="COLLABORATION_EDIT_REQUEST", source_id="request-id", dedupe_key="collaboration-edit-request:request-id", ) db.add.assert_called_once() created = db.add.call_args.args[0] assert created.recipient_id == new_id assert created.source_id == "request-id" @pytest.mark.asyncio async def test_resolving_a_source_closes_every_recipient_notification(): db = SimpleNamespace(execute=AsyncMock()) await notification_service.resolve_source_notifications( db, source_type="COLLABORATION_EDIT_REQUEST", source_id="request-id", ) db.execute.assert_awaited_once() statement = str(db.execute.await_args.args[0]) assert "UPDATE notifications" in statement assert "source_type" in statement assert "source_id" in statement @pytest.mark.asyncio async def test_project_risks_are_materialized_into_the_generic_notification_table(monkeypatch): study_id = uuid.uuid4() user = SimpleNamespace(id=uuid.uuid4(), is_admin=False) membership = SimpleNamespace(is_active=True, role_in_study="PM") db = SimpleNamespace(commit=AsyncMock()) sync = AsyncMock() monkeypatch.setattr(project_reminder_service.member_crud, "get_member", AsyncMock(return_value=membership)) monkeypatch.setattr(project_reminder_service, "role_has_api_permission", AsyncMock(return_value=True)) monkeypatch.setattr(project_reminder_service, "get_cra_site_scope", AsyncMock(return_value=None)) monkeypatch.setattr(project_reminder_service.ae_crud, "list_ae", AsyncMock(return_value=[object(), object()])) monkeypatch.setattr(project_reminder_service.monitoring_issue_crud, "list_issues", AsyncMock(return_value=[object()])) monkeypatch.setattr(project_reminder_service.notification_service, "sync_aggregate_notification", sync) await project_reminder_service.sync_project_reminders(db, study_id, user) assert sync.await_count == 2 assert sync.await_args_list[0].kwargs["count"] == 2 assert sync.await_args_list[1].kwargs["count"] == 1 db.commit.assert_awaited_once() @pytest.mark.asyncio async def test_edit_request_notifies_the_active_file_owner_and_managers(monkeypatch): study_id = uuid.uuid4() file_id = uuid.uuid4() owner_id = uuid.uuid4() manager_id = uuid.uuid4() request_id = uuid.uuid4() item = SimpleNamespace( id=file_id, study_id=study_id, owner_id=owner_id, title="PK_示例.xlsx", allow_edit_request=True, ) user = SimpleNamespace(id=uuid.uuid4(), full_name="周成成", email="member@example.com") added = [] def add(value): added.append(value) async def flush(): request = next(value for value in added if isinstance(value, CollaborationEditRequest)) request.id = request_id request.status = "PENDING" request.resolved_by = None request.resolved_at = None request.created_at = datetime.now(timezone.utc) db = SimpleNamespace( scalar=AsyncMock(return_value=None), scalars=AsyncMock(return_value=SimpleNamespace(all=lambda: [owner_id, manager_id])), add=Mock(side_effect=add), flush=AsyncMock(side_effect=flush), commit=AsyncMock(), refresh=AsyncMock(), ) notify = AsyncMock() monkeypatch.setattr(collaboration_service, "can_edit_file", AsyncMock(return_value=False)) monkeypatch.setattr( collaboration_service.member_crud, "get_member", AsyncMock(return_value=SimpleNamespace(is_active=True)), ) monkeypatch.setattr(collaboration_service, "_audit", AsyncMock()) monkeypatch.setattr(collaboration_service.notification_service, "create_recipient_notifications", notify) result = await collaboration_service.create_edit_request(db, item, user) assert result.id == request_id assert set(notify.await_args.kwargs["recipient_ids"]) == {owner_id, manager_id} assert notify.await_args.kwargs["action_path"] == f"/knowledge/collaboration?editRequestFile={file_id}" assert notify.await_args.kwargs["source_id"] == str(request_id)