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