"""Tests for notification-to-communication consolidation (B-NOTIF-* + B-NOTIF-TEST).""" from __future__ import annotations import uuid from datetime import UTC, datetime import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.notifications import create_notification, post_system_message from app.models.notification import Notification, NotificationPreference, NotificationType from app.plugins.builtins.kommunikation.models import ( CommConversation, CommMessage, CommMessageBlock, CommMessageRead, CommParticipant, ) from app.plugins.builtins.kommunikation.services import get_or_create_system_channel from tests.conftest import seed_tenant_and_users @pytest.mark.asyncio class TestSystemChannel: """B-NOTIF-SYS: System channel creation and properties.""" async def test_get_or_create_system_channel_creates_channel(self, db_session: AsyncSession): """get_or_create_system_channel creates a system channel with is_system=True.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id conv = await get_or_create_system_channel(db_session, tenant_id) await db_session.flush() assert conv is not None assert conv.is_system is True assert conv.is_locked is True assert conv.title == "System Channel" assert conv.created_by_type == "system" assert conv.tenant_id == tenant_id async def test_get_or_create_system_channel_is_idempotent(self, db_session: AsyncSession): """Calling get_or_create_system_channel twice returns the same channel.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id conv1 = await get_or_create_system_channel(db_session, tenant_id) await db_session.flush() conv2 = await get_or_create_system_channel(db_session, tenant_id) await db_session.flush() assert conv1.id == conv2.id async def test_system_channel_has_all_users_as_participants(self, db_session: AsyncSession): """All tenant users are automatically added as participants.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id conv = await get_or_create_system_channel(db_session, tenant_id) await db_session.flush() result = await db_session.execute( select(CommParticipant).where( CommParticipant.conversation_id == conv.id, CommParticipant.left_at.is_(None), ) ) participants = result.scalars().all() participant_ids = {p.participant_id for p in participants} # admin_a, viewer_a, editor_a are all in tenant_a assert seed["admin_a"].id in participant_ids assert seed["viewer_a"].id in participant_ids assert seed["editor_a"].id in participant_ids async def test_system_channel_is_tenant_scoped(self, db_session: AsyncSession): """Different tenants get different system channels.""" seed = await seed_tenant_and_users(db_session) conv_a = await get_or_create_system_channel(db_session, seed["tenant_a"].id) conv_b = await get_or_create_system_channel(db_session, seed["tenant_b"].id) await db_session.flush() assert conv_a.id != conv_b.id assert conv_a.tenant_id == seed["tenant_a"].id assert conv_b.tenant_id == seed["tenant_b"].id @pytest.mark.asyncio class TestPostSystemMessage: """B-NOTIF-EVT: post_system_message creates typed system messages.""" async def test_post_system_message_creates_comm_message(self, db_session: AsyncSession): """post_system_message creates a CommMessage in the system channel.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id msg = await post_system_message( db_session, tenant_id, user_id, message_type="info", title="Test Notification", body="This is a test body", ) await db_session.flush() assert msg is not None assert msg.sender_type == "system" assert "Test Notification" in msg.content assert "This is a test body" in msg.content assert msg.metadata_["notification_type"] == "info" assert msg.metadata_["severity"] == "info" assert msg.metadata_["target_user_id"] == str(user_id) async def test_post_system_message_creates_text_block(self, db_session: AsyncSession): """post_system_message creates a text block with title and body.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id msg = await post_system_message( db_session, tenant_id, user_id, message_type="info", title="Block Test", body="Block body", ) await db_session.flush() result = await db_session.execute( select(CommMessageBlock).where( CommMessageBlock.message_id == msg.id, ).order_by(CommMessageBlock.sort_order) ) blocks = result.scalars().all() assert len(blocks) >= 1 text_block = blocks[0] assert text_block.block_type == "text" assert text_block.block_data["title"] == "Block Test" assert text_block.block_data["body"] == "Block body" async def test_post_system_message_with_entity_creates_action_card(self, db_session: AsyncSession): """post_system_message with entity_type/entity_id creates an action_card block.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id entity_id = seed["company_a"].id msg = await post_system_message( db_session, tenant_id, user_id, message_type="info", title="Entity Notification", body="Check this entity", entity_type="contact", entity_id=entity_id, ) await db_session.flush() result = await db_session.execute( select(CommMessageBlock).where( CommMessageBlock.message_id == msg.id, ).order_by(CommMessageBlock.sort_order) ) blocks = result.scalars().all() assert len(blocks) == 2 assert blocks[0].block_type == "text" assert blocks[1].block_type == "action_card" assert blocks[1].block_data["entity_type"] == "contact" assert blocks[1].block_data["entity_id"] == str(entity_id) # Check metadata entity_ref assert msg.metadata_["entity_ref"]["entity_type"] == "contact" assert msg.metadata_["entity_ref"]["entity_id"] == str(entity_id) async def test_post_system_message_severity_in_metadata(self, db_session: AsyncSession): """post_system_message stores severity in message metadata.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id msg = await post_system_message( db_session, tenant_id, user_id, message_type="alert", title="Critical Alert", body="Something happened", severity="critical", ) await db_session.flush() assert msg.metadata_["severity"] == "critical" assert msg.metadata_["notification_type"] == "alert" @pytest.mark.asyncio class TestCreateNotificationWrapper: """B-NOTIF-EVT: create_notification delegates to post_system_message.""" async def test_create_notification_delegates_to_post_system_message(self, db_session: AsyncSession): """create_notification creates both a CommMessage and a legacy Notification.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id notif = await create_notification( db_session, tenant_id, user_id, type="info", title="Wrapper Test", body="Wrapper body", ) await db_session.flush() # Legacy notification created assert notif is not None assert notif.title == "Wrapper Test" assert notif.type == "info" # CommMessage also created in system channel result = await db_session.execute( select(CommMessage).where( CommMessage.tenant_id == tenant_id, CommMessage.sender_type == "system", ) ) comm_msgs = result.scalars().all() assert len(comm_msgs) >= 1 assert any("Wrapper Test" in m.content for m in comm_msgs) async def test_create_notification_returns_none_when_muted(self, db_session: AsyncSession): """create_notification returns None when user has disabled the type.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id # Create a notification type and disable it for the user ntype = NotificationType( type_key="muted_type", plugin_name="test", category="general", label="Muted Type", is_enabled_by_default=True, ) db_session.add(ntype) await db_session.flush() pref = NotificationPreference( tenant_id=tenant_id, user_id=user_id, type_key="muted_type", is_enabled=False, ) db_session.add(pref) await db_session.flush() notif = await create_notification( db_session, tenant_id, user_id, type="muted_type", title="Should Not Appear", body="Should be muted", ) await db_session.flush() assert notif is None # Verify no CommMessage was created with this title result = await db_session.execute( select(CommMessage).where( CommMessage.tenant_id == tenant_id, CommMessage.content.like("%Should Not Appear%"), ) ) comm_msgs = result.scalars().all() assert len(comm_msgs) == 0 @pytest.mark.asyncio class TestNotificationPreferences: """B-NOTIF-PREF: Delivery preferences are respected.""" async def test_post_system_message_respects_muted_preference(self, db_session: AsyncSession): """post_system_message returns None when user has muted the type.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id ntype = NotificationType( type_key="muted_alert", plugin_name="test", category="general", label="Muted Alert", is_enabled_by_default=True, ) db_session.add(ntype) await db_session.flush() pref = NotificationPreference( tenant_id=tenant_id, user_id=user_id, type_key="muted_alert", is_enabled=False, ) db_session.add(pref) await db_session.flush() msg = await post_system_message( db_session, tenant_id, user_id, message_type="muted_alert", title="Muted Message", body="Should not be posted", ) await db_session.flush() assert msg is None async def test_post_system_message_respects_disabled_by_default(self, db_session: AsyncSession): """post_system_message returns None when type is disabled by default and no explicit preference.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id ntype = NotificationType( type_key="disabled_default", plugin_name="test", category="general", label="Disabled By Default", is_enabled_by_default=False, ) db_session.add(ntype) await db_session.flush() msg = await post_system_message( db_session, tenant_id, user_id, message_type="disabled_default", title="Should Not Appear", body="Disabled by default", ) await db_session.flush() assert msg is None async def test_post_system_message_with_enabled_preference(self, db_session: AsyncSession): """post_system_message creates message when preference is enabled.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id ntype = NotificationType( type_key="enabled_type", plugin_name="test", category="general", label="Enabled Type", is_enabled_by_default=False, ) db_session.add(ntype) await db_session.flush() pref = NotificationPreference( tenant_id=tenant_id, user_id=user_id, type_key="enabled_type", is_enabled=True, ) db_session.add(pref) await db_session.flush() msg = await post_system_message( db_session, tenant_id, user_id, message_type="enabled_type", title="Enabled Message", body="Should be posted", ) await db_session.flush() assert msg is not None assert "Enabled Message" in msg.content @pytest.mark.asyncio class TestUnreadBadge: """B-NOTIF-TEST: Unread badge via CommMessageRead.""" async def test_system_message_unread_for_new_user(self, db_session: AsyncSession): """New system messages are unread (no CommMessageRead entry).""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id msg = await post_system_message( db_session, tenant_id, user_id, message_type="info", title="Unread Test", body="Should be unread", ) await db_session.flush() # Check no CommMessageRead exists for this user+conversation result = await db_session.execute( select(CommMessageRead).where( CommMessageRead.conversation_id == msg.conversation_id, CommMessageRead.user_id == user_id, ) ) read_state = result.scalar_one_or_none() assert read_state is None # No read state = unread async def test_mark_system_message_read_creates_read_state(self, db_session: AsyncSession): """Marking a system message read creates CommMessageRead entry.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id msg = await post_system_message( db_session, tenant_id, user_id, message_type="info", title="Read Test", body="Will be marked read", ) await db_session.flush() # Create read state read_state = CommMessageRead( tenant_id=tenant_id, conversation_id=msg.conversation_id, user_id=user_id, last_read_msg_id=msg.id, last_read_at=datetime.now(UTC), ) db_session.add(read_state) await db_session.flush() # Verify read state exists result = await db_session.execute( select(CommMessageRead).where( CommMessageRead.conversation_id == msg.conversation_id, CommMessageRead.user_id == user_id, ) ) saved = result.scalar_one_or_none() assert saved is not None assert saved.last_read_msg_id == msg.id @pytest.mark.asyncio class TestNotificationMigrationMapping: """B-NOTIF-MIG: Migration mapping from Notification to CommMessage.""" async def test_notification_fields_map_to_comm_message(self, db_session: AsyncSession): """Notification fields map correctly to CommMessage metadata and blocks.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id entity_id = seed["company_a"].id # Create a notification via the wrapper (which creates both legacy + comm) notif = await create_notification( db_session, tenant_id, user_id, type="mail_received", title="New Mail", body="You have a new mail", entity_type="contact", entity_id=entity_id, ) await db_session.flush() # Find the corresponding CommMessage result = await db_session.execute( select(CommMessage).where( CommMessage.tenant_id == tenant_id, CommMessage.sender_type == "system", ).order_by(CommMessage.created_at.desc()) ) comm_msg = result.scalars().first() assert comm_msg is not None # Field mapping checks assert comm_msg.metadata_["notification_type"] == "mail_received" assert comm_msg.metadata_["severity"] == "info" assert comm_msg.metadata_["entity_ref"]["entity_type"] == "contact" assert comm_msg.metadata_["entity_ref"]["entity_id"] == str(entity_id) # Check blocks blocks_result = await db_session.execute( select(CommMessageBlock).where( CommMessageBlock.message_id == comm_msg.id, ).order_by(CommMessageBlock.sort_order) ) blocks = blocks_result.scalars().all() assert len(blocks) == 2 # text + action_card assert blocks[0].block_type == "text" assert blocks[1].block_type == "action_card" async def test_notification_read_at_maps_to_comm_message_read(self, db_session: AsyncSession): """Notification.read_at maps to CommMessageRead entry.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id notif = await create_notification( db_session, tenant_id, user_id, type="info", title="Read Mapping Test", body="Will be read", ) await db_session.flush() # Mark notification as read notif.read_at = datetime.now(UTC) await db_session.flush() # Find the CommMessage result = await db_session.execute( select(CommMessage).where( CommMessage.tenant_id == tenant_id, CommMessage.sender_type == "system", ).order_by(CommMessage.created_at.desc()) ) comm_msg = result.scalars().first() assert comm_msg is not None # Create CommMessageRead (simulating what migration does) read_state = CommMessageRead( tenant_id=tenant_id, conversation_id=comm_msg.conversation_id, user_id=user_id, last_read_msg_id=comm_msg.id, last_read_at=notif.read_at, ) db_session.add(read_state) await db_session.flush() # Verify read_result = await db_session.execute( select(CommMessageRead).where( CommMessageRead.conversation_id == comm_msg.conversation_id, CommMessageRead.user_id == user_id, ) ) saved = read_result.scalar_one_or_none() assert saved is not None assert saved.last_read_at is not None @pytest.mark.asyncio class TestSensitiveFieldsExcluded: """B-NOTIF-TEST: Sensitive fields are excluded from system messages.""" async def test_no_password_or_secret_in_message(self, db_session: AsyncSession): """System messages do not contain password hashes or secret values.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id msg = await post_system_message( db_session, tenant_id, user_id, message_type="info", title="Security Test", body="Normal body text", ) await db_session.flush() # Check message content and metadata for sensitive patterns sensitive_patterns = ["password", "hash", "secret", "token", "api_key"] content_lower = msg.content.lower() metadata_str = str(msg.metadata_).lower() for pattern in sensitive_patterns: assert pattern not in content_lower, f"Sensitive pattern '{pattern}' found in content" assert pattern not in metadata_str, f"Sensitive pattern '{pattern}' found in metadata" async def test_blocks_exclude_sensitive_data(self, db_session: AsyncSession): """Message blocks do not contain sensitive data.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id msg = await post_system_message( db_session, tenant_id, user_id, message_type="info", title="Block Security Test", body="Normal block text", ) await db_session.flush() result = await db_session.execute( select(CommMessageBlock).where( CommMessageBlock.message_id == msg.id, ) ) blocks = result.scalars().all() for block in blocks: block_str = str(block.block_data).lower() assert "password" not in block_str assert "secret" not in block_str assert "token" not in block_str