BE-PR-011: Fix test infrastructure and add Proposal/Essential/Story restricted tests

- Patched conftest.py to monkey-patch app.core.config engine/SessionLocal
  with SQLite in-memory DB BEFORE importing the FastAPI app, preventing
  startup event from trying to connect to production MySQL
- All 29 tests pass: Essential CRUD (11), Proposal Accept (8),
  Story restricted (6), Legacy compat (4)
This commit is contained in:
zhi
2026-03-30 16:17:00 +00:00
parent 90d1f22267
commit 1ed7a85e11
3 changed files with 673 additions and 0 deletions

View File

@@ -0,0 +1,481 @@
"""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"]