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
560 lines
20 KiB
Python
560 lines
20 KiB
Python
"""P13.3 — Propose backend tests.
|
|
|
|
Covers:
|
|
- CRUD: create, list, get, update
|
|
- propose_code per-project incrementing
|
|
- accept → auto-generate feature story task + feat_task_id
|
|
- accept with non-open milestone → fail
|
|
- reject → status change
|
|
- rejected → reopen back to open
|
|
- feat_task_id cannot be set manually
|
|
- edit restrictions (only open proposes editable)
|
|
- permission checks for accept/reject/reopen
|
|
"""
|
|
import pytest
|
|
from app.models.milestone import MilestoneStatus
|
|
from app.models.task import TaskStatus
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _propose_url(project_id: int, propose_id: int | None = None) -> str:
|
|
base = f"/projects/{project_id}/proposes"
|
|
return f"{base}/{propose_id}" if propose_id else base
|
|
|
|
|
|
# ===========================================================================
|
|
# CRUD
|
|
# ===========================================================================
|
|
|
|
class TestProposeCRUD:
|
|
"""Basic create / list / get / update."""
|
|
|
|
def test_create_propose(
|
|
self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header,
|
|
):
|
|
admin_role, mgr_role, dev_role = seed_roles_and_permissions
|
|
user = make_user()
|
|
project = make_project(owner_id=user.id, project_code="PROJ")
|
|
make_member(project.id, user.id, dev_role.id)
|
|
|
|
resp = client.post(
|
|
_propose_url(project.id),
|
|
json={"title": "New Feature Idea", "description": "Some details"},
|
|
headers=auth_header(user),
|
|
)
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert data["title"] == "New Feature Idea"
|
|
assert data["status"] == "open"
|
|
assert data["propose_code"].startswith("PROJ:P")
|
|
assert data["feat_task_id"] is None
|
|
|
|
def test_list_proposes(
|
|
self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header,
|
|
):
|
|
admin_role, mgr_role, dev_role = seed_roles_and_permissions
|
|
user = make_user()
|
|
project = make_project(owner_id=user.id)
|
|
make_member(project.id, user.id, dev_role.id)
|
|
|
|
# Create two proposes
|
|
client.post(_propose_url(project.id), json={"title": "P1"}, headers=auth_header(user))
|
|
client.post(_propose_url(project.id), json={"title": "P2"}, headers=auth_header(user))
|
|
|
|
resp = client.get(_propose_url(project.id), headers=auth_header(user))
|
|
assert resp.status_code == 200
|
|
assert len(resp.json()) == 2
|
|
|
|
def test_get_propose(
|
|
self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header,
|
|
):
|
|
admin_role, mgr_role, dev_role = seed_roles_and_permissions
|
|
user = make_user()
|
|
project = make_project(owner_id=user.id)
|
|
make_member(project.id, user.id, dev_role.id)
|
|
|
|
create_resp = client.post(_propose_url(project.id), json={"title": "P1"}, headers=auth_header(user))
|
|
propose_id = create_resp.json()["id"]
|
|
|
|
resp = client.get(_propose_url(project.id, propose_id), headers=auth_header(user))
|
|
assert resp.status_code == 200
|
|
assert resp.json()["title"] == "P1"
|
|
|
|
def test_update_propose_open(
|
|
self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header,
|
|
):
|
|
admin_role, mgr_role, dev_role = seed_roles_and_permissions
|
|
user = make_user()
|
|
project = make_project(owner_id=user.id)
|
|
make_member(project.id, user.id, dev_role.id)
|
|
|
|
create_resp = client.post(_propose_url(project.id), json={"title": "Old"}, headers=auth_header(user))
|
|
propose_id = create_resp.json()["id"]
|
|
|
|
resp = client.patch(
|
|
_propose_url(project.id, propose_id),
|
|
json={"title": "New Title", "description": "Updated"},
|
|
headers=auth_header(user),
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["title"] == "New Title"
|
|
assert resp.json()["description"] == "Updated"
|
|
|
|
|
|
# ===========================================================================
|
|
# Propose Code
|
|
# ===========================================================================
|
|
|
|
class TestProposeCode:
|
|
"""P1.4 — propose_code increments per project independently."""
|
|
|
|
def test_code_increments_per_project(
|
|
self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header,
|
|
):
|
|
admin_role, mgr_role, dev_role = seed_roles_and_permissions
|
|
user = make_user()
|
|
proj_a = make_project(owner_id=user.id, project_code="ALPHA")
|
|
proj_b = make_project(owner_id=user.id, project_code="BETA")
|
|
make_member(proj_a.id, user.id, dev_role.id)
|
|
make_member(proj_b.id, user.id, dev_role.id)
|
|
|
|
# Create 2 in ALPHA
|
|
r1 = client.post(_propose_url(proj_a.id), json={"title": "A1"}, headers=auth_header(user))
|
|
r2 = client.post(_propose_url(proj_a.id), json={"title": "A2"}, headers=auth_header(user))
|
|
|
|
# Create 1 in BETA
|
|
r3 = client.post(_propose_url(proj_b.id), json={"title": "B1"}, headers=auth_header(user))
|
|
|
|
code1 = r1.json()["propose_code"]
|
|
code2 = r2.json()["propose_code"]
|
|
code3 = r3.json()["propose_code"]
|
|
|
|
assert code1.startswith("ALPHA:P")
|
|
assert code2.startswith("ALPHA:P")
|
|
assert code3.startswith("BETA:P")
|
|
# They should be distinct
|
|
assert code1 != code2
|
|
|
|
|
|
# ===========================================================================
|
|
# Accept
|
|
# ===========================================================================
|
|
|
|
class TestAccept:
|
|
"""P6.2 — accept propose → create feature story task."""
|
|
|
|
def test_accept_success(
|
|
self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header,
|
|
):
|
|
admin_role, mgr_role, dev_role = seed_roles_and_permissions
|
|
mgr = make_user()
|
|
project = make_project(owner_id=mgr.id)
|
|
make_member(project.id, mgr.id, mgr_role.id)
|
|
|
|
ms = make_milestone(project.id, mgr.id, status=MilestoneStatus.OPEN)
|
|
|
|
create_resp = client.post(
|
|
_propose_url(project.id),
|
|
json={"title": "Cool Feature", "description": "Do something cool"},
|
|
headers=auth_header(mgr),
|
|
)
|
|
propose_id = create_resp.json()["id"]
|
|
|
|
resp = client.post(
|
|
_propose_url(project.id, propose_id) + "/accept",
|
|
json={"milestone_id": ms.id},
|
|
headers=auth_header(mgr),
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["status"] == "accepted"
|
|
assert data["feat_task_id"] is not None
|
|
|
|
# Verify the generated task exists
|
|
from app.models.task import Task
|
|
task = db.query(Task).filter(Task.id == int(data["feat_task_id"])).first()
|
|
assert task is not None
|
|
assert task.title == "Cool Feature"
|
|
assert task.description == "Do something cool"
|
|
assert task.task_type == "story"
|
|
assert task.task_subtype == "feature"
|
|
task_status = task.status.value if hasattr(task.status, "value") else task.status
|
|
assert task_status == "pending"
|
|
assert task.milestone_id == ms.id
|
|
|
|
def test_accept_non_open_milestone_fails(
|
|
self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header,
|
|
):
|
|
admin_role, mgr_role, dev_role = seed_roles_and_permissions
|
|
mgr = make_user()
|
|
project = make_project(owner_id=mgr.id)
|
|
make_member(project.id, mgr.id, mgr_role.id)
|
|
|
|
ms = make_milestone(project.id, mgr.id, status=MilestoneStatus.FREEZE)
|
|
|
|
create_resp = client.post(
|
|
_propose_url(project.id),
|
|
json={"title": "Feature X"},
|
|
headers=auth_header(mgr),
|
|
)
|
|
propose_id = create_resp.json()["id"]
|
|
|
|
resp = client.post(
|
|
_propose_url(project.id, propose_id) + "/accept",
|
|
json={"milestone_id": ms.id},
|
|
headers=auth_header(mgr),
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "open" in resp.json()["detail"].lower()
|
|
|
|
def test_accept_already_accepted_fails(
|
|
self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header,
|
|
):
|
|
admin_role, mgr_role, dev_role = seed_roles_and_permissions
|
|
mgr = make_user()
|
|
project = make_project(owner_id=mgr.id)
|
|
make_member(project.id, mgr.id, mgr_role.id)
|
|
|
|
ms = make_milestone(project.id, mgr.id, status=MilestoneStatus.OPEN)
|
|
|
|
create_resp = client.post(
|
|
_propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
|
|
)
|
|
propose_id = create_resp.json()["id"]
|
|
|
|
# First accept
|
|
client.post(
|
|
_propose_url(project.id, propose_id) + "/accept",
|
|
json={"milestone_id": ms.id},
|
|
headers=auth_header(mgr),
|
|
)
|
|
|
|
# Second accept should fail
|
|
resp = client.post(
|
|
_propose_url(project.id, propose_id) + "/accept",
|
|
json={"milestone_id": ms.id},
|
|
headers=auth_header(mgr),
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
def test_accept_auto_fills_feat_task_id(
|
|
self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header,
|
|
):
|
|
admin_role, mgr_role, dev_role = seed_roles_and_permissions
|
|
mgr = make_user()
|
|
project = make_project(owner_id=mgr.id)
|
|
make_member(project.id, mgr.id, mgr_role.id)
|
|
|
|
ms = make_milestone(project.id, mgr.id, status=MilestoneStatus.OPEN)
|
|
|
|
create_resp = client.post(
|
|
_propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
|
|
)
|
|
propose_id = create_resp.json()["id"]
|
|
|
|
resp = client.post(
|
|
_propose_url(project.id, propose_id) + "/accept",
|
|
json={"milestone_id": ms.id},
|
|
headers=auth_header(mgr),
|
|
)
|
|
data = resp.json()
|
|
assert data["feat_task_id"] is not None
|
|
|
|
# Re-fetch to confirm persistence
|
|
get_resp = client.get(_propose_url(project.id, propose_id), headers=auth_header(mgr))
|
|
assert get_resp.json()["feat_task_id"] == data["feat_task_id"]
|
|
|
|
def test_accept_no_permission_fails(
|
|
self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header,
|
|
):
|
|
"""dev role should not have propose.accept permission."""
|
|
admin_role, mgr_role, dev_role = seed_roles_and_permissions
|
|
owner = make_user()
|
|
dev_user = make_user()
|
|
project = make_project(owner_id=owner.id)
|
|
make_member(project.id, owner.id, mgr_role.id)
|
|
make_member(project.id, dev_user.id, dev_role.id)
|
|
|
|
ms = make_milestone(project.id, owner.id, status=MilestoneStatus.OPEN)
|
|
|
|
# Dev creates the propose
|
|
create_resp = client.post(
|
|
_propose_url(project.id), json={"title": "F"}, headers=auth_header(dev_user),
|
|
)
|
|
propose_id = create_resp.json()["id"]
|
|
|
|
# Dev tries to accept — should fail
|
|
resp = client.post(
|
|
_propose_url(project.id, propose_id) + "/accept",
|
|
json={"milestone_id": ms.id},
|
|
headers=auth_header(dev_user),
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
|
|
# ===========================================================================
|
|
# Reject
|
|
# ===========================================================================
|
|
|
|
class TestReject:
|
|
"""P6.3 — reject propose."""
|
|
|
|
def test_reject_success(
|
|
self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header,
|
|
):
|
|
admin_role, mgr_role, dev_role = seed_roles_and_permissions
|
|
mgr = make_user()
|
|
project = make_project(owner_id=mgr.id)
|
|
make_member(project.id, mgr.id, mgr_role.id)
|
|
|
|
create_resp = client.post(
|
|
_propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
|
|
)
|
|
propose_id = create_resp.json()["id"]
|
|
|
|
resp = client.post(
|
|
_propose_url(project.id, propose_id) + "/reject",
|
|
json={"reason": "Not needed"},
|
|
headers=auth_header(mgr),
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "rejected"
|
|
|
|
def test_reject_non_open_fails(
|
|
self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header,
|
|
):
|
|
admin_role, mgr_role, dev_role = seed_roles_and_permissions
|
|
mgr = make_user()
|
|
project = make_project(owner_id=mgr.id)
|
|
make_member(project.id, mgr.id, mgr_role.id)
|
|
|
|
ms = make_milestone(project.id, mgr.id, status=MilestoneStatus.OPEN)
|
|
|
|
create_resp = client.post(
|
|
_propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
|
|
)
|
|
propose_id = create_resp.json()["id"]
|
|
|
|
# Accept first
|
|
client.post(
|
|
_propose_url(project.id, propose_id) + "/accept",
|
|
json={"milestone_id": ms.id},
|
|
headers=auth_header(mgr),
|
|
)
|
|
|
|
# Now reject should fail
|
|
resp = client.post(
|
|
_propose_url(project.id, propose_id) + "/reject",
|
|
json={"reason": "Changed mind"},
|
|
headers=auth_header(mgr),
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
def test_reject_no_permission_fails(
|
|
self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header,
|
|
):
|
|
admin_role, mgr_role, dev_role = seed_roles_and_permissions
|
|
owner = make_user()
|
|
dev_user = make_user()
|
|
project = make_project(owner_id=owner.id)
|
|
make_member(project.id, owner.id, mgr_role.id)
|
|
make_member(project.id, dev_user.id, dev_role.id)
|
|
|
|
create_resp = client.post(
|
|
_propose_url(project.id), json={"title": "F"}, headers=auth_header(dev_user),
|
|
)
|
|
propose_id = create_resp.json()["id"]
|
|
|
|
resp = client.post(
|
|
_propose_url(project.id, propose_id) + "/reject",
|
|
json={"reason": "nah"},
|
|
headers=auth_header(dev_user),
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
|
|
# ===========================================================================
|
|
# Reopen
|
|
# ===========================================================================
|
|
|
|
class TestReopen:
|
|
"""P6.4 — reopen rejected propose."""
|
|
|
|
def test_reopen_success(
|
|
self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header,
|
|
):
|
|
admin_role, mgr_role, dev_role = seed_roles_and_permissions
|
|
mgr = make_user()
|
|
project = make_project(owner_id=mgr.id)
|
|
make_member(project.id, mgr.id, mgr_role.id)
|
|
|
|
create_resp = client.post(
|
|
_propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
|
|
)
|
|
propose_id = create_resp.json()["id"]
|
|
|
|
# Reject first
|
|
client.post(
|
|
_propose_url(project.id, propose_id) + "/reject",
|
|
json={"reason": "wait"},
|
|
headers=auth_header(mgr),
|
|
)
|
|
|
|
# Reopen
|
|
resp = client.post(
|
|
_propose_url(project.id, propose_id) + "/reopen",
|
|
headers=auth_header(mgr),
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "open"
|
|
|
|
def test_reopen_non_rejected_fails(
|
|
self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header,
|
|
):
|
|
admin_role, mgr_role, dev_role = seed_roles_and_permissions
|
|
mgr = make_user()
|
|
project = make_project(owner_id=mgr.id)
|
|
make_member(project.id, mgr.id, mgr_role.id)
|
|
|
|
create_resp = client.post(
|
|
_propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
|
|
)
|
|
propose_id = create_resp.json()["id"]
|
|
|
|
# Try reopen on open propose — should fail
|
|
resp = client.post(
|
|
_propose_url(project.id, propose_id) + "/reopen",
|
|
headers=auth_header(mgr),
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
def test_reopen_no_permission_fails(
|
|
self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header,
|
|
):
|
|
admin_role, mgr_role, dev_role = seed_roles_and_permissions
|
|
owner = make_user()
|
|
dev_user = make_user()
|
|
project = make_project(owner_id=owner.id)
|
|
make_member(project.id, owner.id, mgr_role.id)
|
|
make_member(project.id, dev_user.id, dev_role.id)
|
|
|
|
create_resp = client.post(
|
|
_propose_url(project.id), json={"title": "F"}, headers=auth_header(dev_user),
|
|
)
|
|
propose_id = create_resp.json()["id"]
|
|
|
|
# Owner rejects
|
|
client.post(
|
|
_propose_url(project.id, propose_id) + "/reject",
|
|
json={"reason": "nah"},
|
|
headers=auth_header(owner),
|
|
)
|
|
|
|
# Dev tries to reopen — should fail
|
|
resp = client.post(
|
|
_propose_url(project.id, propose_id) + "/reopen",
|
|
headers=auth_header(dev_user),
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
|
|
# ===========================================================================
|
|
# feat_task_id protection
|
|
# ===========================================================================
|
|
|
|
class TestFeatTaskIdProtection:
|
|
"""P6.5 — feat_task_id is server-side only, cannot be set by client."""
|
|
|
|
def test_update_cannot_set_feat_task_id(
|
|
self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header,
|
|
):
|
|
admin_role, mgr_role, dev_role = seed_roles_and_permissions
|
|
user = make_user()
|
|
project = make_project(owner_id=user.id)
|
|
make_member(project.id, user.id, dev_role.id)
|
|
|
|
create_resp = client.post(
|
|
_propose_url(project.id), json={"title": "F"}, headers=auth_header(user),
|
|
)
|
|
propose_id = create_resp.json()["id"]
|
|
|
|
# Try to set feat_task_id via PATCH
|
|
resp = client.patch(
|
|
_propose_url(project.id, propose_id),
|
|
json={"feat_task_id": "999"},
|
|
headers=auth_header(user),
|
|
)
|
|
assert resp.status_code == 200
|
|
# feat_task_id should still be None (server ignores it)
|
|
assert resp.json()["feat_task_id"] is None
|
|
|
|
|
|
# ===========================================================================
|
|
# Edit restrictions
|
|
# ===========================================================================
|
|
|
|
class TestEditRestrictions:
|
|
"""Propose editing is only allowed in open status."""
|
|
|
|
def test_edit_accepted_propose_fails(
|
|
self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header,
|
|
):
|
|
admin_role, mgr_role, dev_role = seed_roles_and_permissions
|
|
mgr = make_user()
|
|
project = make_project(owner_id=mgr.id)
|
|
make_member(project.id, mgr.id, mgr_role.id)
|
|
|
|
ms = make_milestone(project.id, mgr.id, status=MilestoneStatus.OPEN)
|
|
|
|
create_resp = client.post(
|
|
_propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
|
|
)
|
|
propose_id = create_resp.json()["id"]
|
|
|
|
# Accept
|
|
client.post(
|
|
_propose_url(project.id, propose_id) + "/accept",
|
|
json={"milestone_id": ms.id},
|
|
headers=auth_header(mgr),
|
|
)
|
|
|
|
# Try to edit
|
|
resp = client.patch(
|
|
_propose_url(project.id, propose_id),
|
|
json={"title": "Changed"},
|
|
headers=auth_header(mgr),
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "open" in resp.json()["detail"].lower()
|
|
|
|
def test_edit_rejected_propose_fails(
|
|
self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header,
|
|
):
|
|
admin_role, mgr_role, dev_role = seed_roles_and_permissions
|
|
mgr = make_user()
|
|
project = make_project(owner_id=mgr.id)
|
|
make_member(project.id, mgr.id, mgr_role.id)
|
|
|
|
create_resp = client.post(
|
|
_propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
|
|
)
|
|
propose_id = create_resp.json()["id"]
|
|
|
|
# Reject
|
|
client.post(
|
|
_propose_url(project.id, propose_id) + "/reject",
|
|
json={"reason": "no"},
|
|
headers=auth_header(mgr),
|
|
)
|
|
|
|
# Try to edit
|
|
resp = client.patch(
|
|
_propose_url(project.id, propose_id),
|
|
json={"title": "Changed"},
|
|
headers=auth_header(mgr),
|
|
)
|
|
assert resp.status_code == 400
|