"""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"