From 9e8dda3f160d1cbc1830bf5b8e1b20b37bb330c0 Mon Sep 17 00:00:00 2001 From: zhi Date: Sun, 29 Mar 2026 15:35:47 +0000 Subject: [PATCH] BE-PR-001: Update tests for Propose -> Proposal rename - Tests now use /proposals canonical URL - Added legacy /proposes backward-compat tests - Updated class/function names to Proposal terminology --- tests/__pycache__/__init__.cpython-312.pyc | Bin 214 -> 214 bytes .../conftest.cpython-312-pytest-9.0.2.pyc | Bin 11600 -> 11699 bytes .../test_propose.cpython-312-pytest-9.0.2.pyc | Bin 47070 -> 54004 bytes tests/test_propose.py | 232 +++++++++++------- 4 files changed, 149 insertions(+), 83 deletions(-) diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc index c0e310b591b1a7785549abf5fd3840738a0a3c4f..c6d0d5a401f50347f66bb9b60770f8b98abe4328 100644 GIT binary patch delta 19 Zcmcb{c#VlB*k}hU264Dh&?t0hu81h_(1ga;MrgY! ziePb2o=y&ei$jVa4+lX9>(Eq5CTilMVQDXbis<`oz!p)RQq*}O*uw?!J=mwX z!Mous|2q5<*TntMDT);d-s6r~F?$rx#dMScPsL1ZQrYuXesTK^?}ZPDkNYKlEqcj& z8a|7ujG-XSuUKuQ#20HBDDm^TCFJ==E{)^jM{XKs`11UGu{Fos*|xl{H*U@?@H3-N z^i8=HgUuKDw^1%y`NzN|P77IX79uCcWwJJ|+GVmfq1qMlfvD90SugwSlgj!823qk| R{YqEEKm)$&KBa3y>K{{eoVx%3 delta 550 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 F`9I)YhoS%g diff --git a/tests/test_propose.py b/tests/test_propose.py index 97469e2..d9d88c0 100644 --- a/tests/test_propose.py +++ b/tests/test_propose.py @@ -1,4 +1,4 @@ -"""P13.3 — Propose backend tests. +"""Proposal backend tests (renamed from Propose). Covers: - CRUD: create, list, get, update @@ -8,8 +8,9 @@ Covers: - reject → status change - rejected → reopen back to open - feat_task_id cannot be set manually -- edit restrictions (only open proposes editable) +- 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 @@ -20,7 +21,14 @@ from app.models.task import TaskStatus # Helpers # --------------------------------------------------------------------------- -def _propose_url(project_id: int, propose_id: int | None = None) -> str: +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 @@ -29,10 +37,10 @@ def _propose_url(project_id: int, propose_id: int | None = None) -> str: # CRUD # =========================================================================== -class TestProposeCRUD: +class TestProposalCRUD: """Basic create / list / get / update.""" - def test_create_propose( + 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 @@ -41,7 +49,7 @@ class TestProposeCRUD: make_member(project.id, user.id, dev_role.id) resp = client.post( - _propose_url(project.id), + _proposal_url(project.id), json={"title": "New Feature Idea", "description": "Some details"}, headers=auth_header(user), ) @@ -52,7 +60,7 @@ class TestProposeCRUD: assert data["propose_code"].startswith("PROJ:P") assert data["feat_task_id"] is None - def test_list_proposes( + 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 @@ -60,15 +68,15 @@ class TestProposeCRUD: 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)) + # 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(_propose_url(project.id), 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_propose( + 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 @@ -76,14 +84,14 @@ class TestProposeCRUD: 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"] + create_resp = client.post(_proposal_url(project.id), json={"title": "P1"}, headers=auth_header(user)) + proposal_id = create_resp.json()["id"] - resp = client.get(_propose_url(project.id, propose_id), headers=auth_header(user)) + 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_propose_open( + 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 @@ -91,11 +99,11 @@ class TestProposeCRUD: 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"] + create_resp = client.post(_proposal_url(project.id), json={"title": "Old"}, headers=auth_header(user)) + proposal_id = create_resp.json()["id"] resp = client.patch( - _propose_url(project.id, propose_id), + _proposal_url(project.id, proposal_id), json={"title": "New Title", "description": "Updated"}, headers=auth_header(user), ) @@ -105,11 +113,11 @@ class TestProposeCRUD: # =========================================================================== -# Propose Code +# Proposal Code # =========================================================================== -class TestProposeCode: - """P1.4 — propose_code increments per project independently.""" +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, @@ -122,11 +130,11 @@ class TestProposeCode: 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)) + 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(_propose_url(proj_b.id), json={"title": "B1"}, headers=auth_header(user)) + 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"] @@ -144,7 +152,7 @@ class TestProposeCode: # =========================================================================== class TestAccept: - """P6.2 — accept propose → create feature story task.""" + """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, @@ -157,14 +165,14 @@ class TestAccept: ms = make_milestone(project.id, mgr.id, status=MilestoneStatus.OPEN) create_resp = client.post( - _propose_url(project.id), + _proposal_url(project.id), json={"title": "Cool Feature", "description": "Do something cool"}, headers=auth_header(mgr), ) - propose_id = create_resp.json()["id"] + proposal_id = create_resp.json()["id"] resp = client.post( - _propose_url(project.id, propose_id) + "/accept", + _proposal_url(project.id, proposal_id) + "/accept", json={"milestone_id": ms.id}, headers=auth_header(mgr), ) @@ -196,14 +204,14 @@ class TestAccept: ms = make_milestone(project.id, mgr.id, status=MilestoneStatus.FREEZE) create_resp = client.post( - _propose_url(project.id), + _proposal_url(project.id), json={"title": "Feature X"}, headers=auth_header(mgr), ) - propose_id = create_resp.json()["id"] + proposal_id = create_resp.json()["id"] resp = client.post( - _propose_url(project.id, propose_id) + "/accept", + _proposal_url(project.id, proposal_id) + "/accept", json={"milestone_id": ms.id}, headers=auth_header(mgr), ) @@ -221,20 +229,20 @@ class TestAccept: 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), + _proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr), ) - propose_id = create_resp.json()["id"] + proposal_id = create_resp.json()["id"] # First accept client.post( - _propose_url(project.id, propose_id) + "/accept", + _proposal_url(project.id, proposal_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", + _proposal_url(project.id, proposal_id) + "/accept", json={"milestone_id": ms.id}, headers=auth_header(mgr), ) @@ -251,12 +259,12 @@ class TestAccept: 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), + _proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr), ) - propose_id = create_resp.json()["id"] + proposal_id = create_resp.json()["id"] resp = client.post( - _propose_url(project.id, propose_id) + "/accept", + _proposal_url(project.id, proposal_id) + "/accept", json={"milestone_id": ms.id}, headers=auth_header(mgr), ) @@ -264,7 +272,7 @@ class TestAccept: 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)) + get_resp = client.get(_proposal_url(project.id, proposal_id), headers=auth_header(mgr)) assert get_resp.json()["feat_task_id"] == data["feat_task_id"] def test_accept_no_permission_fails( @@ -280,15 +288,15 @@ class TestAccept: ms = make_milestone(project.id, owner.id, status=MilestoneStatus.OPEN) - # Dev creates the propose + # Dev creates the proposal create_resp = client.post( - _propose_url(project.id), json={"title": "F"}, headers=auth_header(dev_user), + _proposal_url(project.id), json={"title": "F"}, headers=auth_header(dev_user), ) - propose_id = create_resp.json()["id"] + proposal_id = create_resp.json()["id"] # Dev tries to accept — should fail resp = client.post( - _propose_url(project.id, propose_id) + "/accept", + _proposal_url(project.id, proposal_id) + "/accept", json={"milestone_id": ms.id}, headers=auth_header(dev_user), ) @@ -300,7 +308,7 @@ class TestAccept: # =========================================================================== class TestReject: - """P6.3 — reject propose.""" + """reject proposal.""" def test_reject_success( self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header, @@ -311,12 +319,12 @@ class TestReject: make_member(project.id, mgr.id, mgr_role.id) create_resp = client.post( - _propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr), + _proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr), ) - propose_id = create_resp.json()["id"] + proposal_id = create_resp.json()["id"] resp = client.post( - _propose_url(project.id, propose_id) + "/reject", + _proposal_url(project.id, proposal_id) + "/reject", json={"reason": "Not needed"}, headers=auth_header(mgr), ) @@ -334,20 +342,20 @@ class TestReject: 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), + _proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr), ) - propose_id = create_resp.json()["id"] + proposal_id = create_resp.json()["id"] # Accept first client.post( - _propose_url(project.id, propose_id) + "/accept", + _proposal_url(project.id, proposal_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", + _proposal_url(project.id, proposal_id) + "/reject", json={"reason": "Changed mind"}, headers=auth_header(mgr), ) @@ -364,12 +372,12 @@ class TestReject: 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), + _proposal_url(project.id), json={"title": "F"}, headers=auth_header(dev_user), ) - propose_id = create_resp.json()["id"] + proposal_id = create_resp.json()["id"] resp = client.post( - _propose_url(project.id, propose_id) + "/reject", + _proposal_url(project.id, proposal_id) + "/reject", json={"reason": "nah"}, headers=auth_header(dev_user), ) @@ -381,7 +389,7 @@ class TestReject: # =========================================================================== class TestReopen: - """P6.4 — reopen rejected propose.""" + """reopen rejected proposal.""" def test_reopen_success( self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header, @@ -392,20 +400,20 @@ class TestReopen: make_member(project.id, mgr.id, mgr_role.id) create_resp = client.post( - _propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr), + _proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr), ) - propose_id = create_resp.json()["id"] + proposal_id = create_resp.json()["id"] # Reject first client.post( - _propose_url(project.id, propose_id) + "/reject", + _proposal_url(project.id, proposal_id) + "/reject", json={"reason": "wait"}, headers=auth_header(mgr), ) # Reopen resp = client.post( - _propose_url(project.id, propose_id) + "/reopen", + _proposal_url(project.id, proposal_id) + "/reopen", headers=auth_header(mgr), ) assert resp.status_code == 200 @@ -420,13 +428,13 @@ class TestReopen: make_member(project.id, mgr.id, mgr_role.id) create_resp = client.post( - _propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr), + _proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr), ) - propose_id = create_resp.json()["id"] + proposal_id = create_resp.json()["id"] - # Try reopen on open propose — should fail + # Try reopen on open proposal — should fail resp = client.post( - _propose_url(project.id, propose_id) + "/reopen", + _proposal_url(project.id, proposal_id) + "/reopen", headers=auth_header(mgr), ) assert resp.status_code == 400 @@ -442,20 +450,20 @@ class TestReopen: 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), + _proposal_url(project.id), json={"title": "F"}, headers=auth_header(dev_user), ) - propose_id = create_resp.json()["id"] + proposal_id = create_resp.json()["id"] # Owner rejects client.post( - _propose_url(project.id, propose_id) + "/reject", + _proposal_url(project.id, proposal_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", + _proposal_url(project.id, proposal_id) + "/reopen", headers=auth_header(dev_user), ) assert resp.status_code == 403 @@ -466,7 +474,7 @@ class TestReopen: # =========================================================================== class TestFeatTaskIdProtection: - """P6.5 — feat_task_id is server-side only, cannot be set by client.""" + """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, @@ -477,13 +485,13 @@ class TestFeatTaskIdProtection: make_member(project.id, user.id, dev_role.id) create_resp = client.post( - _propose_url(project.id), json={"title": "F"}, headers=auth_header(user), + _proposal_url(project.id), json={"title": "F"}, headers=auth_header(user), ) - propose_id = create_resp.json()["id"] + proposal_id = create_resp.json()["id"] # Try to set feat_task_id via PATCH resp = client.patch( - _propose_url(project.id, propose_id), + _proposal_url(project.id, proposal_id), json={"feat_task_id": "999"}, headers=auth_header(user), ) @@ -497,9 +505,9 @@ class TestFeatTaskIdProtection: # =========================================================================== class TestEditRestrictions: - """Propose editing is only allowed in open status.""" + """Proposal editing is only allowed in open status.""" - def test_edit_accepted_propose_fails( + 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 @@ -510,27 +518,27 @@ class TestEditRestrictions: 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), + _proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr), ) - propose_id = create_resp.json()["id"] + proposal_id = create_resp.json()["id"] # Accept client.post( - _propose_url(project.id, propose_id) + "/accept", + _proposal_url(project.id, proposal_id) + "/accept", json={"milestone_id": ms.id}, headers=auth_header(mgr), ) # Try to edit resp = client.patch( - _propose_url(project.id, propose_id), + _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_propose_fails( + 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 @@ -539,21 +547,79 @@ class TestEditRestrictions: make_member(project.id, mgr.id, mgr_role.id) create_resp = client.post( - _propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr), + _proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr), ) - propose_id = create_resp.json()["id"] + proposal_id = create_resp.json()["id"] # Reject client.post( - _propose_url(project.id, propose_id) + "/reject", + _proposal_url(project.id, proposal_id) + "/reject", json={"reason": "no"}, headers=auth_header(mgr), ) # Try to edit resp = client.patch( - _propose_url(project.id, propose_id), + _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"