"""Tests for the Command pattern — Create, Update, Delete, Merge contact commands. Tests cover: - Permission checks (viewer denied, admin allowed) - Business logic execution - Audit log entry creation - Outbox event enqueuing - State machine validation """ from __future__ import annotations import pytest import pytest_asyncio import uuid as uuid_mod from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.commands.base import CommandResult from app.commands.contact_commands import ( CreateContactCommand, UpdateContactCommand, DeleteContactCommand, MergeContactsCommand, ) from app.core.state_machine import ( StateMachine, StateMachineError, contact_state_machine, workflow_state_machine, ) from app.models.audit import AuditLog from app.models.contact import Contact from app.models.outbox import EventOutbox from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users # ── State Machine Unit Tests ── class TestStateMachine: """Unit tests for the StateMachine class.""" def test_can_transition_allowed(self): """Allowed transitions return True.""" assert contact_state_machine.can_transition("lead", "qualified") is True assert contact_state_machine.can_transition("qualified", "customer") is True assert contact_state_machine.can_transition("customer", "inactive") is True assert contact_state_machine.can_transition("inactive", "lead") is True def test_can_transition_disallowed(self): """Disallowed transitions return False.""" assert contact_state_machine.can_transition("customer", "lead") is False assert contact_state_machine.can_transition("inactive", "customer") is False def test_transition_success(self): """Valid transition returns the new state.""" assert contact_state_machine.transition("lead", "qualified") == "qualified" assert workflow_state_machine.transition("active", "paused") == "paused" def test_transition_invalid_raises(self): """Invalid transition raises StateMachineError.""" with pytest.raises(StateMachineError, match="Invalid state transition"): contact_state_machine.transition("customer", "lead") def test_transition_unknown_state_raises(self): """Transition from unknown state raises StateMachineError.""" with pytest.raises(StateMachineError, match="Invalid state transition"): contact_state_machine.transition("nonexistent", "lead") def test_workflow_state_machine_transitions(self): """Workflow state machine has correct transitions.""" assert workflow_state_machine.can_transition("draft", "active") is True assert workflow_state_machine.can_transition("active", "paused") is True assert workflow_state_machine.can_transition("paused", "active") is True assert workflow_state_machine.can_transition("active", "completed") is True assert workflow_state_machine.can_transition("active", "cancelled") is True assert workflow_state_machine.can_transition("paused", "cancelled") is True assert workflow_state_machine.can_transition("draft", "cancelled") is True assert workflow_state_machine.can_transition("completed", "active") is False assert workflow_state_machine.can_transition("cancelled", "active") is False def test_custom_state_machine(self): """Custom state machine with own transitions.""" sm = StateMachine({"a": ["b"], "b": ["c"], "c": []}) assert sm.can_transition("a", "b") is True assert sm.can_transition("b", "c") is True assert sm.can_transition("c", "a") is False assert sm.transition("a", "b") == "b" with pytest.raises(StateMachineError): sm.transition("c", "a") # ── CommandResult Tests ── class TestCommandResult: """Unit tests for CommandResult.""" def test_ok_result(self): """ok() creates a successful result.""" result = CommandResult.ok(data={"id": "123"}) assert result.success is True assert result.data == {"id": "123"} assert result.error is None assert result.events == [] def test_ok_with_events(self): """ok() with events creates a successful result with events.""" events = [{"event": "contact.created"}] result = CommandResult.ok(data={}, events=events) assert result.events == events def test_fail_result(self): """fail() creates a failed result.""" result = CommandResult.fail("Something went wrong") assert result.success is False assert result.data is None assert result.error == "Something went wrong" assert result.events == [] # ── CreateContactCommand Tests ── @pytest.mark.asyncio class TestCreateContactCommand: """Tests for CreateContactCommand.""" async def test_create_contact_admin_success( self, client, db_session: AsyncSession, redis_client ): """Admin can create a contact, audit log is created, outbox event enqueued.""" seed = await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") # Use command directly with db_session and redis_client current_user = { "user_id": str(seed["admin_a"].id), "tenant_id": str(seed["tenant_a"].id), "role": "admin", "is_system_admin": True, "permissions": ["*: *"], "denied_permissions": [], "field_permissions": {}, } cmd = CreateContactCommand(data={ "type": "company", "name": "Test Company", "email_1": "test@example.com", }) result = await cmd.execute(db_session, redis_client, current_user) assert result.success is True assert result.data is not None assert result.data["name"] == "Test Company" assert result.data["displayname"] == "Test Company" assert len(result.events) >= 1 assert result.events[0]["event"] == "contact.created" # Verify audit log was created await db_session.flush() audit_q = select(AuditLog).where( AuditLog.entity_type == "contact", AuditLog.action == "create", AuditLog.user_id == seed["admin_a"].id, ) audit_result = await db_session.execute(audit_q) audit_entries = audit_result.scalars().all() assert len(audit_entries) >= 1 assert audit_entries[0].entity_id == uuid_mod.UUID(result.data["id"]) # Verify outbox event was enqueued outbox_q = select(EventOutbox).where( EventOutbox.event_name == "contact.created" ) outbox_result = await db_session.execute(outbox_q) outbox_entries = outbox_result.scalars().all() assert len(outbox_entries) >= 1 async def test_create_contact_viewer_denied( self, db_session: AsyncSession, redis_client ): """Viewer cannot create a contact (permission denied).""" seed = await seed_tenant_and_users(db_session) current_user = { "user_id": str(seed["viewer_a"].id), "tenant_id": str(seed["tenant_a"].id), "role": "viewer", "is_system_admin": False, "permissions": ["contacts:read"], "denied_permissions": [], "field_permissions": {}, } cmd = CreateContactCommand(data={ "type": "company", "name": "Denied Company", }) result = await cmd.execute(db_session, redis_client, current_user) assert result.success is False assert "Permission denied" in result.error async def test_create_contact_with_invalid_status( self, db_session: AsyncSession, redis_client ): """Creating a contact with invalid status fails.""" seed = await seed_tenant_and_users(db_session) current_user = { "user_id": str(seed["admin_a"].id), "tenant_id": str(seed["tenant_a"].id), "role": "admin", "is_system_admin": True, "permissions": ["*: *"], "denied_permissions": [], "field_permissions": {}, } cmd = CreateContactCommand(data={ "type": "company", "name": "Bad Status Corp", "status": "nonexistent", }) result = await cmd.execute(db_session, redis_client, current_user) assert result.success is False assert "Invalid contact status" in result.error # ── UpdateContactCommand Tests ── @pytest.mark.asyncio class TestUpdateContactCommand: """Tests for UpdateContactCommand.""" async def test_update_contact_admin_success( self, db_session: AsyncSession, redis_client ): """Admin can update a contact, audit log created, outbox event enqueued.""" seed = await seed_tenant_and_users(db_session) current_user = { "user_id": str(seed["admin_a"].id), "tenant_id": str(seed["tenant_a"].id), "role": "admin", "is_system_admin": True, "permissions": ["*: *"], "denied_permissions": [], "field_permissions": {}, } # First create a contact create_cmd = CreateContactCommand(data={ "type": "company", "name": "Original Name", }) create_result = await create_cmd.execute(db_session, redis_client, current_user) assert create_result.success is True contact_id = create_result.data["id"] # Update the contact update_cmd = UpdateContactCommand( contact_id=contact_id, data={"name": "Updated Name", "email_1": "updated@example.com"}, ) update_result = await update_cmd.execute(db_session, redis_client, current_user) assert update_result.success is True assert update_result.data["name"] == "Updated Name" assert update_result.data["email_1"] == "updated@example.com" assert len(update_result.events) >= 1 assert update_result.events[0]["event"] == "contact.updated" # Verify audit log await db_session.flush() audit_q = select(AuditLog).where( AuditLog.entity_type == "contact", AuditLog.action == "update", AuditLog.entity_id == uuid_mod.UUID(contact_id), ) audit_result = await db_session.execute(audit_q) audit_entries = audit_result.scalars().all() assert len(audit_entries) >= 1 async def test_update_contact_not_found( self, db_session: AsyncSession, redis_client ): """Updating a non-existent contact fails gracefully.""" seed = await seed_tenant_and_users(db_session) current_user = { "user_id": str(seed["admin_a"].id), "tenant_id": str(seed["tenant_a"].id), "role": "admin", "is_system_admin": True, "permissions": ["*: *"], "denied_permissions": [], "field_permissions": {}, } random_id = str(uuid_mod.uuid4()) update_cmd = UpdateContactCommand( contact_id=random_id, data={"name": "New Name"}, ) result = await update_cmd.execute(db_session, redis_client, current_user) assert result.success is False assert "not found" in result.error.lower() async def test_update_contact_viewer_denied( self, db_session: AsyncSession, redis_client ): """Viewer cannot update a contact.""" seed = await seed_tenant_and_users(db_session) current_user = { "user_id": str(seed["viewer_a"].id), "tenant_id": str(seed["tenant_a"].id), "role": "viewer", "is_system_admin": False, "permissions": ["contacts:read"], "denied_permissions": [], "field_permissions": {}, } cmd = UpdateContactCommand( contact_id=str(seed["company_a"].id), data={"name": "Hacked"}, ) result = await cmd.execute(db_session, redis_client, current_user) assert result.success is False assert "Permission denied" in result.error # ── DeleteContactCommand Tests ── @pytest.mark.asyncio class TestDeleteContactCommand: """Tests for DeleteContactCommand.""" async def test_soft_delete_contact_admin_success( self, db_session: AsyncSession, redis_client ): """Admin can soft-delete a contact.""" seed = await seed_tenant_and_users(db_session) current_user = { "user_id": str(seed["admin_a"].id), "tenant_id": str(seed["tenant_a"].id), "role": "admin", "is_system_admin": True, "permissions": ["*: *"], "denied_permissions": [], "field_permissions": {}, } # Create a contact to delete create_cmd = CreateContactCommand(data={ "type": "company", "name": "To Delete", }) create_result = await create_cmd.execute(db_session, redis_client, current_user) assert create_result.success is True contact_id = create_result.data["id"] # Soft-delete it delete_cmd = DeleteContactCommand(contact_id=contact_id, hard=False) delete_result = await delete_cmd.execute(db_session, redis_client, current_user) assert delete_result.success is True assert delete_result.data is None assert len(delete_result.events) >= 1 assert delete_result.events[0]["event"] == "contact.deleted" # Verify audit log await db_session.flush() audit_q = select(AuditLog).where( AuditLog.entity_type == "contact", AuditLog.action == "delete", AuditLog.entity_id == uuid_mod.UUID(contact_id), ) audit_result = await db_session.execute(audit_q) audit_entries = audit_result.scalars().all() assert len(audit_entries) >= 1 async def test_hard_delete_contact_admin_success( self, db_session: AsyncSession, redis_client ): """Admin can hard-delete a contact.""" seed = await seed_tenant_and_users(db_session) current_user = { "user_id": str(seed["admin_a"].id), "tenant_id": str(seed["tenant_a"].id), "role": "admin", "is_system_admin": True, "permissions": ["*: *"], "denied_permissions": [], "field_permissions": {}, } # Create a contact to hard-delete create_cmd = CreateContactCommand(data={ "type": "company", "name": "Hard Delete Me", }) create_result = await create_cmd.execute(db_session, redis_client, current_user) assert create_result.success is True contact_id = create_result.data["id"] # Hard-delete it delete_cmd = DeleteContactCommand(contact_id=contact_id, hard=True) delete_result = await delete_cmd.execute(db_session, redis_client, current_user) assert delete_result.success is True assert len(delete_result.events) >= 1 assert delete_result.events[0]["event"] == "contact.hard_deleted" async def test_delete_contact_not_found( self, db_session: AsyncSession, redis_client ): """Deleting a non-existent contact fails gracefully.""" seed = await seed_tenant_and_users(db_session) current_user = { "user_id": str(seed["admin_a"].id), "tenant_id": str(seed["tenant_a"].id), "role": "admin", "is_system_admin": True, "permissions": ["*: *"], "denied_permissions": [], "field_permissions": {}, } random_id = str(uuid_mod.uuid4()) delete_cmd = DeleteContactCommand(contact_id=random_id) result = await delete_cmd.execute(db_session, redis_client, current_user) assert result.success is False assert "not found" in result.error.lower() async def test_delete_contact_viewer_denied( self, db_session: AsyncSession, redis_client ): """Viewer cannot delete a contact.""" seed = await seed_tenant_and_users(db_session) current_user = { "user_id": str(seed["viewer_a"].id), "tenant_id": str(seed["tenant_a"].id), "role": "viewer", "is_system_admin": False, "permissions": ["contacts:read"], "denied_permissions": [], "field_permissions": {}, } cmd = DeleteContactCommand(contact_id=str(seed["company_a"].id)) result = await cmd.execute(db_session, redis_client, current_user) assert result.success is False assert "Permission denied" in result.error # ── MergeContactsCommand Tests ── @pytest.mark.asyncio class TestMergeContactsCommand: """Tests for MergeContactsCommand.""" async def test_merge_contacts_admin_success( self, db_session: AsyncSession, redis_client ): """Admin can merge two contacts, audit log created, outbox event enqueued.""" seed = await seed_tenant_and_users(db_session) current_user = { "user_id": str(seed["admin_a"].id), "tenant_id": str(seed["tenant_a"].id), "role": "admin", "is_system_admin": True, "permissions": ["*: *"], "denied_permissions": [], "field_permissions": {}, } # Create two contacts to merge create_cmd1 = CreateContactCommand(data={ "type": "company", "name": "Source Company", "email_1": "source@example.com", }) result1 = await create_cmd1.execute(db_session, redis_client, current_user) assert result1.success is True source_id = result1.data["id"] create_cmd2 = CreateContactCommand(data={ "type": "company", "name": "Target Company", "email_1": "target@example.com", }) result2 = await create_cmd2.execute(db_session, redis_client, current_user) assert result2.success is True target_id = result2.data["id"] # Merge source → target merge_cmd = MergeContactsCommand( source_contact_id=source_id, target_contact_id=target_id, note="Duplicate detected", ) merge_result = await merge_cmd.execute(db_session, redis_client, current_user) assert merge_result.success is True assert merge_result.data is not None assert "history" in merge_result.data assert merge_result.data["history"]["source_id"] == source_id assert merge_result.data["history"]["target_id"] == target_id assert len(merge_result.events) >= 1 assert merge_result.events[0]["event"] == "contact.merged" # Verify audit log await db_session.flush() audit_q = select(AuditLog).where( AuditLog.entity_type == "contact", AuditLog.action == "merge", AuditLog.entity_id == uuid_mod.UUID(target_id), ) audit_result = await db_session.execute(audit_q) audit_entries = audit_result.scalars().all() assert len(audit_entries) >= 1 async def test_merge_same_contact_fails( self, db_session: AsyncSession, redis_client ): """Merging a contact with itself fails.""" seed = await seed_tenant_and_users(db_session) current_user = { "user_id": str(seed["admin_a"].id), "tenant_id": str(seed["tenant_a"].id), "role": "admin", "is_system_admin": True, "permissions": ["*: *"], "denied_permissions": [], "field_permissions": {}, } # Create a contact create_cmd = CreateContactCommand(data={ "type": "company", "name": "Solo Company", }) create_result = await create_cmd.execute(db_session, redis_client, current_user) assert create_result.success is True contact_id = create_result.data["id"] # Try to merge with itself merge_cmd = MergeContactsCommand( source_contact_id=contact_id, target_contact_id=contact_id, ) result = await merge_cmd.execute(db_session, redis_client, current_user) assert result.success is False assert "must be different" in result.error async def test_merge_contacts_viewer_denied( self, db_session: AsyncSession, redis_client ): """Viewer cannot merge contacts.""" seed = await seed_tenant_and_users(db_session) current_user = { "user_id": str(seed["viewer_a"].id), "tenant_id": str(seed["tenant_a"].id), "role": "viewer", "is_system_admin": False, "permissions": ["contacts:read"], "denied_permissions": [], "field_permissions": {}, } merge_cmd = MergeContactsCommand( source_contact_id=str(seed["company_a"].id), target_contact_id=str(seed["company_a"].id), ) result = await merge_cmd.execute(db_session, redis_client, current_user) assert result.success is False assert "Permission denied" in result.error async def test_merge_contact_not_found( self, db_session: AsyncSession, redis_client ): """Merging with a non-existent contact fails.""" seed = await seed_tenant_and_users(db_session) current_user = { "user_id": str(seed["admin_a"].id), "tenant_id": str(seed["tenant_a"].id), "role": "admin", "is_system_admin": True, "permissions": ["*: *"], "denied_permissions": [], "field_permissions": {}, } random_id = str(uuid_mod.uuid4()) merge_cmd = MergeContactsCommand( source_contact_id=random_id, target_contact_id=str(seed["company_a"].id), ) result = await merge_cmd.execute(db_session, redis_client, current_user) assert result.success is False assert "not found" in result.error.lower()