Add 134 tests as independent test project: - test_auth.py (5): Login, JWT, protected endpoints - test_users.py (8): User CRUD, permissions - test_projects.py (8): Project CRUD, ownership - test_milestones.py (7): Milestone CRUD, filtering - test_tasks.py (8): Task CRUD, filtering - test_comments.py (5): Comment CRUD, permissions - test_roles.py (9): Role/permission management - test_milestone_actions.py (17): Milestone state machine - test_task_transitions.py (34): Task state machine - test_propose.py (19): Propose CRUD, lifecycle - test_misc.py (14): Notifications, activity, API keys, dashboard Setup: - conftest.py: SQLite in-memory DB, fixtures - requirements.txt: Dependencies - pyproject.toml: Pytest config - README.md: Documentation
359 lines
13 KiB
Python
359 lines
13 KiB
Python
"""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
|