"""Tests for mandate service: advance_mandate, assign_mandatee, revoke_mandate, create_vote_session_for_step. These are pure unit tests that mock the database layer to test the service logic in isolation. """ from __future__ import annotations import uuid from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock import pytest sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for mandate service tests") from app.services.mandate_service import ( # noqa: E402 advance_mandate, assign_mandatee, create_vote_session_for_step, revoke_mandate, ) # --------------------------------------------------------------------------- # Helpers: mock objects that behave like SQLAlchemy models # --------------------------------------------------------------------------- def _make_step( step_id: uuid.UUID | None = None, mandate_id: uuid.UUID | None = None, step_order: int = 0, step_type: str = "formulation", status: str = "pending", vote_session_id: uuid.UUID | None = None, ) -> MagicMock: """Create a mock MandateStep.""" step = MagicMock() step.id = step_id or uuid.uuid4() step.mandate_id = mandate_id or uuid.uuid4() step.step_order = step_order step.step_type = step_type step.title = None step.description = None step.status = status step.vote_session_id = vote_session_id step.outcome = None step.created_at = datetime.now(timezone.utc) return step def _make_mandate( mandate_id: uuid.UUID | None = None, title: str = "Mandat de test", mandate_type: str = "techcomm", status: str = "draft", mandatee_id: uuid.UUID | None = None, decision_id: uuid.UUID | None = None, steps: list | None = None, ) -> MagicMock: """Create a mock Mandate.""" mandate = MagicMock() mandate.id = mandate_id or uuid.uuid4() mandate.title = title mandate.description = None mandate.mandate_type = mandate_type mandate.status = status mandate.mandatee_id = mandatee_id mandate.decision_id = decision_id mandate.starts_at = None mandate.ends_at = None mandate.created_at = datetime.now(timezone.utc) mandate.updated_at = datetime.now(timezone.utc) mandate.steps = steps or [] return mandate def _make_identity(identity_id: uuid.UUID | None = None) -> MagicMock: """Create a mock DuniterIdentity.""" identity = MagicMock() identity.id = identity_id or uuid.uuid4() identity.address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" identity.username = "Alice" return identity def _make_protocol( protocol_id: uuid.UUID | None = None, duration_days: int = 30, ) -> MagicMock: """Create a mock VotingProtocol with formula_config.""" protocol = MagicMock() protocol.id = protocol_id or uuid.uuid4() protocol.name = "Protocole standard" protocol.vote_type = "binary" protocol.formula_config = MagicMock() protocol.formula_config.duration_days = duration_days return protocol def _make_decision_mock( decision_id: uuid.UUID | None = None, voting_protocol_id: uuid.UUID | None = None, ) -> MagicMock: """Create a mock Decision for the vote session lookup.""" decision = MagicMock() decision.id = decision_id or uuid.uuid4() decision.voting_protocol_id = voting_protocol_id return decision def _make_db_for_advance(mandate: MagicMock | None) -> AsyncMock: """Create a mock async database session for advance_mandate.""" db = AsyncMock() result = MagicMock() result.scalar_one_or_none.return_value = mandate db.execute = AsyncMock(return_value=result) db.commit = AsyncMock() db.refresh = AsyncMock() return db def _make_db_for_assign( mandate: MagicMock | None, identity: MagicMock | None = None, ) -> AsyncMock: """Create a mock async database session for assign_mandatee. Sequential execute calls: 1st -> mandate lookup 2nd -> identity lookup """ db = AsyncMock() call_results = [] # Mandate result mandate_result = MagicMock() mandate_result.scalar_one_or_none.return_value = mandate call_results.append(mandate_result) # Identity result if identity is not None or mandate is not None: identity_result = MagicMock() identity_result.scalar_one_or_none.return_value = identity call_results.append(identity_result) db.execute = AsyncMock(side_effect=call_results) db.commit = AsyncMock() db.refresh = AsyncMock() return db def _make_db_for_revoke(mandate: MagicMock | None) -> AsyncMock: """Create a mock async database session for revoke_mandate.""" db = AsyncMock() result = MagicMock() result.scalar_one_or_none.return_value = mandate db.execute = AsyncMock(return_value=result) db.commit = AsyncMock() db.refresh = AsyncMock() return db def _make_db_for_vote_session( mandate: MagicMock | None, decision: MagicMock | None = None, protocol: MagicMock | None = None, ) -> AsyncMock: """Create a mock async database session for create_vote_session_for_step. Sequential execute calls: 1st -> mandate lookup 2nd -> decision lookup 3rd -> protocol lookup """ db = AsyncMock() call_results = [] # Mandate result mandate_result = MagicMock() mandate_result.scalar_one_or_none.return_value = mandate call_results.append(mandate_result) # Decision result if decision is not None: decision_result = MagicMock() decision_result.scalar_one_or_none.return_value = decision call_results.append(decision_result) # Protocol result if protocol is not None: proto_result = MagicMock() proto_result.scalar_one_or_none.return_value = protocol call_results.append(proto_result) db.execute = AsyncMock(side_effect=call_results) db.commit = AsyncMock() db.refresh = AsyncMock() db.flush = AsyncMock() db.add = MagicMock() return db # --------------------------------------------------------------------------- # Tests: advance_mandate # --------------------------------------------------------------------------- class TestAdvanceMandate: """Test mandate_service.advance_mandate.""" @pytest.mark.asyncio async def test_advance_from_draft_activates_first_step(self): """Advancing a draft mandate with pending steps activates the first one and moves mandate to candidacy.""" mandate_id = uuid.uuid4() step1 = _make_step(mandate_id=mandate_id, step_order=0, status="pending") step2 = _make_step(mandate_id=mandate_id, step_order=1, status="pending") mandate = _make_mandate( mandate_id=mandate_id, status="draft", steps=[step1, step2], ) db = _make_db_for_advance(mandate) result = await advance_mandate(mandate_id, db) assert step1.status == "active" assert step2.status == "pending" assert mandate.status == "candidacy" db.commit.assert_awaited_once() @pytest.mark.asyncio async def test_advance_completes_active_and_activates_next(self): """Advancing completes the current active step and activates the next pending one.""" mandate_id = uuid.uuid4() step1 = _make_step(mandate_id=mandate_id, step_order=0, status="active") step2 = _make_step(mandate_id=mandate_id, step_order=1, status="pending") mandate = _make_mandate( mandate_id=mandate_id, status="candidacy", steps=[step1, step2], ) db = _make_db_for_advance(mandate) result = await advance_mandate(mandate_id, db) assert step1.status == "completed" assert step2.status == "active" db.commit.assert_awaited_once() @pytest.mark.asyncio async def test_advance_last_step_advances_status(self): """When the last active step is completed with no more pending steps, the mandate status advances.""" mandate_id = uuid.uuid4() step1 = _make_step(mandate_id=mandate_id, step_order=0, status="completed") step2 = _make_step(mandate_id=mandate_id, step_order=1, status="active") mandate = _make_mandate( mandate_id=mandate_id, status="candidacy", steps=[step1, step2], ) db = _make_db_for_advance(mandate) result = await advance_mandate(mandate_id, db) assert step2.status == "completed" assert mandate.status == "voting" db.commit.assert_awaited_once() @pytest.mark.asyncio async def test_advance_full_progression(self): """A mandate with no steps can progress through all statuses.""" mandate_id = uuid.uuid4() mandate = _make_mandate( mandate_id=mandate_id, status="draft", steps=[], ) statuses_seen = [mandate.status] # Advance through all states until completed for _ in range(10): # safety limit db = _make_db_for_advance(mandate) await advance_mandate(mandate_id, db) statuses_seen.append(mandate.status) if mandate.status == "completed": break assert statuses_seen == [ "draft", "candidacy", "voting", "active", "reporting", "completed", ] @pytest.mark.asyncio async def test_advance_completed_raises_error(self): """Advancing a completed mandate raises ValueError.""" mandate_id = uuid.uuid4() mandate = _make_mandate( mandate_id=mandate_id, status="completed", steps=[], ) db = _make_db_for_advance(mandate) with pytest.raises(ValueError, match="statut terminal"): await advance_mandate(mandate_id, db) @pytest.mark.asyncio async def test_advance_revoked_raises_error(self): """Advancing a revoked mandate raises ValueError.""" mandate_id = uuid.uuid4() mandate = _make_mandate( mandate_id=mandate_id, status="revoked", steps=[], ) db = _make_db_for_advance(mandate) with pytest.raises(ValueError, match="statut terminal"): await advance_mandate(mandate_id, db) @pytest.mark.asyncio async def test_advance_not_found_raises_error(self): """Advancing a non-existent mandate raises ValueError.""" db = _make_db_for_advance(None) with pytest.raises(ValueError, match="Mandat introuvable"): await advance_mandate(uuid.uuid4(), db) # --------------------------------------------------------------------------- # Tests: assign_mandatee # --------------------------------------------------------------------------- class TestAssignMandatee: """Test mandate_service.assign_mandatee.""" @pytest.mark.asyncio async def test_assign_success(self): """Assigning a valid identity to an active mandate succeeds.""" mandate_id = uuid.uuid4() mandatee_id = uuid.uuid4() mandate = _make_mandate( mandate_id=mandate_id, status="active", ) identity = _make_identity(identity_id=mandatee_id) db = _make_db_for_assign(mandate, identity) result = await assign_mandatee(mandate_id, mandatee_id, db) assert mandate.mandatee_id == mandatee_id assert mandate.starts_at is not None db.commit.assert_awaited_once() @pytest.mark.asyncio async def test_assign_draft_mandate(self): """Assigning to a draft mandate is allowed.""" mandate_id = uuid.uuid4() mandatee_id = uuid.uuid4() mandate = _make_mandate( mandate_id=mandate_id, status="draft", ) identity = _make_identity(identity_id=mandatee_id) db = _make_db_for_assign(mandate, identity) result = await assign_mandatee(mandate_id, mandatee_id, db) assert mandate.mandatee_id == mandatee_id db.commit.assert_awaited_once() @pytest.mark.asyncio async def test_assign_not_found_raises_error(self): """Assigning to a non-existent mandate raises ValueError.""" db = _make_db_for_assign(None) with pytest.raises(ValueError, match="Mandat introuvable"): await assign_mandatee(uuid.uuid4(), uuid.uuid4(), db) @pytest.mark.asyncio async def test_assign_completed_raises_error(self): """Assigning to a completed mandate raises ValueError.""" mandate_id = uuid.uuid4() mandate = _make_mandate( mandate_id=mandate_id, status="completed", ) db = _make_db_for_assign(mandate) with pytest.raises(ValueError, match="statut terminal"): await assign_mandatee(mandate_id, uuid.uuid4(), db) @pytest.mark.asyncio async def test_assign_revoked_raises_error(self): """Assigning to a revoked mandate raises ValueError.""" mandate_id = uuid.uuid4() mandate = _make_mandate( mandate_id=mandate_id, status="revoked", ) db = _make_db_for_assign(mandate) with pytest.raises(ValueError, match="statut terminal"): await assign_mandatee(mandate_id, uuid.uuid4(), db) @pytest.mark.asyncio async def test_assign_identity_not_found_raises_error(self): """Assigning a non-existent identity raises ValueError.""" mandate_id = uuid.uuid4() mandate = _make_mandate( mandate_id=mandate_id, status="active", ) db = _make_db_for_assign(mandate, identity=None) with pytest.raises(ValueError, match="Identite Duniter introuvable"): await assign_mandatee(mandate_id, uuid.uuid4(), db) # --------------------------------------------------------------------------- # Tests: revoke_mandate # --------------------------------------------------------------------------- class TestRevokeMandate: """Test mandate_service.revoke_mandate.""" @pytest.mark.asyncio async def test_revoke_active_mandate(self): """Revoking an active mandate sets status to revoked and cancels steps.""" mandate_id = uuid.uuid4() step1 = _make_step(mandate_id=mandate_id, step_order=0, status="completed") step2 = _make_step(mandate_id=mandate_id, step_order=1, status="active") step3 = _make_step(mandate_id=mandate_id, step_order=2, status="pending") mandate = _make_mandate( mandate_id=mandate_id, status="active", steps=[step1, step2, step3], ) db = _make_db_for_revoke(mandate) result = await revoke_mandate(mandate_id, db) assert mandate.status == "revoked" assert mandate.ends_at is not None # Completed steps stay completed assert step1.status == "completed" # Active and pending steps are cancelled assert step2.status == "cancelled" assert step3.status == "cancelled" db.commit.assert_awaited_once() @pytest.mark.asyncio async def test_revoke_draft_mandate(self): """Revoking a draft mandate is allowed.""" mandate_id = uuid.uuid4() mandate = _make_mandate( mandate_id=mandate_id, status="draft", steps=[], ) db = _make_db_for_revoke(mandate) result = await revoke_mandate(mandate_id, db) assert mandate.status == "revoked" assert mandate.ends_at is not None db.commit.assert_awaited_once() @pytest.mark.asyncio async def test_revoke_not_found_raises_error(self): """Revoking a non-existent mandate raises ValueError.""" db = _make_db_for_revoke(None) with pytest.raises(ValueError, match="Mandat introuvable"): await revoke_mandate(uuid.uuid4(), db) @pytest.mark.asyncio async def test_revoke_completed_raises_error(self): """Revoking an already completed mandate raises ValueError.""" mandate_id = uuid.uuid4() mandate = _make_mandate( mandate_id=mandate_id, status="completed", ) db = _make_db_for_revoke(mandate) with pytest.raises(ValueError, match="statut terminal"): await revoke_mandate(mandate_id, db) @pytest.mark.asyncio async def test_revoke_already_revoked_raises_error(self): """Revoking an already revoked mandate raises ValueError.""" mandate_id = uuid.uuid4() mandate = _make_mandate( mandate_id=mandate_id, status="revoked", ) db = _make_db_for_revoke(mandate) with pytest.raises(ValueError, match="statut terminal"): await revoke_mandate(mandate_id, db) # --------------------------------------------------------------------------- # Tests: create_vote_session_for_step # --------------------------------------------------------------------------- class TestCreateVoteSessionForStep: """Test mandate_service.create_vote_session_for_step.""" @pytest.mark.asyncio async def test_create_vote_session_success(self): """A vote session is created and linked to the mandate step.""" mandate_id = uuid.uuid4() step_id = uuid.uuid4() decision_id = uuid.uuid4() protocol_id = uuid.uuid4() step = _make_step( step_id=step_id, mandate_id=mandate_id, step_type="vote", status="active", vote_session_id=None, ) mandate = _make_mandate( mandate_id=mandate_id, status="voting", decision_id=decision_id, steps=[step], ) decision = _make_decision_mock( decision_id=decision_id, voting_protocol_id=protocol_id, ) protocol = _make_protocol(protocol_id=protocol_id, duration_days=14) db = _make_db_for_vote_session(mandate, decision, protocol) result = await create_vote_session_for_step(mandate_id, step_id, db) # The step should now have a vote_session_id set assert step.vote_session_id is not None db.commit.assert_awaited_once() @pytest.mark.asyncio async def test_create_vote_session_mandate_not_found(self): """ValueError when mandate does not exist.""" db = _make_db_for_vote_session(None) with pytest.raises(ValueError, match="Mandat introuvable"): await create_vote_session_for_step(uuid.uuid4(), uuid.uuid4(), db) @pytest.mark.asyncio async def test_create_vote_session_step_not_found(self): """ValueError when step does not belong to the mandate.""" mandate_id = uuid.uuid4() mandate = _make_mandate(mandate_id=mandate_id, decision_id=uuid.uuid4(), steps=[]) db = _make_db_for_vote_session(mandate) with pytest.raises(ValueError, match="Etape introuvable"): await create_vote_session_for_step(mandate_id, uuid.uuid4(), db) @pytest.mark.asyncio async def test_create_vote_session_step_already_has_session(self): """ValueError when step already has a vote session.""" mandate_id = uuid.uuid4() step_id = uuid.uuid4() existing_session_id = uuid.uuid4() step = _make_step( step_id=step_id, mandate_id=mandate_id, vote_session_id=existing_session_id, ) mandate = _make_mandate( mandate_id=mandate_id, decision_id=uuid.uuid4(), steps=[step], ) db = _make_db_for_vote_session(mandate) with pytest.raises(ValueError, match="deja une session de vote"): await create_vote_session_for_step(mandate_id, step_id, db) @pytest.mark.asyncio async def test_create_vote_session_no_decision(self): """ValueError when mandate has no linked decision.""" mandate_id = uuid.uuid4() step_id = uuid.uuid4() step = _make_step( step_id=step_id, mandate_id=mandate_id, vote_session_id=None, ) mandate = _make_mandate( mandate_id=mandate_id, decision_id=None, steps=[step], ) db = _make_db_for_vote_session(mandate) with pytest.raises(ValueError, match="Aucune decision liee"): await create_vote_session_for_step(mandate_id, step_id, db)