From 9e8dda3f160d1cbc1830bf5b8e1b20b37bb330c0 Mon Sep 17 00:00:00 2001 From: zhi Date: Sun, 29 Mar 2026 15:35:47 +0000 Subject: [PATCH 1/3] 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" -- 2.49.1 From 9cc561e5d51aef01bf53cde3fa8a3018c612c5c9 Mon Sep 17 00:00:00 2001 From: zhi Date: Mon, 30 Mar 2026 11:46:24 +0000 Subject: [PATCH 2/3] Fix conftest: import Essential and Proposal models for test DB schema --- .../conftest.cpython-312-pytest-9.0.2.pyc | Bin 11699 -> 11764 bytes .../test_tasks.cpython-312-pytest-9.0.2.pyc | Bin 20036 -> 20036 bytes tests/conftest.py | 2 ++ 3 files changed, 2 insertions(+) diff --git a/tests/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc index d8d6309e0eaef9506f3b73ff8d595427e5af3cd7..93fb7d732056037058563ff4ec670e9f548ae95d 100644 GIT binary patch delta 681 zcmZ9HPiPZC7{%wC*>sb&V`H|pX168Uq;XAXw6(QDAW$fD^&t4?U^l1eERvX-kQLhs zUZi^Jp*lCgo8TcqkU5B#g7jce>Om01;?0v5D&oO|2PbW52j}qS{oebSKce`%NZ(Ux zAh~W{dsqJTh{mDt^7tHFVZd-T;l;Q2qh36Mw%xHkm)vtHITiMXG4f;nFpDsw4AQ6B z0oHv3TC|Bf$0Jm^z(2#F?a0ooT-wQ0^&K9?{ev0V_c4PRzSBC?Bs&vcR;JO)RX&Zg z`pB-o*k|z-O!b_?tCCMDyeavV!WN4k))H#&zREkT7PeB1tHpp)Ozanr zH9C1_5hsy9ltK*+&@5Vk<*9kJKxWZA{Fl3GE~Yg?U|hKRbAT!FGdu%uPMkF`h9b{* zuft^#p&u{={Ch0L|Mt9qifBd)7?x%51|Eo(xsKtPxO*4_JQer)&y%iI`KjPF--;!~ zrX}N7V#|K0!zXbkNeQI*QaXTy_>*1)7~>6R0Y>-_X9y<6!4oOS^p@)N++2LE8u+C9)wGvp4CwhdDM0mQzg1poj5 delta 615 zcmZ9Hv1?O70LJgTd+{YNIfzMXNMdYFLYnqzt&P>8Vi1J)s33?LnpYfzduW3lv_spq zN{6DLoSTE<;*cWf;ULyQ9a@lM3-v;SJ-p%L`+eW>arbjdP4RD>J4Byf znNQO%p7Jb)-ww>+N{k2ydbcmgcHPN;t{7xRG$>ILroxhl31=GGmqlD8ZexYFX=jd* z)`a|lLcXHAbnDtdW@=cG{WMdk>b_Sv!U-!YN89Y+Mlh(;WUCa=VQp?9E@LWC4Ot2%X`ctzX>FFZps_(qS|r4W9+=;)yS7| k%d3;G#PZ{0trqbvvbAw^v>x%JY~4mj;}O4yt*4>&FXIM}F#rGn diff --git a/tests/__pycache__/test_tasks.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_tasks.cpython-312-pytest-9.0.2.pyc index 3ecf674431f58f71ebf400ac2d6169a7baaba543..eaf0b25c7fe67df0071df94e02e090929592693d 100644 GIT binary patch delta 22 ccmX>yhw;c9M()$Ryj%=G@Kt>8Ms7<#090ZI$p8QV delta 22 ccmX>yhw;c9M()$Ryj%=GAohOuMs7<#08>u}zyJUM diff --git a/tests/conftest.py b/tests/conftest.py index 4894da3..f404fc9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,8 @@ import app.models.task # noqa: F401 import app.models.role_permission # noqa: F401 import app.models.activity # noqa: F401 import app.models.propose # noqa: F401 +import app.models.essential # noqa: F401 +import app.models.proposal # noqa: F401 try: import app.models.apikey # noqa: F401 except ImportError: -- 2.49.1 From b505fa7b35333afd331f8b790eb42efccecc719f Mon Sep 17 00:00:00 2001 From: zhi Date: Mon, 30 Mar 2026 12:50:00 +0000 Subject: [PATCH 3/3] 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 --- .../test_propose.cpython-312-pytest-9.0.2.pyc | Bin 54004 -> 53241 bytes tests/test_propose.py | 84 +++++++++++++----- 2 files changed, 63 insertions(+), 21 deletions(-) diff --git a/tests/__pycache__/test_propose.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_propose.cpython-312-pytest-9.0.2.pyc index 80a624fa1e2651858f7d7a7af76420d9dadab09d..2c985cac8aa6c8cd4f748ee2fbc30659aa97001a 100644 GIT binary patch delta 10259 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 F{|`P7EMfov diff --git a/tests/test_propose.py b/tests/test_propose.py index d9d88c0..ca7b0c9 100644 --- a/tests/test_propose.py +++ b/tests/test_propose.py @@ -3,11 +3,11 @@ Covers: - CRUD: create, list, get, update - propose_code per-project incrementing -- accept → auto-generate feature story task + feat_task_id +- 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 +- 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 @@ -33,6 +33,27 @@ def _legacy_propose_url(project_id: int, propose_id: int | None = None) -> str: 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 # =========================================================================== @@ -58,7 +79,7 @@ class TestProposalCRUD: assert data["title"] == "New Feature Idea" assert data["status"] == "open" assert data["propose_code"].startswith("PROJ:P") - assert data["feat_task_id"] is None + 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, @@ -171,6 +192,10 @@ class TestAccept: ) 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}, @@ -179,19 +204,12 @@ class TestAccept: assert resp.status_code == 200 data = resp.json() assert data["status"] == "accepted" - assert data["feat_task_id"] is not None + # BE-PR-010: feat_task_id is no longer written by new accept flow + assert data["feat_task_id"] is 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 + # 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, @@ -210,6 +228,9 @@ class TestAccept: ) 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}, @@ -233,6 +254,9 @@ class TestAccept: ) 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", @@ -248,9 +272,14 @@ class TestAccept: ) assert resp.status_code == 400 - def test_accept_auto_fills_feat_task_id( + 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) @@ -263,17 +292,21 @@ class TestAccept: ) 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() - assert data["feat_task_id"] is not None + # 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"] == data["feat_task_id"] + 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, @@ -294,6 +327,9 @@ class TestAccept: ) 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", @@ -346,6 +382,9 @@ class TestReject: ) 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", @@ -470,11 +509,11 @@ class TestReopen: # =========================================================================== -# feat_task_id protection +# feat_task_id protection (DEPRECATED per BE-PR-010) # =========================================================================== class TestFeatTaskIdProtection: - """feat_task_id is server-side only, cannot be set by client.""" + """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, @@ -496,7 +535,7 @@ class TestFeatTaskIdProtection: headers=auth_header(user), ) assert resp.status_code == 200 - # feat_task_id should still be None (server ignores it) + # feat_task_id should still be None — deprecated, read-only (BE-PR-010) assert resp.json()["feat_task_id"] is None @@ -522,6 +561,9 @@ class TestEditRestrictions: ) 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", -- 2.49.1