"""P13.1 — Milestone state-machine action tests. Covers: - freeze: success, missing release task, multiple release tasks, wrong status - start: success + started_at, deps not met, wrong status - close: from open/freeze/undergoing, wrong status (completed/closed) - auto-complete: release task completion triggers milestone completed """ import json import pytest from app.models.milestone import MilestoneStatus from app.models.task import TaskStatus # ----------------------------------------------------------------------- # Freeze # ----------------------------------------------------------------------- class TestFreeze: """POST /projects/{pid}/milestones/{mid}/actions/freeze""" def test_freeze_success( self, client, db, make_user, make_project, make_milestone, make_task, seed_roles_and_permissions, make_member, auth_header, ): admin_role, mgr_role, _ = seed_roles_and_permissions user = make_user(is_admin=False) project = make_project(owner_id=user.id) make_member(project.id, user.id, mgr_role.id) ms = make_milestone(project.id, user.id) # Create exactly 1 maintenance/release task make_task( project.id, ms.id, user.id, task_type="maintenance", task_subtype="release", ) resp = client.post( f"/projects/{project.id}/milestones/{ms.id}/actions/freeze", headers=auth_header(user), ) assert resp.status_code == 200, resp.text assert resp.json()["status"] == "freeze" db.refresh(ms) assert ms.status == MilestoneStatus.FREEZE def test_freeze_no_release_task( self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header, ): _, mgr_role, _ = seed_roles_and_permissions user = make_user() project = make_project(owner_id=user.id) make_member(project.id, user.id, mgr_role.id) ms = make_milestone(project.id, user.id) resp = client.post( f"/projects/{project.id}/milestones/{ms.id}/actions/freeze", headers=auth_header(user), ) assert resp.status_code == 400 assert "no maintenance/release task" in resp.json()["detail"].lower() def test_freeze_multiple_release_tasks( self, client, db, make_user, make_project, make_milestone, make_task, seed_roles_and_permissions, make_member, auth_header, ): _, mgr_role, _ = seed_roles_and_permissions user = make_user() project = make_project(owner_id=user.id) make_member(project.id, user.id, mgr_role.id) ms = make_milestone(project.id, user.id) make_task(project.id, ms.id, user.id, task_type="maintenance", task_subtype="release") make_task(project.id, ms.id, user.id, task_type="maintenance", task_subtype="release") resp = client.post( f"/projects/{project.id}/milestones/{ms.id}/actions/freeze", headers=auth_header(user), ) assert resp.status_code == 400 assert "expected exactly 1" in resp.json()["detail"].lower() def test_freeze_wrong_status( self, client, db, make_user, make_project, make_milestone, make_task, seed_roles_and_permissions, make_member, auth_header, ): _, mgr_role, _ = seed_roles_and_permissions user = make_user() project = make_project(owner_id=user.id) make_member(project.id, user.id, mgr_role.id) ms = make_milestone(project.id, user.id, status=MilestoneStatus.CLOSED) make_task(project.id, ms.id, user.id, task_type="maintenance", task_subtype="release") resp = client.post( f"/projects/{project.id}/milestones/{ms.id}/actions/freeze", headers=auth_header(user), ) assert resp.status_code == 400 assert "expected 'open'" in resp.json()["detail"].lower() # ----------------------------------------------------------------------- # Start # ----------------------------------------------------------------------- class TestStart: """POST /projects/{pid}/milestones/{mid}/actions/start""" def _freeze_milestone(self, db, ms): ms.status = MilestoneStatus.FREEZE db.commit() db.refresh(ms) def test_start_success( self, client, db, make_user, make_project, make_milestone, make_task, seed_roles_and_permissions, make_member, auth_header, ): _, mgr_role, _ = seed_roles_and_permissions user = make_user() project = make_project(owner_id=user.id) make_member(project.id, user.id, mgr_role.id) ms = make_milestone(project.id, user.id) self._freeze_milestone(db, ms) resp = client.post( f"/projects/{project.id}/milestones/{ms.id}/actions/start", headers=auth_header(user), ) assert resp.status_code == 200, resp.text data = resp.json() assert data["status"] == "undergoing" assert "started_at" in data db.refresh(ms) assert ms.status == MilestoneStatus.UNDERGOING assert ms.started_at is not None def test_start_deps_not_met( self, client, db, make_user, make_project, make_milestone, make_task, seed_roles_and_permissions, make_member, auth_header, ): _, mgr_role, _ = seed_roles_and_permissions user = make_user() project = make_project(owner_id=user.id) make_member(project.id, user.id, mgr_role.id) # Create a dependency milestone that is NOT completed dep_ms = make_milestone(project.id, user.id, status=MilestoneStatus.OPEN) ms = make_milestone( project.id, user.id, depend_on_milestones=json.dumps([dep_ms.id]), ) self._freeze_milestone(db, ms) resp = client.post( f"/projects/{project.id}/milestones/{ms.id}/actions/start", headers=auth_header(user), ) assert resp.status_code == 400 assert "cannot start" in resp.json()["detail"].lower() def test_start_wrong_status( self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header, ): _, mgr_role, _ = seed_roles_and_permissions user = make_user() project = make_project(owner_id=user.id) make_member(project.id, user.id, mgr_role.id) ms = make_milestone(project.id, user.id, status=MilestoneStatus.OPEN) resp = client.post( f"/projects/{project.id}/milestones/{ms.id}/actions/start", headers=auth_header(user), ) assert resp.status_code == 400 assert "expected 'freeze'" in resp.json()["detail"].lower() # ----------------------------------------------------------------------- # Close # ----------------------------------------------------------------------- class TestClose: """POST /projects/{pid}/milestones/{mid}/actions/close""" @pytest.mark.parametrize("initial_status", [ MilestoneStatus.OPEN, MilestoneStatus.FREEZE, MilestoneStatus.UNDERGOING, ]) def test_close_from_allowed_statuses( self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header, initial_status, ): _, mgr_role, _ = seed_roles_and_permissions user = make_user() project = make_project(owner_id=user.id) make_member(project.id, user.id, mgr_role.id) ms = make_milestone(project.id, user.id, status=initial_status) resp = client.post( f"/projects/{project.id}/milestones/{ms.id}/actions/close", headers=auth_header(user), json={"reason": "no longer needed"}, ) assert resp.status_code == 200, resp.text assert resp.json()["status"] == "closed" db.refresh(ms) assert ms.status == MilestoneStatus.CLOSED @pytest.mark.parametrize("terminal_status", [ MilestoneStatus.COMPLETED, MilestoneStatus.CLOSED, ]) def test_close_from_terminal_rejected( self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header, terminal_status, ): _, mgr_role, _ = seed_roles_and_permissions user = make_user() project = make_project(owner_id=user.id) make_member(project.id, user.id, mgr_role.id) ms = make_milestone(project.id, user.id, status=terminal_status) resp = client.post( f"/projects/{project.id}/milestones/{ms.id}/actions/close", headers=auth_header(user), ) assert resp.status_code == 400 # ----------------------------------------------------------------------- # Auto-complete # ----------------------------------------------------------------------- class TestAutoComplete: """When the sole release task is completed, milestone auto-completes.""" def test_auto_complete_on_release_task_finish( self, db, make_user, make_project, make_milestone, make_task, ): """Direct unit test of try_auto_complete_milestone.""" from app.api.routers.milestone_actions import try_auto_complete_milestone user = make_user() project = make_project(owner_id=user.id) ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) release_task = make_task( project.id, ms.id, user.id, task_type="maintenance", task_subtype="release", status=TaskStatus.COMPLETED, ) try_auto_complete_milestone(db, release_task, user_id=user.id) db.refresh(ms) assert ms.status == MilestoneStatus.COMPLETED def test_no_auto_complete_for_non_release_task( self, db, make_user, make_project, make_milestone, make_task, ): from app.api.routers.milestone_actions import try_auto_complete_milestone user = make_user() project = make_project(owner_id=user.id) ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) # Also add the required release task (still pending) make_task( project.id, ms.id, user.id, task_type="maintenance", task_subtype="release", status=TaskStatus.PENDING, ) normal_task = make_task( project.id, ms.id, user.id, task_type="issue", task_subtype="defect", status=TaskStatus.COMPLETED, ) try_auto_complete_milestone(db, normal_task, user_id=user.id) db.refresh(ms) assert ms.status == MilestoneStatus.UNDERGOING # unchanged def test_no_auto_complete_when_not_undergoing( self, db, make_user, make_project, make_milestone, make_task, ): from app.api.routers.milestone_actions import try_auto_complete_milestone user = make_user() project = make_project(owner_id=user.id) ms = make_milestone(project.id, user.id, status=MilestoneStatus.FREEZE) release_task = make_task( project.id, ms.id, user.id, task_type="maintenance", task_subtype="release", status=TaskStatus.COMPLETED, ) try_auto_complete_milestone(db, release_task, user_id=user.id) db.refresh(ms) assert ms.status == MilestoneStatus.FREEZE # unchanged # ----------------------------------------------------------------------- # Preflight # ----------------------------------------------------------------------- class TestPreflight: """GET /projects/{pid}/milestones/{mid}/actions/preflight""" def test_preflight_freeze_allowed( self, client, db, make_user, make_project, make_milestone, make_task, seed_roles_and_permissions, make_member, auth_header, ): _, mgr_role, _ = seed_roles_and_permissions user = make_user() project = make_project(owner_id=user.id) make_member(project.id, user.id, mgr_role.id) ms = make_milestone(project.id, user.id) make_task(project.id, ms.id, user.id, task_type="maintenance", task_subtype="release") resp = client.get( f"/projects/{project.id}/milestones/{ms.id}/actions/preflight", headers=auth_header(user), ) assert resp.status_code == 200 data = resp.json() assert data["freeze"]["allowed"] is True def test_preflight_freeze_not_allowed( self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header, ): _, mgr_role, _ = seed_roles_and_permissions user = make_user() project = make_project(owner_id=user.id) make_member(project.id, user.id, mgr_role.id) ms = make_milestone(project.id, user.id) # No release task resp = client.get( f"/projects/{project.id}/milestones/{ms.id}/actions/preflight", headers=auth_header(user), ) assert resp.status_code == 200 data = resp.json() assert data["freeze"]["allowed"] is False