diff --git a/backend/alembic/versions/2026_04_25_1000-e3f4a5b6c7d8_mandate_origin_id.py b/backend/alembic/versions/2026_04_25_1000-e3f4a5b6c7d8_mandate_origin_id.py new file mode 100644 index 0000000..2df6a9b --- /dev/null +++ b/backend/alembic/versions/2026_04_25_1000-e3f4a5b6c7d8_mandate_origin_id.py @@ -0,0 +1,27 @@ +"""Mandate origin: replace free-text with FK to duniter_identities. + +Revision ID: e3f4a5b6c7d8 +Revises: d91a3c7f8b02 +Create Date: 2026-04-25 10:00:00.000000 +""" + +from __future__ import annotations + +from alembic import op + +revision = "e3f4a5b6c7d8" +down_revision = "d91a3c7f8b02" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("ALTER TABLE mandates DROP COLUMN IF EXISTS origin") + op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS origin_id UUID REFERENCES duniter_identities(id)") + op.execute("CREATE INDEX IF NOT EXISTS ix_mandates_origin_id ON mandates (origin_id)") + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_mandates_origin_id") + op.execute("ALTER TABLE mandates DROP COLUMN IF EXISTS origin_id") + op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS origin TEXT") diff --git a/backend/app/models/mandate.py b/backend/app/models/mandate.py index c3c677b..6d542b1 100644 --- a/backend/app/models/mandate.py +++ b/backend/app/models/mandate.py @@ -12,10 +12,10 @@ class Mandate(Base): id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) title: Mapped[str] = mapped_column(String(256), nullable=False) - origin: Mapped[str | None] = mapped_column(Text) # contexte / déclencheur du mandat + origin_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"), nullable=True, index=True) description: Mapped[str | None] = mapped_column(Text) - mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) # techcomm, smith, custom - status: Mapped[str] = mapped_column(String(32), default="draft") # draft, candidacy, voting, active, reporting, completed, revoked + mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) + status: Mapped[str] = mapped_column(String(32), default="draft") organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True) mandatee_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id")) decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id")) @@ -24,7 +24,27 @@ class Mandate(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - steps: Mapped[list["MandateStep"]] = relationship(back_populates="mandate", cascade="all, delete-orphan", order_by="MandateStep.step_order") + steps: Mapped[list["MandateStep"]] = relationship( + back_populates="mandate", cascade="all, delete-orphan", order_by="MandateStep.step_order" + ) + origin_identity: Mapped["DuniterIdentity | None"] = relationship( # type: ignore[name-defined] + "DuniterIdentity", foreign_keys=[origin_id] + ) + mandatee: Mapped["DuniterIdentity | None"] = relationship( # type: ignore[name-defined] + "DuniterIdentity", foreign_keys=[mandatee_id] + ) + + @property + def origin_display_name(self) -> str | None: + if self.origin_identity is not None: + return self.origin_identity.display_name or self.origin_identity.address + return None + + @property + def mandatee_display_name(self) -> str | None: + if self.mandatee is not None: + return self.mandatee.display_name or self.mandatee.address + return None class MandateStep(Base): @@ -33,12 +53,16 @@ class MandateStep(Base): id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) mandate_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("mandates.id"), nullable=False) step_order: Mapped[int] = mapped_column(Integer, nullable=False) - step_type: Mapped[str] = mapped_column(String(32), nullable=False) # formulation, candidacy, vote, assignment, reporting, completion, revocation + step_type: Mapped[str] = mapped_column(String(32), nullable=False) title: Mapped[str | None] = mapped_column(String(256)) description: Mapped[str | None] = mapped_column(Text) - status: Mapped[str] = mapped_column(String(32), default="pending") # pending, active, completed, skipped + status: Mapped[str] = mapped_column(String(32), default="pending") vote_session_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("vote_sessions.id")) outcome: Mapped[str | None] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) mandate: Mapped["Mandate"] = relationship(back_populates="steps") + + +# Avoid circular import — DuniterIdentity imported at runtime by SQLAlchemy relationship resolution +from app.models.user import DuniterIdentity # noqa: E402, F401 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 02e7f24..1247532 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -5,7 +5,8 @@ from __future__ import annotations import secrets from datetime import datetime, timedelta, timezone -from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -232,3 +233,24 @@ async def logout( for session in sessions: await db.delete(session) await db.commit() + + +@router.get("/identities", response_model=list[IdentityOut]) +async def search_identities( + q: str = Query(..., min_length=1, description="Recherche par adresse ou nom"), + limit: int = Query(default=10, ge=1, le=50), + db: AsyncSession = Depends(get_db), +) -> list[IdentityOut]: + """Search Duniter identities by address prefix or display_name.""" + result = await db.execute( + select(DuniterIdentity) + .where( + or_( + DuniterIdentity.address.ilike(f"{q}%"), + DuniterIdentity.display_name.ilike(f"%{q}%"), + ) + ) + .order_by(DuniterIdentity.display_name) + .limit(limit) + ) + return [IdentityOut.model_validate(i) for i in result.scalars().all()] diff --git a/backend/app/routers/mandates.py b/backend/app/routers/mandates.py index d8f5af5..dd96f1f 100644 --- a/backend/app/routers/mandates.py +++ b/backend/app/routers/mandates.py @@ -38,10 +38,13 @@ router = APIRouter() async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate: - """Fetch a mandate by ID with its steps eagerly loaded, or raise 404.""" result = await db.execute( select(Mandate) - .options(selectinload(Mandate.steps)) + .options( + selectinload(Mandate.steps), + selectinload(Mandate.origin_identity), + selectinload(Mandate.mandatee), + ) .where(Mandate.id == mandate_id) ) mandate = result.scalar_one_or_none() @@ -50,6 +53,13 @@ async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate: return mandate +def _mandate_out(mandate: Mandate) -> MandateOut: + out = MandateOut.model_validate(mandate) + out.origin_display_name = mandate.origin_display_name + out.mandatee_display_name = mandate.mandatee_display_name + return out + + # ── Mandate routes ────────────────────────────────────────────────────────── @@ -57,13 +67,16 @@ async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate: async def list_mandates( db: AsyncSession = Depends(get_db), org_id: uuid.UUID | None = Depends(get_active_org_id), - mandate_type: str | None = Query(default=None, description="Filtrer par type de mandat"), - status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"), + mandate_type: str | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), skip: int = Query(default=0, ge=0), limit: int = Query(default=50, ge=1, le=200), ) -> list[MandateOut]: - """List all mandates with optional filters.""" - stmt = select(Mandate).options(selectinload(Mandate.steps)) + stmt = select(Mandate).options( + selectinload(Mandate.steps), + selectinload(Mandate.origin_identity), + selectinload(Mandate.mandatee), + ) if org_id is not None: stmt = stmt.where(Mandate.organization_id == org_id) @@ -76,7 +89,7 @@ async def list_mandates( result = await db.execute(stmt) mandates = result.scalars().unique().all() - return [MandateOut.model_validate(m) for m in mandates] + return [_mandate_out(m) for m in mandates] @router.post("/", response_model=MandateOut, status_code=status.HTTP_201_CREATED) @@ -86,15 +99,20 @@ async def create_mandate( identity: DuniterIdentity = Depends(get_current_identity), org_id: uuid.UUID | None = Depends(get_active_org_id), ) -> MandateOut: - """Create a new mandate.""" - mandate = Mandate(**payload.model_dump(), organization_id=org_id) + data = payload.model_dump() + nomination_mode = data.pop("nomination_mode", "postpone") + + mandate = Mandate(**data, organization_id=org_id) + + if nomination_mode == "auto": + mandate.mandatee_id = identity.id + db.add(mandate) await db.commit() await db.refresh(mandate) - # Reload with steps (empty at creation) mandate = await _get_mandate(db, mandate.id) - return MandateOut.model_validate(mandate) + return _mandate_out(mandate) @router.get("/{id}", response_model=MandateOut) @@ -102,9 +120,8 @@ async def get_mandate( id: uuid.UUID, db: AsyncSession = Depends(get_db), ) -> MandateOut: - """Get a single mandate with all its steps.""" mandate = await _get_mandate(db, id) - return MandateOut.model_validate(mandate) + return _mandate_out(mandate) @router.put("/{id}", response_model=MandateOut) @@ -114,19 +131,14 @@ async def update_mandate( db: AsyncSession = Depends(get_db), identity: DuniterIdentity = Depends(get_current_identity), ) -> MandateOut: - """Update a mandate's metadata.""" mandate = await _get_mandate(db, id) - update_data = payload.model_dump(exclude_unset=True) - for field, value in update_data.items(): + for field, value in payload.model_dump(exclude_unset=True).items(): setattr(mandate, field, value) await db.commit() - await db.refresh(mandate) - - # Reload with steps mandate = await _get_mandate(db, mandate.id) - return MandateOut.model_validate(mandate) + return _mandate_out(mandate) @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None) @@ -135,7 +147,6 @@ async def delete_mandate( db: AsyncSession = Depends(get_db), identity: DuniterIdentity = Depends(get_current_identity), ) -> None: - """Delete a mandate (only if in draft status).""" mandate = await _get_mandate(db, id) if mandate.status != "draft": @@ -158,13 +169,9 @@ async def add_step( db: AsyncSession = Depends(get_db), identity: DuniterIdentity = Depends(get_current_identity), ) -> MandateStepOut: - """Add a step to a mandate process.""" mandate = await _get_mandate(db, id) - step = MandateStep( - mandate_id=mandate.id, - **payload.model_dump(), - ) + step = MandateStep(mandate_id=mandate.id, **payload.model_dump()) db.add(step) await db.commit() await db.refresh(step) @@ -177,7 +184,6 @@ async def list_steps( id: uuid.UUID, db: AsyncSession = Depends(get_db), ) -> list[MandateStepOut]: - """List all steps for a mandate, ordered by step_order.""" mandate = await _get_mandate(db, id) return [MandateStepOut.model_validate(s) for s in mandate.steps] @@ -191,17 +197,17 @@ async def advance_mandate_endpoint( db: AsyncSession = Depends(get_db), identity: DuniterIdentity = Depends(get_current_identity), ) -> MandateAdvanceOut: - """Advance a mandate to its next step or status.""" try: mandate = await advance_mandate(id, db) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) - # Reload with steps for complete output mandate = await _get_mandate(db, mandate.id) - data = MandateOut.model_validate(mandate).model_dump() - data["message"] = f"Mandat avance au statut : {mandate.status}" - return MandateAdvanceOut(**data) + out = _mandate_out(mandate) + return MandateAdvanceOut( + **out.model_dump(), + message=f"Mandat avance au statut : {mandate.status}", + ) @router.post("/{id}/assign", response_model=MandateOut) @@ -211,15 +217,13 @@ async def assign_mandatee_endpoint( db: AsyncSession = Depends(get_db), identity: DuniterIdentity = Depends(get_current_identity), ) -> MandateOut: - """Assign a mandatee to a mandate.""" try: mandate = await assign_mandatee(id, payload.mandatee_id, db) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) - # Reload with steps mandate = await _get_mandate(db, mandate.id) - return MandateOut.model_validate(mandate) + return _mandate_out(mandate) @router.post("/{id}/revoke", response_model=MandateOut) @@ -228,15 +232,13 @@ async def revoke_mandate_endpoint( db: AsyncSession = Depends(get_db), identity: DuniterIdentity = Depends(get_current_identity), ) -> MandateOut: - """Revoke an active mandate.""" try: mandate = await revoke_mandate(id, db) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) - # Reload with steps mandate = await _get_mandate(db, mandate.id) - return MandateOut.model_validate(mandate) + return _mandate_out(mandate) @router.post( @@ -250,7 +252,6 @@ async def create_vote_session_for_step_endpoint( db: AsyncSession = Depends(get_db), identity: DuniterIdentity = Depends(get_current_identity), ) -> VoteSessionOut: - """Create a vote session linked to a mandate step.""" try: session = await create_vote_session_for_step(id, step_id, db) except ValueError as exc: diff --git a/backend/app/schemas/mandate.py b/backend/app/schemas/mandate.py index e801f6e..31e0c58 100644 --- a/backend/app/schemas/mandate.py +++ b/backend/app/schemas/mandate.py @@ -10,21 +10,13 @@ from pydantic import BaseModel, ConfigDict, Field class MandateStepCreate(BaseModel): - """Payload for creating a step within a mandate process.""" - step_order: int = Field(..., ge=0) - step_type: str = Field( - ..., - max_length=32, - description="formulation, candidacy, vote, assignment, reporting, completion, revocation", - ) + step_type: str = Field(..., max_length=32) title: str | None = Field(default=None, max_length=256) description: str | None = None class MandateStepOut(BaseModel): - """Full mandate step representation.""" - model_config = ConfigDict(from_attributes=True) id: UUID @@ -43,44 +35,45 @@ class MandateStepOut(BaseModel): class MandateCreate(BaseModel): - """Payload for creating a new mandate.""" - title: str = Field(..., min_length=1, max_length=256) - origin: str | None = None + origin_id: UUID | None = None description: str | None = None - mandate_type: str = Field(..., max_length=64, description="techcomm, smith, custom") + mandate_type: str = Field(..., max_length=64) + nomination_mode: str = Field( + default="postpone", + description="auto (auto-assign author), collective, postpone", + ) decision_id: UUID | None = None starts_at: datetime | None = None ends_at: datetime | None = None class MandateUpdate(BaseModel): - """Partial update for a mandate.""" - title: str | None = Field(default=None, max_length=256) + origin_id: UUID | None = None description: str | None = None mandate_type: str | None = Field(default=None, max_length=64) decision_id: UUID | None = None + starts_at: datetime | None = None + ends_at: datetime | None = None class MandateAssignRequest(BaseModel): - """Request body for assigning a mandatee to a mandate.""" - - mandatee_id: UUID = Field(..., description="ID de l'identite Duniter du mandataire") + mandatee_id: UUID = Field(..., description="UUID de l'identite Duniter du mandataire") class MandateOut(BaseModel): - """Full mandate representation returned by the API.""" - model_config = ConfigDict(from_attributes=True) id: UUID title: str - origin: str | None = None + origin_id: UUID | None = None + origin_display_name: str | None = None description: str | None = None mandate_type: str status: str mandatee_id: UUID | None = None + mandatee_display_name: str | None = None decision_id: UUID | None = None starts_at: datetime | None = None ends_at: datetime | None = None @@ -89,22 +82,5 @@ class MandateOut(BaseModel): steps: list[MandateStepOut] = Field(default_factory=list) -class MandateAdvanceOut(BaseModel): - """Output after advancing a mandate through its workflow.""" - - model_config = ConfigDict(from_attributes=True) - - id: UUID - title: str - origin: str | None = None - description: str | None = None - mandate_type: str - status: str - mandatee_id: UUID | None = None - decision_id: UUID | None = None - starts_at: datetime | None = None - ends_at: datetime | None = None - created_at: datetime - updated_at: datetime - steps: list[MandateStepOut] = Field(default_factory=list) +class MandateAdvanceOut(MandateOut): message: str = Field(..., description="Message decrivant l'avancement effectue") diff --git a/backend/app/tests/test_mandate_flows.py b/backend/app/tests/test_mandate_flows.py new file mode 100644 index 0000000..ff067a9 --- /dev/null +++ b/backend/app/tests/test_mandate_flows.py @@ -0,0 +1,405 @@ +"""Integration tests for mandate flows: nomination, lifecycle, assignment, revocation. + +Uses a real in-memory SQLite database — no mocks of the DB layer. +Tests the service functions directly to verify interconnected business logic. +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import selectinload +from sqlalchemy import select + +import app.models # noqa: F401 — registers all models with Base.metadata +from app.database import Base +from app.models.mandate import Mandate, MandateStep +from app.models.user import DuniterIdentity +from app.services.mandate_service import ( + advance_mandate, + assign_mandatee, + revoke_mandate, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture +async def engine(): + eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False) + async with eng.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield eng + await eng.dispose() + + +@pytest_asyncio.fixture +async def db(engine): + factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with factory() as session: + yield session + + +async def _mk_identity(db: AsyncSession, display_name: str = "Alice") -> DuniterIdentity: + ident = DuniterIdentity( + id=uuid.uuid4(), + address=f"5{uuid.uuid4().hex[:46]}", + display_name=display_name, + wot_status="member", + is_smith=False, + is_techcomm=False, + ) + db.add(ident) + await db.commit() + await db.refresh(ident) + return ident + + +async def _mk_mandate( + db: AsyncSession, + *, + mandatee_id: uuid.UUID | None = None, + origin_id: uuid.UUID | None = None, + status: str = "draft", + steps: list[dict] | None = None, +) -> Mandate: + mandate = Mandate( + id=uuid.uuid4(), + title="Mandat test", + mandate_type="functional", + status=status, + mandatee_id=mandatee_id, + origin_id=origin_id, + ) + db.add(mandate) + await db.flush() + + for i, s in enumerate(steps or []): + step = MandateStep( + id=uuid.uuid4(), + mandate_id=mandate.id, + step_order=i, + step_type=s.get("step_type", "formulation"), + title=s.get("title"), + status=s.get("status", "pending"), + ) + db.add(step) + + await db.commit() + await db.refresh(mandate) + return mandate + + +async def _reload(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate: + result = await db.execute( + select(Mandate) + .options( + selectinload(Mandate.steps), + selectinload(Mandate.origin_identity), + selectinload(Mandate.mandatee), + ) + .where(Mandate.id == mandate_id) + ) + return result.scalar_one() + + +# --------------------------------------------------------------------------- +# TestMandateOrigin +# --------------------------------------------------------------------------- + + +class TestMandateOrigin: + """origin_id must link to a real DuniterIdentity and expose display_name.""" + + @pytest.mark.asyncio + async def test_origin_id_linked(self, db: AsyncSession): + author = await _mk_identity(db, "Baptiste") + mandate = await _mk_mandate(db, origin_id=author.id) + + loaded = await _reload(db, mandate.id) + + assert loaded.origin_id == author.id + assert loaded.origin_display_name == "Baptiste" + + @pytest.mark.asyncio + async def test_origin_id_optional(self, db: AsyncSession): + mandate = await _mk_mandate(db) + + loaded = await _reload(db, mandate.id) + + assert loaded.origin_id is None + assert loaded.origin_display_name is None + + +# --------------------------------------------------------------------------- +# TestAutoNomination +# --------------------------------------------------------------------------- + + +class TestAutoNomination: + """Auto-désignation: mandatee = author, no candidacy steps needed.""" + + @pytest.mark.asyncio + async def test_auto_assign_author(self, db: AsyncSession): + author = await _mk_identity(db, "Constance") + mandate = await _mk_mandate(db, mandatee_id=author.id) + + loaded = await _reload(db, mandate.id) + + assert loaded.mandatee_id == author.id + assert loaded.mandatee_display_name == "Constance" + + @pytest.mark.asyncio + async def test_auto_assign_then_advance_to_active(self, db: AsyncSession): + author = await _mk_identity(db, "David") + mandate = await _mk_mandate( + db, + mandatee_id=author.id, + steps=[ + {"step_type": "formulation", "status": "pending"}, + {"step_type": "assignment", "status": "pending"}, + {"step_type": "reporting", "status": "pending"}, + ], + ) + + # First advance: draft → candidacy, activates step 0 + result = await advance_mandate(mandate.id, db) + assert result.status == "candidacy" + + loaded = await _reload(db, mandate.id) + assert loaded.steps[0].status == "active" + assert loaded.steps[1].status == "pending" + + # Second advance: step 0 → completed, step 1 → active + await advance_mandate(mandate.id, db) + loaded = await _reload(db, mandate.id) + assert loaded.steps[0].status == "completed" + assert loaded.steps[1].status == "active" + + +# --------------------------------------------------------------------------- +# TestMandateAssign +# --------------------------------------------------------------------------- + + +class TestMandateAssign: + """assign_mandatee service: proper UUID lookup, display_name populated.""" + + @pytest.mark.asyncio + async def test_assign_sets_mandatee_and_starts_at(self, db: AsyncSession): + identity = await _mk_identity(db, "Elodie") + mandate = await _mk_mandate(db, status="active") + + await assign_mandatee(mandate.id, identity.id, db) + + loaded = await _reload(db, mandate.id) + assert loaded.mandatee_id == identity.id + assert loaded.starts_at is not None + assert loaded.mandatee_display_name == "Elodie" + + @pytest.mark.asyncio + async def test_assign_unknown_identity_raises(self, db: AsyncSession): + mandate = await _mk_mandate(db, status="active") + + with pytest.raises(ValueError, match="Identite Duniter introuvable"): + await assign_mandatee(mandate.id, uuid.uuid4(), db) + + @pytest.mark.asyncio + async def test_assign_completed_mandate_raises(self, db: AsyncSession): + identity = await _mk_identity(db, "Fabien") + mandate = await _mk_mandate(db, status="completed") + + with pytest.raises(ValueError, match="statut terminal"): + await assign_mandatee(mandate.id, identity.id, db) + + @pytest.mark.asyncio + async def test_reassign_replaces_mandatee(self, db: AsyncSession): + first = await _mk_identity(db, "Gilles") + second = await _mk_identity(db, "Hélène") + mandate = await _mk_mandate(db, mandatee_id=first.id, status="active") + + await assign_mandatee(mandate.id, second.id, db) + + loaded = await _reload(db, mandate.id) + assert loaded.mandatee_id == second.id + + +# --------------------------------------------------------------------------- +# TestMandateLifecycle +# --------------------------------------------------------------------------- + + +class TestMandateLifecycle: + """advance_mandate: full lifecycle with and without steps.""" + + @pytest.mark.asyncio + async def test_full_lifecycle_no_steps(self, db: AsyncSession): + mandate = await _mk_mandate(db) + statuses = [mandate.status] + + for _ in range(10): + m = await advance_mandate(mandate.id, db) + statuses.append(m.status) + if m.status == "completed": + break + + assert statuses == ["draft", "candidacy", "voting", "active", "reporting", "completed"] + + @pytest.mark.asyncio + async def test_steps_activate_in_order(self, db: AsyncSession): + mandate = await _mk_mandate( + db, + steps=[ + {"step_type": "formulation", "status": "pending"}, + {"step_type": "candidacy", "status": "pending"}, + {"step_type": "vote", "status": "pending"}, + ], + ) + + # Advance 1: activates step 0, moves to candidacy + await advance_mandate(mandate.id, db) + loaded = await _reload(db, mandate.id) + assert loaded.status == "candidacy" + assert loaded.steps[0].status == "active" + assert loaded.steps[1].status == "pending" + + # Advance 2: step 0 → completed, step 1 → active + await advance_mandate(mandate.id, db) + loaded = await _reload(db, mandate.id) + assert loaded.steps[0].status == "completed" + assert loaded.steps[1].status == "active" + + # Advance 3: step 1 → completed, step 2 → active + await advance_mandate(mandate.id, db) + loaded = await _reload(db, mandate.id) + assert loaded.steps[1].status == "completed" + assert loaded.steps[2].status == "active" + + # Advance 4: step 2 → completed, no more pending → advance mandate status + await advance_mandate(mandate.id, db) + loaded = await _reload(db, mandate.id) + assert loaded.steps[2].status == "completed" + assert loaded.status == "voting" + + @pytest.mark.asyncio + async def test_advance_terminal_raises(self, db: AsyncSession): + for terminal in ("completed", "revoked"): + mandate = await _mk_mandate(db, status=terminal) + with pytest.raises(ValueError, match="statut terminal"): + await advance_mandate(mandate.id, db) + + +# --------------------------------------------------------------------------- +# TestMandateRevocation +# --------------------------------------------------------------------------- + + +class TestMandateRevocation: + """revoke_mandate: active/pending steps cancelled, completed steps preserved.""" + + @pytest.mark.asyncio + async def test_revoke_cancels_active_and_pending(self, db: AsyncSession): + mandate = await _mk_mandate( + db, + status="active", + steps=[ + {"step_type": "formulation", "status": "completed"}, + {"step_type": "assignment", "status": "active"}, + {"step_type": "reporting", "status": "pending"}, + ], + ) + + await revoke_mandate(mandate.id, db) + + loaded = await _reload(db, mandate.id) + assert loaded.status == "revoked" + assert loaded.ends_at is not None + assert loaded.steps[0].status == "completed" + assert loaded.steps[1].status == "cancelled" + assert loaded.steps[2].status == "cancelled" + + @pytest.mark.asyncio + async def test_revoke_sets_ends_at(self, db: AsyncSession): + mandate = await _mk_mandate(db, status="draft") + + await revoke_mandate(mandate.id, db) + + loaded = await _reload(db, mandate.id) + assert loaded.ends_at is not None + + @pytest.mark.asyncio + async def test_revoke_already_revoked_raises(self, db: AsyncSession): + mandate = await _mk_mandate(db, status="revoked") + + with pytest.raises(ValueError, match="statut terminal"): + await revoke_mandate(mandate.id, db) + + +# --------------------------------------------------------------------------- +# TestNominationInteractions +# --------------------------------------------------------------------------- + + +class TestNominationInteractions: + """Cross-process: nomination + assignment + lifecycle are consistent.""" + + @pytest.mark.asyncio + async def test_assign_then_advance_full_cycle(self, db: AsyncSession): + """Assigning a mandatee then running the full lifecycle completes cleanly.""" + mandatee = await _mk_identity(db, "Isabelle") + mandate = await _mk_mandate( + db, + steps=[ + {"step_type": "formulation", "status": "pending"}, + {"step_type": "assignment", "status": "pending"}, + {"step_type": "completion", "status": "pending"}, + ], + ) + + # Assign before starting + await assign_mandatee(mandate.id, mandatee.id, db) + + # Run through all steps + for _ in range(5): + m = await advance_mandate(mandate.id, db) + if m.status in ("completed", "revoked"): + break + + loaded = await _reload(db, mandate.id) + # All steps completed, mandate advanced beyond steps + assert all(s.status == "completed" for s in loaded.steps) + assert loaded.mandatee_id == mandatee.id + + @pytest.mark.asyncio + async def test_revoke_after_assign_preserves_mandatee_id(self, db: AsyncSession): + """Revoking a mandate keeps mandatee_id (for audit trail).""" + mandatee = await _mk_identity(db, "Jacques") + mandate = await _mk_mandate(db, mandatee_id=mandatee.id, status="active") + + await revoke_mandate(mandate.id, db) + + loaded = await _reload(db, mandate.id) + assert loaded.status == "revoked" + assert loaded.mandatee_id == mandatee.id + + @pytest.mark.asyncio + async def test_origin_and_mandatee_can_differ(self, db: AsyncSession): + """The person who proposed the mandate (origin) is different from the mandatee.""" + proposer = await _mk_identity(db, "Kim") + mandatee = await _mk_identity(db, "Laurent") + + mandate = await _mk_mandate(db, origin_id=proposer.id) + await assign_mandatee(mandate.id, mandatee.id, db) + + loaded = await _reload(db, mandate.id) + assert loaded.origin_id == proposer.id + assert loaded.mandatee_id == mandatee.id + assert loaded.origin_display_name == "Kim" + assert loaded.mandatee_display_name == "Laurent" diff --git a/frontend/app/pages/mandates/[id].vue b/frontend/app/pages/mandates/[id].vue index edd6306..020ca5c 100644 --- a/frontend/app/pages/mandates/[id].vue +++ b/frontend/app/pages/mandates/[id].vue @@ -1,6 +1,7 @@