From e938507a2469a1672d5e0ff7e7c62b5bc7c55492 Mon Sep 17 00:00:00 2001 From: zhi Date: Wed, 18 Mar 2026 05:01:56 +0000 Subject: [PATCH] =?UTF-8?q?test(P13.3):=20propose=20backend=20tests=20?= =?UTF-8?q?=E2=80=94=2019=20tests=20covering=20CRUD,=20accept/reject/reope?= =?UTF-8?q?n,=20code=20generation,=20feat=5Ftask=5Fid=20protection,=20edit?= =?UTF-8?q?=20restrictions,=20permissions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_propose.py | 559 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 559 insertions(+) create mode 100644 tests/test_propose.py diff --git a/tests/test_propose.py b/tests/test_propose.py new file mode 100644 index 0000000..97469e2 --- /dev/null +++ b/tests/test_propose.py @@ -0,0 +1,559 @@ +"""P13.3 — Propose backend tests. + +Covers: +- CRUD: create, list, get, update +- propose_code per-project incrementing +- accept → auto-generate feature story task + feat_task_id +- accept with non-open milestone → fail +- reject → status change +- rejected → reopen back to open +- feat_task_id cannot be set manually +- edit restrictions (only open proposes editable) +- permission checks for accept/reject/reopen +""" +import pytest +from app.models.milestone import MilestoneStatus +from app.models.task import TaskStatus + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _propose_url(project_id: int, propose_id: int | None = None) -> str: + base = f"/projects/{project_id}/proposes" + return f"{base}/{propose_id}" if propose_id else base + + +# =========================================================================== +# CRUD +# =========================================================================== + +class TestProposeCRUD: + """Basic create / list / get / update.""" + + def test_create_propose( + 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="PROJ") + make_member(project.id, user.id, dev_role.id) + + resp = client.post( + _propose_url(project.id), + json={"title": "New Feature Idea", "description": "Some details"}, + headers=auth_header(user), + ) + assert resp.status_code == 201 + data = resp.json() + assert data["title"] == "New Feature Idea" + assert data["status"] == "open" + assert data["propose_code"].startswith("PROJ:P") + assert data["feat_task_id"] is None + + def test_list_proposes( + 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 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)) + + resp = client.get(_propose_url(project.id), headers=auth_header(user)) + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + def test_get_propose( + 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_resp = client.post(_propose_url(project.id), json={"title": "P1"}, headers=auth_header(user)) + propose_id = create_resp.json()["id"] + + resp = client.get(_propose_url(project.id, propose_id), headers=auth_header(user)) + assert resp.status_code == 200 + assert resp.json()["title"] == "P1" + + def test_update_propose_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 + user = make_user() + 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"] + + resp = client.patch( + _propose_url(project.id, propose_id), + json={"title": "New Title", "description": "Updated"}, + headers=auth_header(user), + ) + assert resp.status_code == 200 + assert resp.json()["title"] == "New Title" + assert resp.json()["description"] == "Updated" + + +# =========================================================================== +# Propose Code +# =========================================================================== + +class TestProposeCode: + """P1.4 — 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, + ): + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + proj_a = make_project(owner_id=user.id, project_code="ALPHA") + proj_b = make_project(owner_id=user.id, project_code="BETA") + make_member(proj_a.id, user.id, dev_role.id) + 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)) + + # Create 1 in BETA + r3 = client.post(_propose_url(proj_b.id), json={"title": "B1"}, headers=auth_header(user)) + + code1 = r1.json()["propose_code"] + code2 = r2.json()["propose_code"] + code3 = r3.json()["propose_code"] + + assert code1.startswith("ALPHA:P") + assert code2.startswith("ALPHA:P") + assert code3.startswith("BETA:P") + # They should be distinct + assert code1 != code2 + + +# =========================================================================== +# Accept +# =========================================================================== + +class TestAccept: + """P6.2 — accept propose → 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, + ): + admin_role, mgr_role, dev_role = seed_roles_and_permissions + mgr = make_user() + project = make_project(owner_id=mgr.id) + make_member(project.id, mgr.id, mgr_role.id) + + ms = make_milestone(project.id, mgr.id, status=MilestoneStatus.OPEN) + + create_resp = client.post( + _propose_url(project.id), + json={"title": "Cool Feature", "description": "Do something cool"}, + headers=auth_header(mgr), + ) + propose_id = create_resp.json()["id"] + + resp = client.post( + _propose_url(project.id, propose_id) + "/accept", + json={"milestone_id": ms.id}, + headers=auth_header(mgr), + ) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "accepted" + assert data["feat_task_id"] is not 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 + + def test_accept_non_open_milestone_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 + mgr = make_user() + project = make_project(owner_id=mgr.id) + make_member(project.id, mgr.id, mgr_role.id) + + ms = make_milestone(project.id, mgr.id, status=MilestoneStatus.FREEZE) + + create_resp = client.post( + _propose_url(project.id), + json={"title": "Feature X"}, + headers=auth_header(mgr), + ) + propose_id = create_resp.json()["id"] + + resp = client.post( + _propose_url(project.id, propose_id) + "/accept", + json={"milestone_id": ms.id}, + headers=auth_header(mgr), + ) + assert resp.status_code == 400 + assert "open" in resp.json()["detail"].lower() + + def test_accept_already_accepted_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 + mgr = make_user() + project = make_project(owner_id=mgr.id) + make_member(project.id, mgr.id, mgr_role.id) + + 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), + ) + propose_id = create_resp.json()["id"] + + # First accept + client.post( + _propose_url(project.id, propose_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", + json={"milestone_id": ms.id}, + headers=auth_header(mgr), + ) + assert resp.status_code == 400 + + def test_accept_auto_fills_feat_task_id( + 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 + mgr = make_user() + project = make_project(owner_id=mgr.id) + make_member(project.id, mgr.id, mgr_role.id) + + 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), + ) + propose_id = create_resp.json()["id"] + + resp = client.post( + _propose_url(project.id, propose_id) + "/accept", + json={"milestone_id": ms.id}, + headers=auth_header(mgr), + ) + data = resp.json() + 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)) + assert get_resp.json()["feat_task_id"] == data["feat_task_id"] + + def test_accept_no_permission_fails( + self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header, + ): + """dev role should not have propose.accept permission.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + owner = make_user() + dev_user = make_user() + project = make_project(owner_id=owner.id) + make_member(project.id, owner.id, mgr_role.id) + make_member(project.id, dev_user.id, dev_role.id) + + ms = make_milestone(project.id, owner.id, status=MilestoneStatus.OPEN) + + # Dev creates the propose + create_resp = client.post( + _propose_url(project.id), json={"title": "F"}, headers=auth_header(dev_user), + ) + propose_id = create_resp.json()["id"] + + # Dev tries to accept — should fail + resp = client.post( + _propose_url(project.id, propose_id) + "/accept", + json={"milestone_id": ms.id}, + headers=auth_header(dev_user), + ) + assert resp.status_code == 403 + + +# =========================================================================== +# Reject +# =========================================================================== + +class TestReject: + """P6.3 — reject propose.""" + + def test_reject_success( + 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 + mgr = make_user() + project = make_project(owner_id=mgr.id) + make_member(project.id, mgr.id, mgr_role.id) + + create_resp = client.post( + _propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr), + ) + propose_id = create_resp.json()["id"] + + resp = client.post( + _propose_url(project.id, propose_id) + "/reject", + json={"reason": "Not needed"}, + headers=auth_header(mgr), + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "rejected" + + def test_reject_non_open_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 + mgr = make_user() + project = make_project(owner_id=mgr.id) + make_member(project.id, mgr.id, mgr_role.id) + + 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), + ) + propose_id = create_resp.json()["id"] + + # Accept first + client.post( + _propose_url(project.id, propose_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", + json={"reason": "Changed mind"}, + headers=auth_header(mgr), + ) + assert resp.status_code == 400 + + def test_reject_no_permission_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 + owner = make_user() + dev_user = make_user() + project = make_project(owner_id=owner.id) + make_member(project.id, owner.id, mgr_role.id) + 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), + ) + propose_id = create_resp.json()["id"] + + resp = client.post( + _propose_url(project.id, propose_id) + "/reject", + json={"reason": "nah"}, + headers=auth_header(dev_user), + ) + assert resp.status_code == 403 + + +# =========================================================================== +# Reopen +# =========================================================================== + +class TestReopen: + """P6.4 — reopen rejected propose.""" + + def test_reopen_success( + 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 + mgr = make_user() + project = make_project(owner_id=mgr.id) + make_member(project.id, mgr.id, mgr_role.id) + + create_resp = client.post( + _propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr), + ) + propose_id = create_resp.json()["id"] + + # Reject first + client.post( + _propose_url(project.id, propose_id) + "/reject", + json={"reason": "wait"}, + headers=auth_header(mgr), + ) + + # Reopen + resp = client.post( + _propose_url(project.id, propose_id) + "/reopen", + headers=auth_header(mgr), + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "open" + + def test_reopen_non_rejected_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 + mgr = make_user() + project = make_project(owner_id=mgr.id) + make_member(project.id, mgr.id, mgr_role.id) + + create_resp = client.post( + _propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr), + ) + propose_id = create_resp.json()["id"] + + # Try reopen on open propose — should fail + resp = client.post( + _propose_url(project.id, propose_id) + "/reopen", + headers=auth_header(mgr), + ) + assert resp.status_code == 400 + + def test_reopen_no_permission_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 + owner = make_user() + dev_user = make_user() + project = make_project(owner_id=owner.id) + make_member(project.id, owner.id, mgr_role.id) + 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), + ) + propose_id = create_resp.json()["id"] + + # Owner rejects + client.post( + _propose_url(project.id, propose_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", + headers=auth_header(dev_user), + ) + assert resp.status_code == 403 + + +# =========================================================================== +# feat_task_id protection +# =========================================================================== + +class TestFeatTaskIdProtection: + """P6.5 — 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, + ): + 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_resp = client.post( + _propose_url(project.id), json={"title": "F"}, headers=auth_header(user), + ) + propose_id = create_resp.json()["id"] + + # Try to set feat_task_id via PATCH + resp = client.patch( + _propose_url(project.id, propose_id), + json={"feat_task_id": "999"}, + headers=auth_header(user), + ) + assert resp.status_code == 200 + # feat_task_id should still be None (server ignores it) + assert resp.json()["feat_task_id"] is None + + +# =========================================================================== +# Edit restrictions +# =========================================================================== + +class TestEditRestrictions: + """Propose editing is only allowed in open status.""" + + def test_edit_accepted_propose_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 + mgr = make_user() + project = make_project(owner_id=mgr.id) + make_member(project.id, mgr.id, mgr_role.id) + + 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), + ) + propose_id = create_resp.json()["id"] + + # Accept + client.post( + _propose_url(project.id, propose_id) + "/accept", + json={"milestone_id": ms.id}, + headers=auth_header(mgr), + ) + + # Try to edit + resp = client.patch( + _propose_url(project.id, propose_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( + 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 + mgr = make_user() + project = make_project(owner_id=mgr.id) + make_member(project.id, mgr.id, mgr_role.id) + + create_resp = client.post( + _propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr), + ) + propose_id = create_resp.json()["id"] + + # Reject + client.post( + _propose_url(project.id, propose_id) + "/reject", + json={"reason": "no"}, + headers=auth_header(mgr), + ) + + # Try to edit + resp = client.patch( + _propose_url(project.id, propose_id), + json={"title": "Changed"}, + headers=auth_header(mgr), + ) + assert resp.status_code == 400