Files
HarborForge.Backend.Test/tests/test_propose.py
zhi b505fa7b35 BE-PR-010: update proposal tests for feat_task_id deprecation
- Accept tests now create Essentials (required by BE-PR-007)
- Accept tests assert feat_task_id is None (no longer written)
- Added _add_essential helper for test convenience
- Updated test docstrings with BE-PR-010 references
2026-03-30 12:50:00 +00:00

668 lines
25 KiB
Python

"""Proposal backend tests (renamed from Propose).
Covers:
- CRUD: create, list, get, update
- propose_code per-project incrementing
- accept → auto-generate story tasks from Essentials (feat_task_id deprecated per BE-PR-010)
- accept with non-open milestone → fail
- reject → status change
- rejected → reopen back to open
- feat_task_id cannot be set manually (deprecated, read-only)
- edit restrictions (only open proposals editable)
- permission checks for accept/reject/reopen
- Legacy /proposes endpoint still works
"""
import pytest
from app.models.milestone import MilestoneStatus
from app.models.task import TaskStatus
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _proposal_url(project_id: int, proposal_id: int | None = None) -> str:
"""Canonical /proposals URL."""
base = f"/projects/{project_id}/proposals"
return f"{base}/{proposal_id}" if proposal_id else base
def _legacy_propose_url(project_id: int, propose_id: int | None = None) -> str:
"""Legacy /proposes URL for backward-compat tests."""
base = f"/projects/{project_id}/proposes"
return f"{base}/{propose_id}" if propose_id else base
def _essential_url(project_id: int, proposal_id: int) -> str:
"""Essential CRUD URL under a Proposal."""
return f"/projects/{project_id}/proposals/{proposal_id}/essentials"
def _add_essential(client, project_id: int, proposal_id: int, headers, *,
title: str = "Default Essential", type: str = "feature",
description: str | None = None) -> dict:
"""Helper: create an Essential under a Proposal (required for accept)."""
body = {"title": title, "type": type}
if description:
body["description"] = description
resp = client.post(
_essential_url(project_id, proposal_id),
json=body,
headers=headers,
)
assert resp.status_code == 201, f"Failed to create essential: {resp.text}"
return resp.json()
# ===========================================================================
# CRUD
# ===========================================================================
class TestProposalCRUD:
"""Basic create / list / get / update."""
def test_create_proposal(
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(
_proposal_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 # DEPRECATED (BE-PR-010): always None for new proposals
def test_list_proposals(
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 proposals
client.post(_proposal_url(project.id), json={"title": "P1"}, headers=auth_header(user))
client.post(_proposal_url(project.id), json={"title": "P2"}, headers=auth_header(user))
resp = client.get(_proposal_url(project.id), headers=auth_header(user))
assert resp.status_code == 200
assert len(resp.json()) == 2
def test_get_proposal(
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(_proposal_url(project.id), json={"title": "P1"}, headers=auth_header(user))
proposal_id = create_resp.json()["id"]
resp = client.get(_proposal_url(project.id, proposal_id), headers=auth_header(user))
assert resp.status_code == 200
assert resp.json()["title"] == "P1"
def test_update_proposal_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(_proposal_url(project.id), json={"title": "Old"}, headers=auth_header(user))
proposal_id = create_resp.json()["id"]
resp = client.patch(
_proposal_url(project.id, proposal_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"
# ===========================================================================
# Proposal Code
# ===========================================================================
class TestProposalCode:
"""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(_proposal_url(proj_a.id), json={"title": "A1"}, headers=auth_header(user))
r2 = client.post(_proposal_url(proj_a.id), json={"title": "A2"}, headers=auth_header(user))
# Create 1 in BETA
r3 = client.post(_proposal_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:
"""accept proposal → 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(
_proposal_url(project.id),
json={"title": "Cool Feature", "description": "Do something cool"},
headers=auth_header(mgr),
)
proposal_id = create_resp.json()["id"]
# New accept flow requires at least one Essential (BE-PR-007)
_add_essential(client, project.id, proposal_id, auth_header(mgr),
title="Cool Feature", type="feature", description="Do something cool")
resp = client.post(
_proposal_url(project.id, proposal_id) + "/accept",
json={"milestone_id": ms.id},
headers=auth_header(mgr),
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "accepted"
# BE-PR-010: feat_task_id is no longer written by new accept flow
assert data["feat_task_id"] is None
# Tasks are tracked via generated_tasks (source_proposal_id)
assert "generated_tasks" in data
assert len(data["generated_tasks"]) >= 1
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(
_proposal_url(project.id),
json={"title": "Feature X"},
headers=auth_header(mgr),
)
proposal_id = create_resp.json()["id"]
# Add Essential (required for accept per BE-PR-007)
_add_essential(client, project.id, proposal_id, auth_header(mgr))
resp = client.post(
_proposal_url(project.id, proposal_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(
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
)
proposal_id = create_resp.json()["id"]
# Add Essential (required for accept per BE-PR-007)
_add_essential(client, project.id, proposal_id, auth_header(mgr))
# First accept
client.post(
_proposal_url(project.id, proposal_id) + "/accept",
json={"milestone_id": ms.id},
headers=auth_header(mgr),
)
# Second accept should fail
resp = client.post(
_proposal_url(project.id, proposal_id) + "/accept",
json={"milestone_id": ms.id},
headers=auth_header(mgr),
)
assert resp.status_code == 400
def test_accept_does_not_write_feat_task_id(
self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header,
):
"""BE-PR-010: new accept flow does NOT populate feat_task_id.
feat_task_id is deprecated; tasks are now tracked via
Task.source_proposal_id / source_essential_id.
"""
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(
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
)
proposal_id = create_resp.json()["id"]
# Add Essential (required for accept per BE-PR-007)
_add_essential(client, project.id, proposal_id, auth_header(mgr))
resp = client.post(
_proposal_url(project.id, proposal_id) + "/accept",
json={"milestone_id": ms.id},
headers=auth_header(mgr),
)
data = resp.json()
# feat_task_id should remain None — deprecated field
assert data["feat_task_id"] is None
# Re-fetch to confirm persistence
get_resp = client.get(_proposal_url(project.id, proposal_id), headers=auth_header(mgr))
assert get_resp.json()["feat_task_id"] is None
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 proposal
create_resp = client.post(
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(dev_user),
)
proposal_id = create_resp.json()["id"]
# Add Essential (required for accept per BE-PR-007)
_add_essential(client, project.id, proposal_id, auth_header(dev_user))
# Dev tries to accept — should fail
resp = client.post(
_proposal_url(project.id, proposal_id) + "/accept",
json={"milestone_id": ms.id},
headers=auth_header(dev_user),
)
assert resp.status_code == 403
# ===========================================================================
# Reject
# ===========================================================================
class TestReject:
"""reject proposal."""
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(
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
)
proposal_id = create_resp.json()["id"]
resp = client.post(
_proposal_url(project.id, proposal_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(
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
)
proposal_id = create_resp.json()["id"]
# Add Essential (required for accept per BE-PR-007)
_add_essential(client, project.id, proposal_id, auth_header(mgr))
# Accept first
client.post(
_proposal_url(project.id, proposal_id) + "/accept",
json={"milestone_id": ms.id},
headers=auth_header(mgr),
)
# Now reject should fail
resp = client.post(
_proposal_url(project.id, proposal_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(
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(dev_user),
)
proposal_id = create_resp.json()["id"]
resp = client.post(
_proposal_url(project.id, proposal_id) + "/reject",
json={"reason": "nah"},
headers=auth_header(dev_user),
)
assert resp.status_code == 403
# ===========================================================================
# Reopen
# ===========================================================================
class TestReopen:
"""reopen rejected proposal."""
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(
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
)
proposal_id = create_resp.json()["id"]
# Reject first
client.post(
_proposal_url(project.id, proposal_id) + "/reject",
json={"reason": "wait"},
headers=auth_header(mgr),
)
# Reopen
resp = client.post(
_proposal_url(project.id, proposal_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(
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
)
proposal_id = create_resp.json()["id"]
# Try reopen on open proposal — should fail
resp = client.post(
_proposal_url(project.id, proposal_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(
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(dev_user),
)
proposal_id = create_resp.json()["id"]
# Owner rejects
client.post(
_proposal_url(project.id, proposal_id) + "/reject",
json={"reason": "nah"},
headers=auth_header(owner),
)
# Dev tries to reopen — should fail
resp = client.post(
_proposal_url(project.id, proposal_id) + "/reopen",
headers=auth_header(dev_user),
)
assert resp.status_code == 403
# ===========================================================================
# feat_task_id protection (DEPRECATED per BE-PR-010)
# ===========================================================================
class TestFeatTaskIdProtection:
"""feat_task_id is deprecated and read-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(
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(user),
)
proposal_id = create_resp.json()["id"]
# Try to set feat_task_id via PATCH
resp = client.patch(
_proposal_url(project.id, proposal_id),
json={"feat_task_id": "999"},
headers=auth_header(user),
)
assert resp.status_code == 200
# feat_task_id should still be None — deprecated, read-only (BE-PR-010)
assert resp.json()["feat_task_id"] is None
# ===========================================================================
# Edit restrictions
# ===========================================================================
class TestEditRestrictions:
"""Proposal editing is only allowed in open status."""
def test_edit_accepted_proposal_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(
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
)
proposal_id = create_resp.json()["id"]
# Add Essential (required for accept per BE-PR-007)
_add_essential(client, project.id, proposal_id, auth_header(mgr))
# Accept
client.post(
_proposal_url(project.id, proposal_id) + "/accept",
json={"milestone_id": ms.id},
headers=auth_header(mgr),
)
# Try to edit
resp = client.patch(
_proposal_url(project.id, proposal_id),
json={"title": "Changed"},
headers=auth_header(mgr),
)
assert resp.status_code == 400
assert "open" in resp.json()["detail"].lower()
def test_edit_rejected_proposal_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(
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
)
proposal_id = create_resp.json()["id"]
# Reject
client.post(
_proposal_url(project.id, proposal_id) + "/reject",
json={"reason": "no"},
headers=auth_header(mgr),
)
# Try to edit
resp = client.patch(
_proposal_url(project.id, proposal_id),
json={"title": "Changed"},
headers=auth_header(mgr),
)
assert resp.status_code == 400
# ===========================================================================
# Legacy /proposes endpoint backward compatibility
# ===========================================================================
class TestLegacyProposeEndpoint:
"""Verify the old /proposes URL still works."""
def test_legacy_create_and_list(
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="LEG")
make_member(project.id, user.id, dev_role.id)
# Create via legacy endpoint
resp = client.post(
_legacy_propose_url(project.id),
json={"title": "Legacy Proposal"},
headers=auth_header(user),
)
assert resp.status_code == 201
assert resp.json()["title"] == "Legacy Proposal"
# List via legacy endpoint
resp = client.get(_legacy_propose_url(project.id), headers=auth_header(user))
assert resp.status_code == 200
assert len(resp.json()) >= 1
def test_legacy_get_and_update(
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 via new endpoint
create_resp = client.post(
_proposal_url(project.id), json={"title": "Cross"}, headers=auth_header(user),
)
pid = create_resp.json()["id"]
# Get via legacy
resp = client.get(_legacy_propose_url(project.id, pid), headers=auth_header(user))
assert resp.status_code == 200
assert resp.json()["title"] == "Cross"
# Update via legacy
resp = client.patch(
_legacy_propose_url(project.id, pid),
json={"title": "Updated Cross"},
headers=auth_header(user),
)
assert resp.status_code == 200
assert resp.json()["title"] == "Updated Cross"