Compare commits
7 Commits
5f6a3dffe4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b925b5c07e | |||
| b505fa7b35 | |||
| 9cc561e5d5 | |||
| 9e8dda3f16 | |||
| 7b49b17edd | |||
| 63e3352854 | |||
| ed21b73a43 |
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pytest_cache/
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -24,6 +24,8 @@ import app.models.task # noqa: F401
|
||||
import app.models.role_permission # noqa: F401
|
||||
import app.models.activity # noqa: F401
|
||||
import app.models.propose # noqa: F401
|
||||
import app.models.essential # noqa: F401
|
||||
import app.models.proposal # noqa: F401
|
||||
try:
|
||||
import app.models.apikey # noqa: F401
|
||||
except ImportError:
|
||||
@@ -32,6 +34,10 @@ try:
|
||||
import app.models.webhook # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
import app.models.monitor # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
TEST_DATABASE_URL = "sqlite://" # in-memory
|
||||
|
||||
|
||||
322
tests/test_monitor.py
Normal file
322
tests/test_monitor.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""Tests for monitor API — API Key and heartbeat-v2 endpoints."""
|
||||
import json
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture()
|
||||
def make_server(db):
|
||||
"""Factory to create a MonitoredServer row."""
|
||||
from app.models.monitor import MonitoredServer
|
||||
|
||||
_counter = [0]
|
||||
|
||||
def _make(identifier=None, display_name=None, is_enabled=True, api_key=None):
|
||||
_counter[0] += 1
|
||||
n = _counter[0]
|
||||
s = MonitoredServer(
|
||||
identifier=identifier or f"test-server-{n}",
|
||||
display_name=display_name or f"Test Server {n}",
|
||||
is_enabled=is_enabled,
|
||||
api_key=api_key,
|
||||
)
|
||||
db.add(s)
|
||||
db.commit()
|
||||
db.refresh(s)
|
||||
return s
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin_auth(make_user, auth_header):
|
||||
"""Create admin user and return auth header."""
|
||||
user = make_user(is_admin=True)
|
||||
return auth_header(user), user
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API Key Generation Tests (POST /admin/servers/{id}/api-key)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_generate_api_key_success(client, db, make_server, admin_auth):
|
||||
"""B8-1: api-key 生成成功返回 200 + api_key"""
|
||||
headers, _ = admin_auth
|
||||
server = make_server()
|
||||
|
||||
response = client.post(f"/monitor/admin/servers/{server.id}/api-key", headers=headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "api_key" in data
|
||||
assert data["server_id"] == server.id
|
||||
assert len(data["api_key"]) > 20 # token_urlsafe(32) produces ~43 chars
|
||||
|
||||
# Verify key is stored in DB
|
||||
db.refresh(server)
|
||||
assert server.api_key == data["api_key"]
|
||||
|
||||
|
||||
def test_generate_api_key_not_found(client, db, admin_auth):
|
||||
"""B8-2: api-key 生成对不存在 server 返回 404"""
|
||||
headers, _ = admin_auth
|
||||
|
||||
response = client.post("/monitor/admin/servers/99999/api-key", headers=headers)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Heartbeat v2 Tests (POST /server/heartbeat-v2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_heartbeat_v2_valid_key(client, db, make_server):
|
||||
"""B8-3: heartbeat-v2 带有效 key 返回 200"""
|
||||
api_key = "test-api-key-valid-123"
|
||||
server = make_server(api_key=api_key)
|
||||
|
||||
payload = {
|
||||
"identifier": server.identifier,
|
||||
"openclaw_version": "1.0.0",
|
||||
"agents": [{"id": "agent1", "status": "idle"}],
|
||||
"cpu_pct": 45.5,
|
||||
"mem_pct": 60.0,
|
||||
"disk_pct": 30.0,
|
||||
"swap_pct": 10.0,
|
||||
"load_avg": [0.5, 0.6, 0.7],
|
||||
"uptime_seconds": 3600,
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/monitor/server/heartbeat-v2",
|
||||
json=payload,
|
||||
headers={"X-API-Key": api_key},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
assert data["server_id"] == server.id
|
||||
assert data["identifier"] == server.identifier
|
||||
|
||||
|
||||
def test_heartbeat_v2_invalid_key(client, db, make_server):
|
||||
"""B8-4: heartbeat-v2 带无效 key 返回 401"""
|
||||
server = make_server(api_key="valid-key")
|
||||
|
||||
payload = {
|
||||
"identifier": server.identifier,
|
||||
"cpu_pct": 45.5,
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/monitor/server/heartbeat-v2",
|
||||
json=payload,
|
||||
headers={"X-API-Key": "invalid-key"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "invalid" in response.json()["detail"].lower() or "api key" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_heartbeat_v2_missing_key(client, db, make_server):
|
||||
"""B8-5: heartbeat-v2 不带 key 返回 401"""
|
||||
server = make_server(api_key="valid-key")
|
||||
|
||||
payload = {"identifier": server.identifier}
|
||||
|
||||
response = client.post("/monitor/server/heartbeat-v2", json=payload)
|
||||
|
||||
# FastAPI will reject missing required header with 422 (validation error)
|
||||
# or we may expect 401 depending on implementation
|
||||
assert response.status_code in [401, 422]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Revoke API Key Tests (DELETE /admin/servers/{id}/api-key)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_revoke_api_key_makes_key_invalid(client, db, make_server, admin_auth):
|
||||
"""B8-6: 测试 revoke 后 key 失效"""
|
||||
headers, _ = admin_auth
|
||||
api_key = "test-key-to-revoke"
|
||||
server = make_server(api_key=api_key)
|
||||
|
||||
# First, verify key works
|
||||
payload = {"identifier": server.identifier, "cpu_pct": 50.0}
|
||||
response1 = client.post(
|
||||
"/monitor/server/heartbeat-v2",
|
||||
json=payload,
|
||||
headers={"X-API-Key": api_key},
|
||||
)
|
||||
assert response1.status_code == 200
|
||||
|
||||
# Revoke the key
|
||||
response2 = client.delete(f"/monitor/admin/servers/{server.id}/api-key", headers=headers)
|
||||
assert response2.status_code == 204
|
||||
|
||||
# Verify key no longer works
|
||||
response3 = client.post(
|
||||
"/monitor/server/heartbeat-v2",
|
||||
json=payload,
|
||||
headers={"X-API-Key": api_key},
|
||||
)
|
||||
assert response3.status_code == 401
|
||||
|
||||
# Verify DB
|
||||
db.refresh(server)
|
||||
assert server.api_key is None
|
||||
|
||||
|
||||
def test_revoke_api_key_not_found(client, db, admin_auth):
|
||||
"""Revoke API key for non-existent server returns 404"""
|
||||
headers, _ = admin_auth
|
||||
|
||||
response = client.delete("/monitor/admin/servers/99999/api-key", headers=headers)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data Storage Tests (ServerState)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_heartbeat_v2_stores_data_correctly(client, db, make_server):
|
||||
"""B8-7: 测试数据是否正确写入 ServerState(agents_json, cpu_pct, etc.)"""
|
||||
from app.models.monitor import ServerState
|
||||
|
||||
api_key = "test-api-key-storage"
|
||||
server = make_server(api_key=api_key)
|
||||
|
||||
agents = [{"id": "agent1", "name": "TestAgent", "status": "busy"}]
|
||||
payload = {
|
||||
"identifier": server.identifier,
|
||||
"openclaw_version": "2.5.1",
|
||||
"agents": agents,
|
||||
"cpu_pct": 75.5,
|
||||
"mem_pct": 82.3,
|
||||
"disk_pct": 45.0,
|
||||
"swap_pct": 5.5,
|
||||
"load_avg": [1.2, 1.5, 1.8],
|
||||
"uptime_seconds": 7200,
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/monitor/server/heartbeat-v2",
|
||||
json=payload,
|
||||
headers={"X-API-Key": api_key},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify ServerState in DB
|
||||
state = db.query(ServerState).filter(ServerState.server_id == server.id).first()
|
||||
assert state is not None
|
||||
assert state.openclaw_version == "2.5.1"
|
||||
assert state.cpu_pct == 75.5
|
||||
assert state.mem_pct == 82.3
|
||||
assert state.disk_pct == 45.0
|
||||
assert state.swap_pct == 5.5
|
||||
assert state.last_seen_at is not None
|
||||
|
||||
# Verify agents_json
|
||||
stored_agents = json.loads(state.agents_json)
|
||||
assert stored_agents == agents
|
||||
assert stored_agents[0]["id"] == "agent1"
|
||||
assert stored_agents[0]["status"] == "busy"
|
||||
|
||||
|
||||
def test_heartbeat_v2_updates_existing_state(client, db, make_server):
|
||||
"""Test that subsequent heartbeats update the existing ServerState"""
|
||||
from app.models.monitor import ServerState
|
||||
|
||||
api_key = "test-api-key-update"
|
||||
server = make_server(api_key=api_key)
|
||||
|
||||
# First heartbeat
|
||||
response1 = client.post(
|
||||
"/monitor/server/heartbeat-v2",
|
||||
json={
|
||||
"identifier": server.identifier,
|
||||
"cpu_pct": 30.0,
|
||||
"mem_pct": 40.0,
|
||||
"agents": [{"id": "a1"}],
|
||||
},
|
||||
headers={"X-API-Key": api_key},
|
||||
)
|
||||
assert response1.status_code == 200
|
||||
|
||||
state1 = db.query(ServerState).filter(ServerState.server_id == server.id).first()
|
||||
first_seen = state1.last_seen_at
|
||||
|
||||
# Second heartbeat with different data
|
||||
response2 = client.post(
|
||||
"/monitor/server/heartbeat-v2",
|
||||
json={
|
||||
"identifier": server.identifier,
|
||||
"cpu_pct": 60.0,
|
||||
"mem_pct": 70.0,
|
||||
"agents": [{"id": "a1"}, {"id": "a2"}],
|
||||
},
|
||||
headers={"X-API-Key": api_key},
|
||||
)
|
||||
assert response2.status_code == 200
|
||||
|
||||
# Verify update
|
||||
db.refresh(state1)
|
||||
assert state1.cpu_pct == 60.0
|
||||
assert state1.mem_pct == 70.0
|
||||
assert len(json.loads(state1.agents_json)) == 2
|
||||
# last_seen_at should be updated (newer timestamp)
|
||||
assert state1.last_seen_at >= first_seen
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authorization Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_generate_api_key_requires_admin(client, make_user, auth_header, make_server):
|
||||
"""Only admin can generate API keys"""
|
||||
non_admin = make_user(is_admin=False)
|
||||
server = make_server()
|
||||
|
||||
response = client.post(
|
||||
f"/monitor/admin/servers/{server.id}/api-key",
|
||||
headers=auth_header(non_admin),
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_revoke_api_key_requires_admin(client, make_user, auth_header, make_server):
|
||||
"""Only admin can revoke API keys"""
|
||||
non_admin = make_user(is_admin=False)
|
||||
server = make_server(api_key="some-key")
|
||||
|
||||
response = client.delete(
|
||||
f"/monitor/admin/servers/{server.id}/api-key",
|
||||
headers=auth_header(non_admin),
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Disabled Server Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_heartbeat_v2_disabled_server(client, db, make_server):
|
||||
"""Heartbeat-v2 should reject disabled servers even with valid key"""
|
||||
api_key = "test-key-disabled"
|
||||
server = make_server(api_key=api_key, is_enabled=False)
|
||||
|
||||
response = client.post(
|
||||
"/monitor/server/heartbeat-v2",
|
||||
json={"identifier": server.identifier, "cpu_pct": 50.0},
|
||||
headers={"X-API-Key": api_key},
|
||||
)
|
||||
|
||||
assert response.status_code == 401 # Should be treated as invalid key
|
||||
@@ -1,15 +1,16 @@
|
||||
"""P13.3 — Propose backend tests.
|
||||
"""Proposal backend tests (renamed from Propose).
|
||||
|
||||
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
|
||||
- edit restrictions (only open proposes editable)
|
||||
- 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
|
||||
"""
|
||||
import pytest
|
||||
from app.models.milestone import MilestoneStatus
|
||||
@@ -20,19 +21,47 @@ from app.models.task import TaskStatus
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _propose_url(project_id: int, propose_id: int | None = None) -> str:
|
||||
def _proposal_url(project_id: int, proposal_id: int | None = None) -> str:
|
||||
"""Canonical /proposals URL."""
|
||||
base = f"/projects/{project_id}/proposals"
|
||||
return f"{base}/{proposal_id}" if proposal_id else base
|
||||
|
||||
|
||||
def _legacy_propose_url(project_id: int, propose_id: int | None = None) -> str:
|
||||
"""Legacy /proposes URL for backward-compat tests."""
|
||||
base = f"/projects/{project_id}/proposes"
|
||||
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
|
||||
# ===========================================================================
|
||||
|
||||
class TestProposeCRUD:
|
||||
class TestProposalCRUD:
|
||||
"""Basic create / list / get / update."""
|
||||
|
||||
def test_create_propose(
|
||||
def test_create_proposal(
|
||||
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
|
||||
@@ -41,7 +70,7 @@ class TestProposeCRUD:
|
||||
make_member(project.id, user.id, dev_role.id)
|
||||
|
||||
resp = client.post(
|
||||
_propose_url(project.id),
|
||||
_proposal_url(project.id),
|
||||
json={"title": "New Feature Idea", "description": "Some details"},
|
||||
headers=auth_header(user),
|
||||
)
|
||||
@@ -50,9 +79,9 @@ class TestProposeCRUD:
|
||||
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_proposes(
|
||||
def test_list_proposals(
|
||||
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
|
||||
@@ -60,15 +89,15 @@ class TestProposeCRUD:
|
||||
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))
|
||||
# Create two proposals
|
||||
client.post(_proposal_url(project.id), json={"title": "P1"}, headers=auth_header(user))
|
||||
client.post(_proposal_url(project.id), json={"title": "P2"}, headers=auth_header(user))
|
||||
|
||||
resp = client.get(_propose_url(project.id), headers=auth_header(user))
|
||||
resp = client.get(_proposal_url(project.id), headers=auth_header(user))
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 2
|
||||
|
||||
def test_get_propose(
|
||||
def test_get_proposal(
|
||||
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
|
||||
@@ -76,14 +105,14 @@ class TestProposeCRUD:
|
||||
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"]
|
||||
create_resp = client.post(_proposal_url(project.id), json={"title": "P1"}, headers=auth_header(user))
|
||||
proposal_id = create_resp.json()["id"]
|
||||
|
||||
resp = client.get(_propose_url(project.id, propose_id), headers=auth_header(user))
|
||||
resp = client.get(_proposal_url(project.id, proposal_id), headers=auth_header(user))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["title"] == "P1"
|
||||
|
||||
def test_update_propose_open(
|
||||
def test_update_proposal_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
|
||||
@@ -91,11 +120,11 @@ class TestProposeCRUD:
|
||||
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"]
|
||||
create_resp = client.post(_proposal_url(project.id), json={"title": "Old"}, headers=auth_header(user))
|
||||
proposal_id = create_resp.json()["id"]
|
||||
|
||||
resp = client.patch(
|
||||
_propose_url(project.id, propose_id),
|
||||
_proposal_url(project.id, proposal_id),
|
||||
json={"title": "New Title", "description": "Updated"},
|
||||
headers=auth_header(user),
|
||||
)
|
||||
@@ -105,11 +134,11 @@ class TestProposeCRUD:
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Propose Code
|
||||
# Proposal Code
|
||||
# ===========================================================================
|
||||
|
||||
class TestProposeCode:
|
||||
"""P1.4 — propose_code increments per project independently."""
|
||||
class TestProposalCode:
|
||||
"""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,
|
||||
@@ -122,11 +151,11 @@ class TestProposeCode:
|
||||
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))
|
||||
r1 = client.post(_proposal_url(proj_a.id), json={"title": "A1"}, headers=auth_header(user))
|
||||
r2 = client.post(_proposal_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))
|
||||
r3 = client.post(_proposal_url(proj_b.id), json={"title": "B1"}, headers=auth_header(user))
|
||||
|
||||
code1 = r1.json()["propose_code"]
|
||||
code2 = r2.json()["propose_code"]
|
||||
@@ -144,7 +173,7 @@ class TestProposeCode:
|
||||
# ===========================================================================
|
||||
|
||||
class TestAccept:
|
||||
"""P6.2 — accept propose → create feature story task."""
|
||||
"""accept proposal → 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,
|
||||
@@ -157,33 +186,30 @@ class TestAccept:
|
||||
ms = make_milestone(project.id, mgr.id, status=MilestoneStatus.OPEN)
|
||||
|
||||
create_resp = client.post(
|
||||
_propose_url(project.id),
|
||||
_proposal_url(project.id),
|
||||
json={"title": "Cool Feature", "description": "Do something cool"},
|
||||
headers=auth_header(mgr),
|
||||
)
|
||||
propose_id = create_resp.json()["id"]
|
||||
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(
|
||||
_propose_url(project.id, propose_id) + "/accept",
|
||||
_proposal_url(project.id, proposal_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
|
||||
# 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,
|
||||
@@ -196,14 +222,17 @@ class TestAccept:
|
||||
ms = make_milestone(project.id, mgr.id, status=MilestoneStatus.FREEZE)
|
||||
|
||||
create_resp = client.post(
|
||||
_propose_url(project.id),
|
||||
_proposal_url(project.id),
|
||||
json={"title": "Feature X"},
|
||||
headers=auth_header(mgr),
|
||||
)
|
||||
propose_id = create_resp.json()["id"]
|
||||
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(
|
||||
_propose_url(project.id, propose_id) + "/accept",
|
||||
_proposal_url(project.id, proposal_id) + "/accept",
|
||||
json={"milestone_id": ms.id},
|
||||
headers=auth_header(mgr),
|
||||
)
|
||||
@@ -221,28 +250,36 @@ class TestAccept:
|
||||
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),
|
||||
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
|
||||
)
|
||||
propose_id = create_resp.json()["id"]
|
||||
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(
|
||||
_propose_url(project.id, propose_id) + "/accept",
|
||||
_proposal_url(project.id, proposal_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",
|
||||
_proposal_url(project.id, proposal_id) + "/accept",
|
||||
json={"milestone_id": ms.id},
|
||||
headers=auth_header(mgr),
|
||||
)
|
||||
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)
|
||||
@@ -251,21 +288,25 @@ class TestAccept:
|
||||
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),
|
||||
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
|
||||
)
|
||||
propose_id = create_resp.json()["id"]
|
||||
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(
|
||||
_propose_url(project.id, propose_id) + "/accept",
|
||||
_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(_propose_url(project.id, propose_id), headers=auth_header(mgr))
|
||||
assert get_resp.json()["feat_task_id"] == data["feat_task_id"]
|
||||
get_resp = client.get(_proposal_url(project.id, proposal_id), headers=auth_header(mgr))
|
||||
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,
|
||||
@@ -280,15 +321,18 @@ class TestAccept:
|
||||
|
||||
ms = make_milestone(project.id, owner.id, status=MilestoneStatus.OPEN)
|
||||
|
||||
# Dev creates the propose
|
||||
# Dev creates the proposal
|
||||
create_resp = client.post(
|
||||
_propose_url(project.id), json={"title": "F"}, headers=auth_header(dev_user),
|
||||
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(dev_user),
|
||||
)
|
||||
propose_id = create_resp.json()["id"]
|
||||
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(
|
||||
_propose_url(project.id, propose_id) + "/accept",
|
||||
_proposal_url(project.id, proposal_id) + "/accept",
|
||||
json={"milestone_id": ms.id},
|
||||
headers=auth_header(dev_user),
|
||||
)
|
||||
@@ -300,7 +344,7 @@ class TestAccept:
|
||||
# ===========================================================================
|
||||
|
||||
class TestReject:
|
||||
"""P6.3 — reject propose."""
|
||||
"""reject proposal."""
|
||||
|
||||
def test_reject_success(
|
||||
self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header,
|
||||
@@ -311,12 +355,12 @@ class TestReject:
|
||||
make_member(project.id, mgr.id, mgr_role.id)
|
||||
|
||||
create_resp = client.post(
|
||||
_propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
|
||||
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
|
||||
)
|
||||
propose_id = create_resp.json()["id"]
|
||||
proposal_id = create_resp.json()["id"]
|
||||
|
||||
resp = client.post(
|
||||
_propose_url(project.id, propose_id) + "/reject",
|
||||
_proposal_url(project.id, proposal_id) + "/reject",
|
||||
json={"reason": "Not needed"},
|
||||
headers=auth_header(mgr),
|
||||
)
|
||||
@@ -334,20 +378,23 @@ class TestReject:
|
||||
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),
|
||||
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
|
||||
)
|
||||
propose_id = create_resp.json()["id"]
|
||||
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(
|
||||
_propose_url(project.id, propose_id) + "/accept",
|
||||
_proposal_url(project.id, proposal_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",
|
||||
_proposal_url(project.id, proposal_id) + "/reject",
|
||||
json={"reason": "Changed mind"},
|
||||
headers=auth_header(mgr),
|
||||
)
|
||||
@@ -364,12 +411,12 @@ class TestReject:
|
||||
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),
|
||||
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(dev_user),
|
||||
)
|
||||
propose_id = create_resp.json()["id"]
|
||||
proposal_id = create_resp.json()["id"]
|
||||
|
||||
resp = client.post(
|
||||
_propose_url(project.id, propose_id) + "/reject",
|
||||
_proposal_url(project.id, proposal_id) + "/reject",
|
||||
json={"reason": "nah"},
|
||||
headers=auth_header(dev_user),
|
||||
)
|
||||
@@ -381,7 +428,7 @@ class TestReject:
|
||||
# ===========================================================================
|
||||
|
||||
class TestReopen:
|
||||
"""P6.4 — reopen rejected propose."""
|
||||
"""reopen rejected proposal."""
|
||||
|
||||
def test_reopen_success(
|
||||
self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header,
|
||||
@@ -392,20 +439,20 @@ class TestReopen:
|
||||
make_member(project.id, mgr.id, mgr_role.id)
|
||||
|
||||
create_resp = client.post(
|
||||
_propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
|
||||
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
|
||||
)
|
||||
propose_id = create_resp.json()["id"]
|
||||
proposal_id = create_resp.json()["id"]
|
||||
|
||||
# Reject first
|
||||
client.post(
|
||||
_propose_url(project.id, propose_id) + "/reject",
|
||||
_proposal_url(project.id, proposal_id) + "/reject",
|
||||
json={"reason": "wait"},
|
||||
headers=auth_header(mgr),
|
||||
)
|
||||
|
||||
# Reopen
|
||||
resp = client.post(
|
||||
_propose_url(project.id, propose_id) + "/reopen",
|
||||
_proposal_url(project.id, proposal_id) + "/reopen",
|
||||
headers=auth_header(mgr),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
@@ -420,13 +467,13 @@ class TestReopen:
|
||||
make_member(project.id, mgr.id, mgr_role.id)
|
||||
|
||||
create_resp = client.post(
|
||||
_propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
|
||||
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
|
||||
)
|
||||
propose_id = create_resp.json()["id"]
|
||||
proposal_id = create_resp.json()["id"]
|
||||
|
||||
# Try reopen on open propose — should fail
|
||||
# Try reopen on open proposal — should fail
|
||||
resp = client.post(
|
||||
_propose_url(project.id, propose_id) + "/reopen",
|
||||
_proposal_url(project.id, proposal_id) + "/reopen",
|
||||
headers=auth_header(mgr),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
@@ -442,31 +489,31 @@ class TestReopen:
|
||||
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),
|
||||
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(dev_user),
|
||||
)
|
||||
propose_id = create_resp.json()["id"]
|
||||
proposal_id = create_resp.json()["id"]
|
||||
|
||||
# Owner rejects
|
||||
client.post(
|
||||
_propose_url(project.id, propose_id) + "/reject",
|
||||
_proposal_url(project.id, proposal_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",
|
||||
_proposal_url(project.id, proposal_id) + "/reopen",
|
||||
headers=auth_header(dev_user),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# feat_task_id protection
|
||||
# feat_task_id protection (DEPRECATED per BE-PR-010)
|
||||
# ===========================================================================
|
||||
|
||||
class TestFeatTaskIdProtection:
|
||||
"""P6.5 — 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,
|
||||
@@ -477,18 +524,18 @@ class TestFeatTaskIdProtection:
|
||||
make_member(project.id, user.id, dev_role.id)
|
||||
|
||||
create_resp = client.post(
|
||||
_propose_url(project.id), json={"title": "F"}, headers=auth_header(user),
|
||||
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(user),
|
||||
)
|
||||
propose_id = create_resp.json()["id"]
|
||||
proposal_id = create_resp.json()["id"]
|
||||
|
||||
# Try to set feat_task_id via PATCH
|
||||
resp = client.patch(
|
||||
_propose_url(project.id, propose_id),
|
||||
_proposal_url(project.id, proposal_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)
|
||||
# feat_task_id should still be None — deprecated, read-only (BE-PR-010)
|
||||
assert resp.json()["feat_task_id"] is None
|
||||
|
||||
|
||||
@@ -497,9 +544,9 @@ class TestFeatTaskIdProtection:
|
||||
# ===========================================================================
|
||||
|
||||
class TestEditRestrictions:
|
||||
"""Propose editing is only allowed in open status."""
|
||||
"""Proposal editing is only allowed in open status."""
|
||||
|
||||
def test_edit_accepted_propose_fails(
|
||||
def test_edit_accepted_proposal_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
|
||||
@@ -510,27 +557,30 @@ class TestEditRestrictions:
|
||||
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),
|
||||
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
|
||||
)
|
||||
propose_id = create_resp.json()["id"]
|
||||
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(
|
||||
_propose_url(project.id, propose_id) + "/accept",
|
||||
_proposal_url(project.id, proposal_id) + "/accept",
|
||||
json={"milestone_id": ms.id},
|
||||
headers=auth_header(mgr),
|
||||
)
|
||||
|
||||
# Try to edit
|
||||
resp = client.patch(
|
||||
_propose_url(project.id, propose_id),
|
||||
_proposal_url(project.id, proposal_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(
|
||||
def test_edit_rejected_proposal_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
|
||||
@@ -539,21 +589,79 @@ class TestEditRestrictions:
|
||||
make_member(project.id, mgr.id, mgr_role.id)
|
||||
|
||||
create_resp = client.post(
|
||||
_propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
|
||||
_proposal_url(project.id), json={"title": "F"}, headers=auth_header(mgr),
|
||||
)
|
||||
propose_id = create_resp.json()["id"]
|
||||
proposal_id = create_resp.json()["id"]
|
||||
|
||||
# Reject
|
||||
client.post(
|
||||
_propose_url(project.id, propose_id) + "/reject",
|
||||
_proposal_url(project.id, proposal_id) + "/reject",
|
||||
json={"reason": "no"},
|
||||
headers=auth_header(mgr),
|
||||
)
|
||||
|
||||
# Try to edit
|
||||
resp = client.patch(
|
||||
_propose_url(project.id, propose_id),
|
||||
_proposal_url(project.id, proposal_id),
|
||||
json={"title": "Changed"},
|
||||
headers=auth_header(mgr),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Legacy /proposes endpoint backward compatibility
|
||||
# ===========================================================================
|
||||
|
||||
class TestLegacyProposeEndpoint:
|
||||
"""Verify the old /proposes URL still works."""
|
||||
|
||||
def test_legacy_create_and_list(
|
||||
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="LEG")
|
||||
make_member(project.id, user.id, dev_role.id)
|
||||
|
||||
# Create via legacy endpoint
|
||||
resp = client.post(
|
||||
_legacy_propose_url(project.id),
|
||||
json={"title": "Legacy Proposal"},
|
||||
headers=auth_header(user),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["title"] == "Legacy Proposal"
|
||||
|
||||
# List via legacy endpoint
|
||||
resp = client.get(_legacy_propose_url(project.id), headers=auth_header(user))
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) >= 1
|
||||
|
||||
def test_legacy_get_and_update(
|
||||
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 via new endpoint
|
||||
create_resp = client.post(
|
||||
_proposal_url(project.id), json={"title": "Cross"}, headers=auth_header(user),
|
||||
)
|
||||
pid = create_resp.json()["id"]
|
||||
|
||||
# Get via legacy
|
||||
resp = client.get(_legacy_propose_url(project.id, pid), headers=auth_header(user))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["title"] == "Cross"
|
||||
|
||||
# Update via legacy
|
||||
resp = client.patch(
|
||||
_legacy_propose_url(project.id, pid),
|
||||
json={"title": "Updated Cross"},
|
||||
headers=auth_header(user),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["title"] == "Updated Cross"
|
||||
|
||||
Reference in New Issue
Block a user