"""BE-PR-011 — Tests for Proposal / Essential / Story restricted. Covers: 1. Essential CRUD (create, read, update, delete) 2. Proposal Accept — batch generation of story tasks 3. Story restricted — general create endpoint blocks story/* tasks 4. Backward compatibility with legacy proposal data (feat_task_id read-only) """ import pytest from tests.conftest import auth_header # =================================================================== # Helper shortcuts # =================================================================== PRJ = "1" # project id def _create_proposal(client, token, title="Test Proposal", description="desc"): """Create an open proposal and return its JSON.""" r = client.post( f"/projects/{PRJ}/proposals", json={"title": title, "description": description}, headers=auth_header(token), ) assert r.status_code == 201, r.text return r.json() def _create_essential(client, token, proposal_id, etype="feature", title="Ess 1"): """Create an Essential under the given proposal and return its JSON.""" r = client.post( f"/projects/{PRJ}/proposals/{proposal_id}/essentials", json={"type": etype, "title": title, "description": f"{etype} essential"}, headers=auth_header(token), ) assert r.status_code == 201, r.text return r.json() # =================================================================== # 1. Essential CRUD # =================================================================== class TestEssentialCRUD: """Test creating, listing, reading, updating, and deleting Essentials.""" def test_create_essential(self, client, seed): proposal = _create_proposal(client, seed["admin_token"]) ess = _create_essential(client, seed["admin_token"], proposal["id"]) assert ess["type"] == "feature" assert ess["title"] == "Ess 1" assert ess["proposal_id"] == proposal["id"] assert ess["essential_code"].endswith(":E00001") def test_create_multiple_essentials_increments_code(self, client, seed): proposal = _create_proposal(client, seed["admin_token"]) e1 = _create_essential(client, seed["admin_token"], proposal["id"], "feature", "E1") e2 = _create_essential(client, seed["admin_token"], proposal["id"], "improvement", "E2") e3 = _create_essential(client, seed["admin_token"], proposal["id"], "refactor", "E3") assert e1["essential_code"].endswith(":E00001") assert e2["essential_code"].endswith(":E00002") assert e3["essential_code"].endswith(":E00003") def test_list_essentials(self, client, seed): proposal = _create_proposal(client, seed["admin_token"]) _create_essential(client, seed["admin_token"], proposal["id"], "feature", "A") _create_essential(client, seed["admin_token"], proposal["id"], "improvement", "B") r = client.get( f"/projects/{PRJ}/proposals/{proposal['id']}/essentials", headers=auth_header(seed["admin_token"]), ) assert r.status_code == 200 items = r.json() assert len(items) == 2 assert items[0]["title"] == "A" assert items[1]["title"] == "B" def test_get_single_essential(self, client, seed): proposal = _create_proposal(client, seed["admin_token"]) ess = _create_essential(client, seed["admin_token"], proposal["id"]) r = client.get( f"/projects/{PRJ}/proposals/{proposal['id']}/essentials/{ess['id']}", headers=auth_header(seed["admin_token"]), ) assert r.status_code == 200 assert r.json()["id"] == ess["id"] def test_get_essential_by_code(self, client, seed): proposal = _create_proposal(client, seed["admin_token"]) ess = _create_essential(client, seed["admin_token"], proposal["id"]) r = client.get( f"/projects/{PRJ}/proposals/{proposal['id']}/essentials/{ess['essential_code']}", headers=auth_header(seed["admin_token"]), ) assert r.status_code == 200 assert r.json()["id"] == ess["id"] def test_update_essential(self, client, seed): proposal = _create_proposal(client, seed["admin_token"]) ess = _create_essential(client, seed["admin_token"], proposal["id"]) r = client.patch( f"/projects/{PRJ}/proposals/{proposal['id']}/essentials/{ess['id']}", json={"title": "Updated Title", "type": "refactor"}, headers=auth_header(seed["admin_token"]), ) assert r.status_code == 200 data = r.json() assert data["title"] == "Updated Title" assert data["type"] == "refactor" def test_delete_essential(self, client, seed): proposal = _create_proposal(client, seed["admin_token"]) ess = _create_essential(client, seed["admin_token"], proposal["id"]) r = client.delete( f"/projects/{PRJ}/proposals/{proposal['id']}/essentials/{ess['id']}", headers=auth_header(seed["admin_token"]), ) assert r.status_code == 204 # Verify it's gone r = client.get( f"/projects/{PRJ}/proposals/{proposal['id']}/essentials/{ess['id']}", headers=auth_header(seed["admin_token"]), ) assert r.status_code == 404 def test_cannot_create_essential_on_accepted_proposal(self, client, seed): """Essentials can only be added to open proposals.""" proposal = _create_proposal(client, seed["admin_token"]) _create_essential(client, seed["admin_token"], proposal["id"]) # Accept the proposal client.post( f"/projects/{PRJ}/proposals/{proposal['id']}/accept", json={"milestone_id": 1}, headers=auth_header(seed["admin_token"]), ) # Try to create another essential → should fail r = client.post( f"/projects/{PRJ}/proposals/{proposal['id']}/essentials", json={"type": "feature", "title": "Late essential"}, headers=auth_header(seed["admin_token"]), ) assert r.status_code == 400 assert "open" in r.json()["detail"].lower() def test_cannot_update_essential_on_rejected_proposal(self, client, seed): proposal = _create_proposal(client, seed["admin_token"]) ess = _create_essential(client, seed["admin_token"], proposal["id"]) # Reject the proposal client.post( f"/projects/{PRJ}/proposals/{proposal['id']}/reject", json={"reason": "not now"}, headers=auth_header(seed["admin_token"]), ) r = client.patch( f"/projects/{PRJ}/proposals/{proposal['id']}/essentials/{ess['id']}", json={"title": "Should fail"}, headers=auth_header(seed["admin_token"]), ) assert r.status_code == 400 def test_essential_not_found(self, client, seed): proposal = _create_proposal(client, seed["admin_token"]) r = client.get( f"/projects/{PRJ}/proposals/{proposal['id']}/essentials/9999", headers=auth_header(seed["admin_token"]), ) assert r.status_code == 404 def test_essential_types(self, client, seed): """All three essential types should be valid.""" proposal = _create_proposal(client, seed["admin_token"]) for etype in ["feature", "improvement", "refactor"]: ess = _create_essential(client, seed["admin_token"], proposal["id"], etype, f"T-{etype}") assert ess["type"] == etype # =================================================================== # 2. Proposal Accept — batch story task generation # =================================================================== class TestProposalAccept: """Test that accepting a Proposal generates story tasks from Essentials.""" def test_accept_generates_story_tasks(self, client, seed): proposal = _create_proposal(client, seed["admin_token"]) _create_essential(client, seed["admin_token"], proposal["id"], "feature", "Feat 1") _create_essential(client, seed["admin_token"], proposal["id"], "improvement", "Improv 1") _create_essential(client, seed["admin_token"], proposal["id"], "refactor", "Refac 1") r = client.post( f"/projects/{PRJ}/proposals/{proposal['id']}/accept", json={"milestone_id": 1}, headers=auth_header(seed["admin_token"]), ) assert r.status_code == 200, r.text data = r.json() assert data["status"] == "accepted" tasks = data["generated_tasks"] assert len(tasks) == 3 subtypes = {t["task_subtype"] for t in tasks} assert subtypes == {"feature", "improvement", "refactor"} for t in tasks: assert t["task_type"] == "story" assert t["essential_id"] is not None def test_accept_requires_milestone(self, client, seed): proposal = _create_proposal(client, seed["admin_token"]) _create_essential(client, seed["admin_token"], proposal["id"]) # Missing milestone_id r = client.post( f"/projects/{PRJ}/proposals/{proposal['id']}/accept", json={}, headers=auth_header(seed["admin_token"]), ) assert r.status_code == 422 # validation error def test_accept_rejects_invalid_milestone(self, client, seed): proposal = _create_proposal(client, seed["admin_token"]) _create_essential(client, seed["admin_token"], proposal["id"]) r = client.post( f"/projects/{PRJ}/proposals/{proposal['id']}/accept", json={"milestone_id": 9999}, headers=auth_header(seed["admin_token"]), ) assert r.status_code == 404 assert "milestone" in r.json()["detail"].lower() def test_accept_requires_at_least_one_essential(self, client, seed): proposal = _create_proposal(client, seed["admin_token"]) r = client.post( f"/projects/{PRJ}/proposals/{proposal['id']}/accept", json={"milestone_id": 1}, headers=auth_header(seed["admin_token"]), ) assert r.status_code == 400 assert "essential" in r.json()["detail"].lower() def test_accept_only_open_proposals(self, client, seed): proposal = _create_proposal(client, seed["admin_token"]) _create_essential(client, seed["admin_token"], proposal["id"]) # Reject first client.post( f"/projects/{PRJ}/proposals/{proposal['id']}/reject", json={"reason": "nope"}, headers=auth_header(seed["admin_token"]), ) r = client.post( f"/projects/{PRJ}/proposals/{proposal['id']}/accept", json={"milestone_id": 1}, headers=auth_header(seed["admin_token"]), ) assert r.status_code == 400 assert "open" in r.json()["detail"].lower() def test_accept_sets_source_proposal_id_on_tasks(self, client, seed): """Generated tasks should have source_proposal_id and source_essential_id set.""" proposal = _create_proposal(client, seed["admin_token"]) ess = _create_essential(client, seed["admin_token"], proposal["id"]) r = client.post( f"/projects/{PRJ}/proposals/{proposal['id']}/accept", json={"milestone_id": 1}, headers=auth_header(seed["admin_token"]), ) assert r.status_code == 200 tasks = r.json()["generated_tasks"] assert len(tasks) == 1 assert tasks[0]["essential_id"] == ess["id"] def test_proposal_detail_includes_generated_tasks(self, client, seed): """After accept, proposal detail should include generated_tasks.""" proposal = _create_proposal(client, seed["admin_token"]) _create_essential(client, seed["admin_token"], proposal["id"], "feature", "F1") client.post( f"/projects/{PRJ}/proposals/{proposal['id']}/accept", json={"milestone_id": 1}, headers=auth_header(seed["admin_token"]), ) r = client.get( f"/projects/{PRJ}/proposals/{proposal['id']}", headers=auth_header(seed["admin_token"]), ) assert r.status_code == 200 data = r.json() assert len(data["essentials"]) == 1 assert len(data["generated_tasks"]) >= 1 assert data["generated_tasks"][0]["task_type"] == "story" def test_double_accept_fails(self, client, seed): """Accepting an already-accepted proposal should fail.""" proposal = _create_proposal(client, seed["admin_token"]) _create_essential(client, seed["admin_token"], proposal["id"]) client.post( f"/projects/{PRJ}/proposals/{proposal['id']}/accept", json={"milestone_id": 1}, headers=auth_header(seed["admin_token"]), ) r = client.post( f"/projects/{PRJ}/proposals/{proposal['id']}/accept", json={"milestone_id": 1}, headers=auth_header(seed["admin_token"]), ) assert r.status_code == 400 # =================================================================== # 3. Story restricted — general create blocks story/* tasks # =================================================================== class TestStoryRestricted: """Test that story/* tasks cannot be created via the general task endpoint.""" def test_create_story_feature_blocked(self, client, seed): r = client.post( "/tasks", json={ "title": "Sneaky story", "task_type": "story", "task_subtype": "feature", "project_id": 1, "milestone_id": 1, }, headers=auth_header(seed["admin_token"]), ) assert r.status_code == 400 assert "story" in r.json()["detail"].lower() def test_create_story_improvement_blocked(self, client, seed): r = client.post( "/tasks", json={ "title": "Sneaky improvement", "task_type": "story", "task_subtype": "improvement", "project_id": 1, "milestone_id": 1, }, headers=auth_header(seed["admin_token"]), ) assert r.status_code == 400 def test_create_story_refactor_blocked(self, client, seed): r = client.post( "/tasks", json={ "title": "Sneaky refactor", "task_type": "story", "task_subtype": "refactor", "project_id": 1, "milestone_id": 1, }, headers=auth_header(seed["admin_token"]), ) assert r.status_code == 400 def test_create_story_no_subtype_blocked(self, client, seed): r = client.post( "/tasks", json={ "title": "Bare story", "task_type": "story", "project_id": 1, "milestone_id": 1, }, headers=auth_header(seed["admin_token"]), ) assert r.status_code == 400 def test_create_issue_still_allowed(self, client, seed): """Non-restricted types should still work normally.""" r = client.post( "/tasks", json={ "title": "Normal issue", "task_type": "issue", "task_subtype": "defect", "project_id": 1, "milestone_id": 1, }, headers=auth_header(seed["admin_token"]), ) # Should succeed (200 or 201) assert r.status_code in (200, 201), r.text def test_story_only_via_proposal_accept(self, client, seed): """Story tasks should exist only when created via Proposal Accept.""" proposal = _create_proposal(client, seed["admin_token"]) _create_essential(client, seed["admin_token"], proposal["id"], "feature", "Via Accept") r = client.post( f"/projects/{PRJ}/proposals/{proposal['id']}/accept", json={"milestone_id": 1}, headers=auth_header(seed["admin_token"]), ) assert r.status_code == 200 tasks = r.json()["generated_tasks"] assert len(tasks) == 1 assert tasks[0]["task_type"] == "story" assert tasks[0]["task_subtype"] == "feature" # =================================================================== # 4. Legacy / backward compatibility # =================================================================== class TestLegacyCompat: """Test backward compat with old proposal data (feat_task_id read-only).""" def test_feat_task_id_in_response(self, client, seed): """Response should include feat_task_id (even if None).""" proposal = _create_proposal(client, seed["admin_token"]) r = client.get( f"/projects/{PRJ}/proposals/{proposal['id']}", headers=auth_header(seed["admin_token"]), ) assert r.status_code == 200 data = r.json() assert "feat_task_id" in data # New proposals should have None assert data["feat_task_id"] is None def test_feat_task_id_not_writable_via_update(self, client, seed): """Clients should not be able to set feat_task_id via PATCH.""" proposal = _create_proposal(client, seed["admin_token"]) r = client.patch( f"/projects/{PRJ}/proposals/{proposal['id']}", json={"feat_task_id": "FAKE-TASK-123"}, headers=auth_header(seed["admin_token"]), ) # Should succeed (ignoring the field) or reject if r.status_code == 200: assert r.json()["feat_task_id"] is None # not written def test_new_accept_does_not_write_feat_task_id(self, client, seed): """After accept, feat_task_id should remain None; use generated_tasks.""" proposal = _create_proposal(client, seed["admin_token"]) _create_essential(client, seed["admin_token"], proposal["id"]) r = client.post( f"/projects/{PRJ}/proposals/{proposal['id']}/accept", json={"milestone_id": 1}, headers=auth_header(seed["admin_token"]), ) assert r.status_code == 200 assert r.json()["feat_task_id"] is None def test_propose_code_alias(self, client, seed): """Response should include both proposal_code and propose_code for compat.""" proposal = _create_proposal(client, seed["admin_token"]) assert "proposal_code" in proposal assert "propose_code" in proposal assert proposal["proposal_code"] == proposal["propose_code"]