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 80a624f..2c985ca 100644 Binary files a/tests/__pycache__/test_propose.cpython-312-pytest-9.0.2.pyc and b/tests/__pycache__/test_propose.cpython-312-pytest-9.0.2.pyc differ 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",