test(P13.1): add milestone state machine tests — 17 tests covering freeze/start/close/auto-complete/preflight
New test infrastructure: - tests/conftest.py: SQLite in-memory fixtures, TestClient wired to test DB, factory fixtures for User/Project/Milestone/Task/Roles/Permissions - tests/test_milestone_actions.py: 17 tests covering: - freeze success/no-release-task/multiple-release-tasks/wrong-status - start success+started_at/deps-not-met/wrong-status - close from open/freeze/undergoing, rejected from completed/closed - auto-complete on release task finish, no auto-complete for non-release/wrong-status - preflight allowed/not-allowed
This commit is contained in:
358
tests/test_milestone_actions.py
Normal file
358
tests/test_milestone_actions.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user