- 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)
482 lines
18 KiB
Python
482 lines
18 KiB
Python
"""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"]
|