Merge branch 'master' into perf/activity-events-eventtype-createdat
This commit is contained in:
11
backend/tests/core/test_version.py
Normal file
11
backend/tests/core/test_version.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from app.core.version import APP_NAME, APP_VERSION
|
||||
|
||||
|
||||
def test_app_name_constant() -> None:
|
||||
assert APP_NAME == "mission-control"
|
||||
|
||||
|
||||
def test_app_version_semver_format() -> None:
|
||||
parts = APP_VERSION.split(".")
|
||||
assert len(parts) == 3
|
||||
assert all(part.isdigit() for part in parts)
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
@@ -345,3 +346,92 @@ async def test_control_plane_upsert_agent_handles_already_exists(monkeypatch):
|
||||
|
||||
assert calls[0][0] == "agents.create"
|
||||
assert calls[1][0] == "agents.update"
|
||||
|
||||
|
||||
def test_is_missing_agent_error_matches_gateway_agent_not_found() -> None:
|
||||
assert agent_provisioning._is_missing_agent_error(
|
||||
agent_provisioning.OpenClawGatewayError('agent "mc-abc" not found'),
|
||||
)
|
||||
assert not agent_provisioning._is_missing_agent_error(
|
||||
agent_provisioning.OpenClawGatewayError("dial tcp: connection refused"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_agent_lifecycle_ignores_missing_gateway_agent(monkeypatch) -> None:
|
||||
class _ControlPlaneStub:
|
||||
def __init__(self) -> None:
|
||||
self.deleted_sessions: list[str] = []
|
||||
|
||||
async def delete_agent(self, agent_id: str, *, delete_files: bool = True) -> None:
|
||||
_ = (agent_id, delete_files)
|
||||
raise agent_provisioning.OpenClawGatewayError('agent "mc-abc" not found')
|
||||
|
||||
async def delete_agent_session(self, session_key: str) -> None:
|
||||
self.deleted_sessions.append(session_key)
|
||||
|
||||
gateway = _GatewayStub(
|
||||
id=uuid4(),
|
||||
name="Acme",
|
||||
url="ws://gateway.example/ws",
|
||||
token=None,
|
||||
workspace_root="/tmp/openclaw",
|
||||
)
|
||||
agent = SimpleNamespace(
|
||||
id=uuid4(),
|
||||
name="Worker",
|
||||
board_id=uuid4(),
|
||||
openclaw_session_id=None,
|
||||
is_board_lead=False,
|
||||
)
|
||||
control_plane = _ControlPlaneStub()
|
||||
monkeypatch.setattr(agent_provisioning, "_control_plane_for_gateway", lambda _g: control_plane)
|
||||
|
||||
await agent_provisioning.OpenClawGatewayProvisioner().delete_agent_lifecycle(
|
||||
agent=agent, # type: ignore[arg-type]
|
||||
gateway=gateway, # type: ignore[arg-type]
|
||||
delete_files=True,
|
||||
delete_session=True,
|
||||
)
|
||||
|
||||
assert len(control_plane.deleted_sessions) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_agent_lifecycle_raises_on_non_missing_agent_error(monkeypatch) -> None:
|
||||
class _ControlPlaneStub:
|
||||
async def delete_agent(self, agent_id: str, *, delete_files: bool = True) -> None:
|
||||
_ = (agent_id, delete_files)
|
||||
raise agent_provisioning.OpenClawGatewayError("gateway timeout")
|
||||
|
||||
async def delete_agent_session(self, session_key: str) -> None:
|
||||
_ = session_key
|
||||
raise AssertionError("delete_agent_session should not be called")
|
||||
|
||||
gateway = _GatewayStub(
|
||||
id=uuid4(),
|
||||
name="Acme",
|
||||
url="ws://gateway.example/ws",
|
||||
token=None,
|
||||
workspace_root="/tmp/openclaw",
|
||||
)
|
||||
agent = SimpleNamespace(
|
||||
id=uuid4(),
|
||||
name="Worker",
|
||||
board_id=uuid4(),
|
||||
openclaw_session_id=None,
|
||||
is_board_lead=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
agent_provisioning,
|
||||
"_control_plane_for_gateway",
|
||||
lambda _g: _ControlPlaneStub(),
|
||||
)
|
||||
|
||||
with pytest.raises(agent_provisioning.OpenClawGatewayError):
|
||||
await agent_provisioning.OpenClawGatewayProvisioner().delete_agent_lifecycle(
|
||||
agent=agent, # type: ignore[arg-type]
|
||||
gateway=gateway, # type: ignore[arg-type]
|
||||
delete_files=True,
|
||||
delete_session=True,
|
||||
)
|
||||
|
||||
@@ -51,22 +51,25 @@ async def test_create_approval_rejects_duplicate_pending_for_same_task() -> None
|
||||
async with await _make_session(engine) as session:
|
||||
board, task_ids = await _seed_board_with_tasks(session, task_count=1)
|
||||
task_id = task_ids[0]
|
||||
await approvals_api.create_approval(
|
||||
created = await approvals_api.create_approval(
|
||||
payload=ApprovalCreate(
|
||||
action_type="task.execute",
|
||||
task_id=task_id,
|
||||
payload={"reason": "Initial execution needs confirmation."},
|
||||
confidence=80,
|
||||
status="pending",
|
||||
),
|
||||
board=board,
|
||||
session=session,
|
||||
)
|
||||
assert created.task_titles == [f"task-{task_id}"]
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await approvals_api.create_approval(
|
||||
payload=ApprovalCreate(
|
||||
action_type="task.retry",
|
||||
task_id=task_id,
|
||||
payload={"reason": "Retry should still be gated."},
|
||||
confidence=77,
|
||||
status="pending",
|
||||
),
|
||||
@@ -91,22 +94,25 @@ async def test_create_approval_rejects_pending_conflict_from_linked_task_ids() -
|
||||
async with await _make_session(engine) as session:
|
||||
board, task_ids = await _seed_board_with_tasks(session, task_count=2)
|
||||
task_a, task_b = task_ids
|
||||
await approvals_api.create_approval(
|
||||
created = await approvals_api.create_approval(
|
||||
payload=ApprovalCreate(
|
||||
action_type="task.batch_execute",
|
||||
task_ids=[task_a, task_b],
|
||||
payload={"reason": "Batch operation requires sign-off."},
|
||||
confidence=85,
|
||||
status="pending",
|
||||
),
|
||||
board=board,
|
||||
session=session,
|
||||
)
|
||||
assert created.task_titles == [f"task-{task_a}", f"task-{task_b}"]
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await approvals_api.create_approval(
|
||||
payload=ApprovalCreate(
|
||||
action_type="task.execute",
|
||||
task_id=task_b,
|
||||
payload={"reason": "Single task overlaps with pending batch."},
|
||||
confidence=70,
|
||||
status="pending",
|
||||
),
|
||||
@@ -135,6 +141,7 @@ async def test_update_approval_rejects_reopening_to_pending_with_existing_pendin
|
||||
payload=ApprovalCreate(
|
||||
action_type="task.execute",
|
||||
task_id=task_id,
|
||||
payload={"reason": "Primary pending approval is active."},
|
||||
confidence=83,
|
||||
status="pending",
|
||||
),
|
||||
@@ -145,6 +152,7 @@ async def test_update_approval_rejects_reopening_to_pending_with_existing_pendin
|
||||
payload=ApprovalCreate(
|
||||
action_type="task.review",
|
||||
task_id=task_id,
|
||||
payload={"reason": "Review decision completed earlier."},
|
||||
confidence=90,
|
||||
status="approved",
|
||||
),
|
||||
|
||||
60
backend/tests/test_approvals_schema.py
Normal file
60
backend/tests/test_approvals_schema.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.schemas.approvals import ApprovalCreate
|
||||
|
||||
|
||||
def test_approval_create_requires_confidence_score() -> None:
|
||||
with pytest.raises(ValidationError, match="confidence"):
|
||||
ApprovalCreate.model_validate(
|
||||
{
|
||||
"action_type": "task.update",
|
||||
"payload": {"reason": "Missing confidence should fail."},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("confidence", [-1.0, 101.0])
|
||||
def test_approval_create_rejects_out_of_range_confidence(confidence: float) -> None:
|
||||
with pytest.raises(ValidationError, match="confidence"):
|
||||
ApprovalCreate.model_validate(
|
||||
{
|
||||
"action_type": "task.update",
|
||||
"payload": {"reason": "Confidence must be in range."},
|
||||
"confidence": confidence,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_approval_create_requires_lead_reasoning() -> None:
|
||||
with pytest.raises(ValidationError, match="lead reasoning is required"):
|
||||
ApprovalCreate.model_validate(
|
||||
{
|
||||
"action_type": "task.update",
|
||||
"confidence": 80,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_approval_create_accepts_nested_decision_reason() -> None:
|
||||
model = ApprovalCreate.model_validate(
|
||||
{
|
||||
"action_type": "task.update",
|
||||
"confidence": 80,
|
||||
"payload": {"decision": {"reason": "Needs manual approval."}},
|
||||
},
|
||||
)
|
||||
assert model.payload == {"decision": {"reason": "Needs manual approval."}}
|
||||
|
||||
|
||||
def test_approval_create_accepts_float_confidence() -> None:
|
||||
model = ApprovalCreate.model_validate(
|
||||
{
|
||||
"action_type": "task.update",
|
||||
"confidence": 88.75,
|
||||
"payload": {"reason": "Fractional confidence should be preserved."},
|
||||
},
|
||||
)
|
||||
assert model.confidence == 88.75
|
||||
45
backend/tests/test_board_onboarding_autonomy_toggle.py
Normal file
45
backend/tests/test_board_onboarding_autonomy_toggle.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.api.board_onboarding import _require_approval_for_done_from_draft
|
||||
|
||||
|
||||
def test_require_approval_for_done_defaults_true_without_lead_agent_draft() -> None:
|
||||
assert _require_approval_for_done_from_draft(None) is True
|
||||
assert _require_approval_for_done_from_draft({}) is True
|
||||
assert _require_approval_for_done_from_draft({"lead_agent": "invalid"}) is True
|
||||
|
||||
|
||||
def test_require_approval_for_done_stays_enabled_for_non_fully_autonomous_modes() -> None:
|
||||
assert (
|
||||
_require_approval_for_done_from_draft(
|
||||
{"lead_agent": {"autonomy_level": "ask_first"}},
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
_require_approval_for_done_from_draft(
|
||||
{"lead_agent": {"autonomy_level": "balanced"}},
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_require_approval_for_done_disables_for_fully_autonomous_choices() -> None:
|
||||
assert (
|
||||
_require_approval_for_done_from_draft(
|
||||
{"lead_agent": {"autonomy_level": "autonomous"}},
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_require_approval_for_done_from_draft(
|
||||
{"lead_agent": {"autonomy_level": "fully-autonomous"}},
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_require_approval_for_done_from_draft(
|
||||
{"lead_agent": {"identity_profile": {"autonomy_level": "fully autonomous"}}},
|
||||
)
|
||||
is False
|
||||
)
|
||||
@@ -76,6 +76,28 @@ def test_board_update_rejects_empty_description_patch() -> None:
|
||||
BoardUpdate(description=" ")
|
||||
|
||||
|
||||
def test_board_rule_toggles_have_expected_defaults() -> None:
|
||||
"""Boards should default to approval-gated done and optional review gating."""
|
||||
created = BoardCreate(
|
||||
name="Ops Board",
|
||||
slug="ops-board",
|
||||
description="Operations workflow board.",
|
||||
gateway_id=uuid4(),
|
||||
)
|
||||
assert created.require_approval_for_done is True
|
||||
assert created.require_review_before_done is False
|
||||
assert created.block_status_changes_with_pending_approval is False
|
||||
|
||||
updated = BoardUpdate(
|
||||
require_approval_for_done=False,
|
||||
require_review_before_done=True,
|
||||
block_status_changes_with_pending_approval=True,
|
||||
)
|
||||
assert updated.require_approval_for_done is False
|
||||
assert updated.require_review_before_done is True
|
||||
assert updated.block_status_changes_with_pending_approval is True
|
||||
|
||||
|
||||
def test_onboarding_confirm_requires_goal_fields() -> None:
|
||||
"""Onboarding confirm should enforce goal fields for goal board types."""
|
||||
with pytest.raises(
|
||||
|
||||
282
backend/tests/test_board_webhooks_api.py
Normal file
282
backend/tests/test_board_webhooks_api.py
Normal file
@@ -0,0 +1,282 @@
|
||||
# ruff: noqa: INP001
|
||||
"""Integration tests for board webhook ingestion behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import APIRouter, Depends, FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
||||
from sqlmodel import SQLModel, col, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.api import board_webhooks
|
||||
from app.api.board_webhooks import router as board_webhooks_router
|
||||
from app.api.deps import get_board_or_404
|
||||
from app.db.session import get_session
|
||||
from app.models.agents import Agent
|
||||
from app.models.board_memory import BoardMemory
|
||||
from app.models.board_webhook_payloads import BoardWebhookPayload
|
||||
from app.models.board_webhooks import BoardWebhook
|
||||
from app.models.boards import Board
|
||||
from app.models.gateways import Gateway
|
||||
from app.models.organizations import Organization
|
||||
|
||||
|
||||
async def _make_engine() -> AsyncEngine:
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.connect() as conn, conn.begin():
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
return engine
|
||||
|
||||
|
||||
def _build_test_app(
|
||||
session_maker: async_sessionmaker[AsyncSession],
|
||||
) -> FastAPI:
|
||||
app = FastAPI()
|
||||
api_v1 = APIRouter(prefix="/api/v1")
|
||||
api_v1.include_router(board_webhooks_router)
|
||||
app.include_router(api_v1)
|
||||
|
||||
async def _override_get_session() -> AsyncSession:
|
||||
async with session_maker() as session:
|
||||
yield session
|
||||
|
||||
async def _override_get_board_or_404(
|
||||
board_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Board:
|
||||
board = await Board.objects.by_id(UUID(board_id)).first(session)
|
||||
if board is None:
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return board
|
||||
|
||||
app.dependency_overrides[get_session] = _override_get_session
|
||||
app.dependency_overrides[get_board_or_404] = _override_get_board_or_404
|
||||
return app
|
||||
|
||||
|
||||
async def _seed_webhook(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
enabled: bool,
|
||||
) -> tuple[Board, BoardWebhook]:
|
||||
organization_id = uuid4()
|
||||
gateway_id = uuid4()
|
||||
board_id = uuid4()
|
||||
webhook_id = uuid4()
|
||||
|
||||
session.add(Organization(id=organization_id, name=f"org-{organization_id}"))
|
||||
session.add(
|
||||
Gateway(
|
||||
id=gateway_id,
|
||||
organization_id=organization_id,
|
||||
name="gateway",
|
||||
url="https://gateway.example.local",
|
||||
workspace_root="/tmp/workspace",
|
||||
),
|
||||
)
|
||||
board = Board(
|
||||
id=board_id,
|
||||
organization_id=organization_id,
|
||||
gateway_id=gateway_id,
|
||||
name="Launch board",
|
||||
slug="launch-board",
|
||||
description="Board for launch automation.",
|
||||
)
|
||||
session.add(board)
|
||||
session.add(
|
||||
Agent(
|
||||
id=uuid4(),
|
||||
board_id=board_id,
|
||||
gateway_id=gateway_id,
|
||||
name="Lead Agent",
|
||||
status="online",
|
||||
openclaw_session_id="lead:session:key",
|
||||
is_board_lead=True,
|
||||
),
|
||||
)
|
||||
webhook = BoardWebhook(
|
||||
id=webhook_id,
|
||||
board_id=board_id,
|
||||
description="Triage payload and create tasks for impacted services.",
|
||||
enabled=enabled,
|
||||
)
|
||||
session.add(webhook)
|
||||
await session.commit()
|
||||
return board, webhook
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_board_webhook_stores_payload_and_notifies_lead(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = await _make_engine()
|
||||
session_maker = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
app = _build_test_app(session_maker)
|
||||
sent_messages: list[dict[str, str]] = []
|
||||
|
||||
async with session_maker() as session:
|
||||
board, webhook = await _seed_webhook(session, enabled=True)
|
||||
|
||||
async def _fake_optional_gateway_config_for_board(
|
||||
self: board_webhooks.GatewayDispatchService,
|
||||
_board: Board,
|
||||
) -> object:
|
||||
return object()
|
||||
|
||||
async def _fake_try_send_agent_message(
|
||||
self: board_webhooks.GatewayDispatchService,
|
||||
*,
|
||||
session_key: str,
|
||||
config: object,
|
||||
agent_name: str,
|
||||
message: str,
|
||||
deliver: bool = False,
|
||||
) -> None:
|
||||
del self, config, deliver
|
||||
sent_messages.append(
|
||||
{
|
||||
"session_key": session_key,
|
||||
"agent_name": agent_name,
|
||||
"message": message,
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
board_webhooks.GatewayDispatchService,
|
||||
"optional_gateway_config_for_board",
|
||||
_fake_optional_gateway_config_for_board,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
board_webhooks.GatewayDispatchService,
|
||||
"try_send_agent_message",
|
||||
_fake_try_send_agent_message,
|
||||
)
|
||||
|
||||
try:
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
response = await client.post(
|
||||
f"/api/v1/boards/{board.id}/webhooks/{webhook.id}",
|
||||
json={"event": "deploy", "service": "api"},
|
||||
headers={"X-Signature": "sha256=abc123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
body = response.json()
|
||||
payload_id = UUID(body["payload_id"])
|
||||
assert body["board_id"] == str(board.id)
|
||||
assert body["webhook_id"] == str(webhook.id)
|
||||
|
||||
async with session_maker() as session:
|
||||
payloads = (
|
||||
await session.exec(
|
||||
select(BoardWebhookPayload).where(col(BoardWebhookPayload.id) == payload_id),
|
||||
)
|
||||
).all()
|
||||
assert len(payloads) == 1
|
||||
assert payloads[0].payload == {"event": "deploy", "service": "api"}
|
||||
assert payloads[0].headers is not None
|
||||
assert payloads[0].headers.get("x-signature") == "sha256=abc123"
|
||||
assert payloads[0].headers.get("content-type") == "application/json"
|
||||
|
||||
memory_items = (
|
||||
await session.exec(
|
||||
select(BoardMemory).where(col(BoardMemory.board_id) == board.id),
|
||||
)
|
||||
).all()
|
||||
assert len(memory_items) == 1
|
||||
assert memory_items[0].source == "webhook"
|
||||
assert memory_items[0].tags is not None
|
||||
assert f"webhook:{webhook.id}" in memory_items[0].tags
|
||||
assert f"payload:{payload_id}" in memory_items[0].tags
|
||||
assert f"Payload ID: {payload_id}" in memory_items[0].content
|
||||
|
||||
assert len(sent_messages) == 1
|
||||
assert sent_messages[0]["session_key"] == "lead:session:key"
|
||||
assert "WEBHOOK EVENT RECEIVED" in sent_messages[0]["message"]
|
||||
assert str(payload_id) in sent_messages[0]["message"]
|
||||
assert webhook.description in sent_messages[0]["message"]
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_board_webhook_rejects_disabled_endpoint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = await _make_engine()
|
||||
session_maker = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
app = _build_test_app(session_maker)
|
||||
sent_messages: list[str] = []
|
||||
|
||||
async with session_maker() as session:
|
||||
board, webhook = await _seed_webhook(session, enabled=False)
|
||||
|
||||
async def _fake_try_send_agent_message(
|
||||
self: board_webhooks.GatewayDispatchService,
|
||||
*,
|
||||
session_key: str,
|
||||
config: object,
|
||||
agent_name: str,
|
||||
message: str,
|
||||
deliver: bool = False,
|
||||
) -> None:
|
||||
del self, session_key, config, agent_name, deliver
|
||||
sent_messages.append(message)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
board_webhooks.GatewayDispatchService,
|
||||
"try_send_agent_message",
|
||||
_fake_try_send_agent_message,
|
||||
)
|
||||
|
||||
try:
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
response = await client.post(
|
||||
f"/api/v1/boards/{board.id}/webhooks/{webhook.id}",
|
||||
json={"event": "deploy"},
|
||||
)
|
||||
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {"detail": "Webhook is disabled."}
|
||||
|
||||
async with session_maker() as session:
|
||||
stored_payloads = (
|
||||
await session.exec(
|
||||
select(BoardWebhookPayload).where(
|
||||
col(BoardWebhookPayload.board_id) == board.id
|
||||
),
|
||||
)
|
||||
).all()
|
||||
assert stored_payloads == []
|
||||
stored_memory = (
|
||||
await session.exec(
|
||||
select(BoardMemory).where(col(BoardMemory.board_id) == board.id),
|
||||
)
|
||||
).all()
|
||||
assert stored_memory == []
|
||||
|
||||
assert sent_messages == []
|
||||
finally:
|
||||
await engine.dispose()
|
||||
@@ -4,13 +4,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api import boards
|
||||
import app.services.board_lifecycle as board_lifecycle
|
||||
from app.models.boards import Board
|
||||
from app.services.openclaw.gateway_rpc import OpenClawGatewayError
|
||||
|
||||
_NO_EXEC_RESULTS_ERROR = "No more exec_results left for session.exec"
|
||||
|
||||
@@ -63,3 +66,91 @@ async def test_delete_board_cleans_org_board_access_rows() -> None:
|
||||
assert "organization_invite_board_access" in deleted_table_names
|
||||
assert board in session.deleted
|
||||
assert session.committed == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_board_cleans_tag_assignments_before_tasks() -> None:
|
||||
"""Deleting a board should remove task-tag links before deleting tasks."""
|
||||
session: Any = _FakeSession(exec_results=[[], [uuid4()]])
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
organization_id=uuid4(),
|
||||
name="Demo Board",
|
||||
slug="demo-board",
|
||||
gateway_id=None,
|
||||
)
|
||||
|
||||
await boards.delete_board(
|
||||
session=session,
|
||||
board=board,
|
||||
)
|
||||
|
||||
deleted_table_names = [statement.table.name for statement in session.executed]
|
||||
assert "tag_assignments" in deleted_table_names
|
||||
assert deleted_table_names.index("tag_assignments") < deleted_table_names.index("tasks")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_board_ignores_missing_gateway_agent(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Deleting a board should continue when gateway reports agent not found."""
|
||||
session: Any = _FakeSession(exec_results=[[]])
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
organization_id=uuid4(),
|
||||
name="Demo Board",
|
||||
slug="demo-board",
|
||||
gateway_id=uuid4(),
|
||||
)
|
||||
agent = SimpleNamespace(id=uuid4(), board_id=board.id)
|
||||
gateway = SimpleNamespace(url="ws://gateway.example/ws", token=None, workspace_root="/tmp")
|
||||
called = {"delete_agent_lifecycle": 0}
|
||||
|
||||
async def _fake_all(_session: object) -> list[object]:
|
||||
return [agent]
|
||||
|
||||
async def _fake_require_gateway_for_board(
|
||||
_session: object,
|
||||
_board: object,
|
||||
*,
|
||||
require_workspace_root: bool,
|
||||
) -> object:
|
||||
_ = require_workspace_root
|
||||
return gateway
|
||||
|
||||
async def _fake_delete_agent_lifecycle(
|
||||
_self: object,
|
||||
*,
|
||||
agent: object,
|
||||
gateway: object,
|
||||
delete_files: bool = True,
|
||||
delete_session: bool = True,
|
||||
) -> str | None:
|
||||
_ = (agent, gateway, delete_files, delete_session)
|
||||
called["delete_agent_lifecycle"] += 1
|
||||
raise OpenClawGatewayError('agent "mc-worker" not found')
|
||||
|
||||
monkeypatch.setattr(
|
||||
board_lifecycle.Agent,
|
||||
"objects",
|
||||
SimpleNamespace(filter_by=lambda **_kwargs: SimpleNamespace(all=_fake_all)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
board_lifecycle,
|
||||
"require_gateway_for_board",
|
||||
_fake_require_gateway_for_board,
|
||||
)
|
||||
monkeypatch.setattr(board_lifecycle, "gateway_client_config", lambda _gateway: None)
|
||||
monkeypatch.setattr(
|
||||
board_lifecycle.OpenClawGatewayProvisioner,
|
||||
"delete_agent_lifecycle",
|
||||
_fake_delete_agent_lifecycle,
|
||||
)
|
||||
|
||||
await boards.delete_board(
|
||||
session=session,
|
||||
board=board,
|
||||
)
|
||||
|
||||
assert called["delete_agent_lifecycle"] == 1
|
||||
assert board in session.deleted
|
||||
assert session.committed == 1
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.core.error_handling import (
|
||||
_error_payload,
|
||||
_get_request_id,
|
||||
_http_exception_exception_handler,
|
||||
_json_safe,
|
||||
_request_validation_exception_handler,
|
||||
_response_validation_exception_handler,
|
||||
_request_validation_handler,
|
||||
@@ -41,6 +42,31 @@ def test_request_validation_error_includes_request_id():
|
||||
assert resp.headers.get(REQUEST_ID_HEADER) == body["request_id"]
|
||||
|
||||
|
||||
def test_request_validation_error_handles_bytes_input_without_500():
|
||||
class Payload(BaseModel):
|
||||
content: str
|
||||
|
||||
app = FastAPI()
|
||||
install_error_handling(app)
|
||||
|
||||
@app.put("/needs-object")
|
||||
def needs_object(payload: Payload) -> dict[str, str]:
|
||||
return {"content": payload.content}
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
resp = client.put(
|
||||
"/needs-object",
|
||||
content=b"plain-text-body",
|
||||
headers={"content-type": "text/plain"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
body = resp.json()
|
||||
assert isinstance(body.get("detail"), list)
|
||||
assert isinstance(body.get("request_id"), str) and body["request_id"]
|
||||
assert resp.headers.get(REQUEST_ID_HEADER) == body["request_id"]
|
||||
|
||||
|
||||
def test_http_exception_includes_request_id():
|
||||
app = FastAPI()
|
||||
install_error_handling(app)
|
||||
@@ -187,6 +213,20 @@ def test_error_payload_omits_request_id_when_none() -> None:
|
||||
assert _error_payload(detail="x", request_id=None) == {"detail": "x"}
|
||||
|
||||
|
||||
def test_json_safe_handles_binary_inputs() -> None:
|
||||
assert _json_safe(b"\xf0\x9f\x92\xa1") == "💡"
|
||||
assert _json_safe(bytearray(b"hello")) == "hello"
|
||||
assert _json_safe(memoryview(b"world")) == "world"
|
||||
|
||||
|
||||
def test_json_safe_falls_back_to_string_for_unknown_objects() -> None:
|
||||
class Weird:
|
||||
def __str__(self) -> str:
|
||||
return "weird-value"
|
||||
|
||||
assert _json_safe(Weird()) == "weird-value"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_validation_exception_wrapper_rejects_wrong_exception() -> None:
|
||||
req = Request({"type": "http", "headers": [], "state": {}})
|
||||
|
||||
128
backend/tests/test_metrics_filters.py
Normal file
128
backend/tests/test_metrics_filters.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api import metrics as metrics_api
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, exec_result: list[object]) -> None:
|
||||
self._exec_result = exec_result
|
||||
|
||||
async def exec(self, _statement: object) -> list[object]:
|
||||
return self._exec_result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_dashboard_board_ids_returns_requested_board(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
board_id = uuid4()
|
||||
|
||||
async def _accessible(*_args: object, **_kwargs: object) -> list[object]:
|
||||
return [board_id]
|
||||
|
||||
monkeypatch.setattr(
|
||||
metrics_api,
|
||||
"list_accessible_board_ids",
|
||||
_accessible,
|
||||
)
|
||||
ctx = SimpleNamespace(member=SimpleNamespace(organization_id=uuid4()))
|
||||
|
||||
resolved = await metrics_api._resolve_dashboard_board_ids(
|
||||
_FakeSession([]),
|
||||
ctx=ctx,
|
||||
board_id=board_id,
|
||||
group_id=None,
|
||||
)
|
||||
|
||||
assert resolved == [board_id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_dashboard_board_ids_rejects_inaccessible_board(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
accessible_board_id = uuid4()
|
||||
requested_board_id = uuid4()
|
||||
|
||||
async def _accessible(*_args: object, **_kwargs: object) -> list[object]:
|
||||
return [accessible_board_id]
|
||||
|
||||
monkeypatch.setattr(
|
||||
metrics_api,
|
||||
"list_accessible_board_ids",
|
||||
_accessible,
|
||||
)
|
||||
ctx = SimpleNamespace(member=SimpleNamespace(organization_id=uuid4()))
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await metrics_api._resolve_dashboard_board_ids(
|
||||
_FakeSession([]),
|
||||
ctx=ctx,
|
||||
board_id=requested_board_id,
|
||||
group_id=None,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_dashboard_board_ids_filters_by_group(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
board_a = uuid4()
|
||||
board_b = uuid4()
|
||||
group_id = uuid4()
|
||||
|
||||
async def _accessible(*_args: object, **_kwargs: object) -> list[object]:
|
||||
return [board_a, board_b]
|
||||
|
||||
monkeypatch.setattr(
|
||||
metrics_api,
|
||||
"list_accessible_board_ids",
|
||||
_accessible,
|
||||
)
|
||||
ctx = SimpleNamespace(member=SimpleNamespace(organization_id=uuid4()))
|
||||
session = _FakeSession([board_b])
|
||||
|
||||
resolved = await metrics_api._resolve_dashboard_board_ids(
|
||||
session,
|
||||
ctx=ctx,
|
||||
board_id=None,
|
||||
group_id=group_id,
|
||||
)
|
||||
|
||||
assert resolved == [board_b]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_dashboard_board_ids_returns_empty_when_board_not_in_group(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
board_id = uuid4()
|
||||
group_id = uuid4()
|
||||
|
||||
async def _accessible(*_args: object, **_kwargs: object) -> list[object]:
|
||||
return [board_id]
|
||||
|
||||
monkeypatch.setattr(
|
||||
metrics_api,
|
||||
"list_accessible_board_ids",
|
||||
_accessible,
|
||||
)
|
||||
ctx = SimpleNamespace(member=SimpleNamespace(organization_id=uuid4()))
|
||||
session = _FakeSession([])
|
||||
|
||||
resolved = await metrics_api._resolve_dashboard_board_ids(
|
||||
session,
|
||||
ctx=ctx,
|
||||
board_id=board_id,
|
||||
group_id=group_id,
|
||||
)
|
||||
|
||||
assert resolved == []
|
||||
80
backend/tests/test_openapi_agent_role_tags.py
Normal file
80
backend/tests/test_openapi_agent_role_tags.py
Normal file
@@ -0,0 +1,80 @@
|
||||
# ruff: noqa: S101
|
||||
"""OpenAPI role-tag coverage for agent-facing endpoint discovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def _op_tags(schema: dict[str, object], *, path: str, method: str) -> set[str]:
|
||||
op = schema["paths"][path][method]
|
||||
return set(op.get("tags", []))
|
||||
|
||||
|
||||
def _op_description(schema: dict[str, object], *, path: str, method: str) -> str:
|
||||
op = schema["paths"][path][method]
|
||||
return str(op.get("description", "")).strip()
|
||||
|
||||
|
||||
def test_openapi_agent_role_tags_are_exposed() -> None:
|
||||
"""Role tags should be queryable without path-based heuristics."""
|
||||
schema = app.openapi()
|
||||
|
||||
assert "agent-lead" in _op_tags(
|
||||
schema,
|
||||
path="/api/v1/agent/boards/{board_id}/tasks",
|
||||
method="post",
|
||||
)
|
||||
assert "agent-worker" in _op_tags(
|
||||
schema,
|
||||
path="/api/v1/agent/boards/{board_id}/tasks",
|
||||
method="get",
|
||||
)
|
||||
assert "agent-main" in _op_tags(
|
||||
schema,
|
||||
path="/api/v1/agent/gateway/leads/broadcast",
|
||||
method="post",
|
||||
)
|
||||
assert "agent-worker" in _op_tags(
|
||||
schema,
|
||||
path="/api/v1/boards/{board_id}/group-memory",
|
||||
method="get",
|
||||
)
|
||||
assert "agent-lead" in _op_tags(
|
||||
schema,
|
||||
path="/api/v1/boards/{board_id}/group-snapshot",
|
||||
method="get",
|
||||
)
|
||||
heartbeat_tags = _op_tags(schema, path="/api/v1/agent/heartbeat", method="post")
|
||||
assert {"agent-lead", "agent-worker", "agent-main"} <= heartbeat_tags
|
||||
|
||||
|
||||
def test_openapi_agent_role_endpoint_descriptions_exist() -> None:
|
||||
"""Agent-role endpoints should provide human-readable operation guidance."""
|
||||
schema = app.openapi()
|
||||
|
||||
assert _op_description(
|
||||
schema,
|
||||
path="/api/v1/agent/boards/{board_id}/tasks",
|
||||
method="post",
|
||||
)
|
||||
assert _op_description(
|
||||
schema,
|
||||
path="/api/v1/agent/boards/{board_id}/tasks/{task_id}",
|
||||
method="patch",
|
||||
)
|
||||
assert _op_description(
|
||||
schema,
|
||||
path="/api/v1/agent/heartbeat",
|
||||
method="post",
|
||||
)
|
||||
assert _op_description(
|
||||
schema,
|
||||
path="/api/v1/boards/{board_id}/group-memory",
|
||||
method="get",
|
||||
)
|
||||
assert _op_description(
|
||||
schema,
|
||||
path="/api/v1/boards/{board_id}/group-snapshot",
|
||||
method="get",
|
||||
)
|
||||
@@ -59,6 +59,8 @@ async def test_delete_my_org_cleans_dependents_before_organization_delete() -> N
|
||||
"approval_task_links",
|
||||
"approvals",
|
||||
"board_memory",
|
||||
"board_webhook_payloads",
|
||||
"board_webhooks",
|
||||
"board_onboarding_sessions",
|
||||
"organization_board_access",
|
||||
"organization_invite_board_access",
|
||||
|
||||
401
backend/tests/test_tasks_done_approval_gate.py
Normal file
401
backend/tests/test_tasks_done_approval_gate.py
Normal file
@@ -0,0 +1,401 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.api import tasks as tasks_api
|
||||
from app.api.deps import ActorContext
|
||||
from app.models.agents import Agent
|
||||
from app.models.approval_task_links import ApprovalTaskLink
|
||||
from app.models.approvals import Approval
|
||||
from app.models.boards import Board
|
||||
from app.models.gateways import Gateway
|
||||
from app.models.organizations import Organization
|
||||
from app.models.tasks import Task
|
||||
from app.schemas.tasks import TaskUpdate
|
||||
|
||||
|
||||
async def _make_engine() -> AsyncEngine:
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.connect() as conn, conn.begin():
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
return engine
|
||||
|
||||
|
||||
async def _make_session(engine: AsyncEngine) -> AsyncSession:
|
||||
return AsyncSession(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
async def _seed_board_task_and_agent(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
task_status: str = "review",
|
||||
require_approval_for_done: bool = True,
|
||||
require_review_before_done: bool = False,
|
||||
block_status_changes_with_pending_approval: bool = False,
|
||||
) -> tuple[Board, Task, Agent]:
|
||||
organization_id = uuid4()
|
||||
gateway = Gateway(
|
||||
id=uuid4(),
|
||||
organization_id=organization_id,
|
||||
name="gateway",
|
||||
url="https://gateway.local",
|
||||
workspace_root="/tmp/workspace",
|
||||
)
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
organization_id=organization_id,
|
||||
gateway_id=gateway.id,
|
||||
name="board",
|
||||
slug=f"board-{uuid4()}",
|
||||
require_approval_for_done=require_approval_for_done,
|
||||
require_review_before_done=require_review_before_done,
|
||||
block_status_changes_with_pending_approval=block_status_changes_with_pending_approval,
|
||||
)
|
||||
task = Task(id=uuid4(), board_id=board.id, title="Task", status=task_status)
|
||||
agent = Agent(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
gateway_id=gateway.id,
|
||||
name="agent",
|
||||
status="online",
|
||||
is_board_lead=False,
|
||||
)
|
||||
|
||||
session.add(Organization(id=organization_id, name=f"org-{organization_id}"))
|
||||
session.add(gateway)
|
||||
session.add(board)
|
||||
session.add(task)
|
||||
session.add(agent)
|
||||
await session.commit()
|
||||
return board, task, agent
|
||||
|
||||
|
||||
async def _update_task_to_done(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
task: Task,
|
||||
agent: Agent,
|
||||
) -> None:
|
||||
await _update_task_status(
|
||||
session,
|
||||
task=task,
|
||||
agent=agent,
|
||||
status="done",
|
||||
)
|
||||
|
||||
|
||||
async def _update_task_status(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
task: Task,
|
||||
agent: Agent,
|
||||
status: Literal["inbox", "in_progress", "review", "done"],
|
||||
) -> None:
|
||||
await tasks_api.update_task(
|
||||
payload=TaskUpdate(status=status),
|
||||
task=task,
|
||||
session=session,
|
||||
actor=ActorContext(actor_type="agent", agent=agent),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_rejects_done_without_approved_linked_approval() -> None:
|
||||
engine = await _make_engine()
|
||||
try:
|
||||
async with await _make_session(engine) as session:
|
||||
board, task, agent = await _seed_board_task_and_agent(session)
|
||||
session.add(
|
||||
Approval(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
task_id=task.id,
|
||||
action_type="task.review",
|
||||
confidence=65,
|
||||
status="pending",
|
||||
),
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _update_task_to_done(session, task=task, agent=agent)
|
||||
|
||||
assert exc.value.status_code == 409
|
||||
detail = exc.value.detail
|
||||
assert isinstance(detail, dict)
|
||||
assert detail["message"] == (
|
||||
"Task can only be marked done when a linked approval has been approved."
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_allows_done_with_approved_primary_task_approval() -> None:
|
||||
engine = await _make_engine()
|
||||
try:
|
||||
async with await _make_session(engine) as session:
|
||||
board, task, agent = await _seed_board_task_and_agent(session)
|
||||
session.add(
|
||||
Approval(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
task_id=task.id,
|
||||
action_type="task.review",
|
||||
confidence=92,
|
||||
status="approved",
|
||||
),
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
updated = await tasks_api.update_task(
|
||||
payload=TaskUpdate(status="done"),
|
||||
task=task,
|
||||
session=session,
|
||||
actor=ActorContext(actor_type="agent", agent=agent),
|
||||
)
|
||||
|
||||
assert updated.status == "done"
|
||||
assert updated.assigned_agent_id == agent.id
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_allows_done_with_approved_multi_task_link() -> None:
|
||||
engine = await _make_engine()
|
||||
try:
|
||||
async with await _make_session(engine) as session:
|
||||
board, task, agent = await _seed_board_task_and_agent(session)
|
||||
primary_task_id = uuid4()
|
||||
session.add(Task(id=primary_task_id, board_id=board.id, title="Primary"))
|
||||
|
||||
approval_id = uuid4()
|
||||
session.add(
|
||||
Approval(
|
||||
id=approval_id,
|
||||
board_id=board.id,
|
||||
task_id=primary_task_id,
|
||||
action_type="task.batch_review",
|
||||
confidence=88,
|
||||
status="approved",
|
||||
),
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
session.add(ApprovalTaskLink(approval_id=approval_id, task_id=task.id))
|
||||
await session.commit()
|
||||
|
||||
updated = await tasks_api.update_task(
|
||||
payload=TaskUpdate(status="done"),
|
||||
task=task,
|
||||
session=session,
|
||||
actor=ActorContext(actor_type="agent", agent=agent),
|
||||
)
|
||||
|
||||
assert updated.status == "done"
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_allows_done_without_approval_when_board_toggle_disabled() -> None:
|
||||
engine = await _make_engine()
|
||||
try:
|
||||
async with await _make_session(engine) as session:
|
||||
_board, task, agent = await _seed_board_task_and_agent(
|
||||
session,
|
||||
require_approval_for_done=False,
|
||||
)
|
||||
|
||||
updated = await tasks_api.update_task(
|
||||
payload=TaskUpdate(status="done"),
|
||||
task=task,
|
||||
session=session,
|
||||
actor=ActorContext(actor_type="agent", agent=agent),
|
||||
)
|
||||
|
||||
assert updated.status == "done"
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_rejects_done_from_in_progress_when_review_toggle_enabled() -> None:
|
||||
engine = await _make_engine()
|
||||
try:
|
||||
async with await _make_session(engine) as session:
|
||||
_board, task, agent = await _seed_board_task_and_agent(
|
||||
session,
|
||||
task_status="in_progress",
|
||||
require_approval_for_done=False,
|
||||
require_review_before_done=True,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _update_task_to_done(session, task=task, agent=agent)
|
||||
|
||||
assert exc.value.status_code == 409
|
||||
detail = exc.value.detail
|
||||
assert isinstance(detail, dict)
|
||||
assert detail["message"] == (
|
||||
"Task can only be marked done from review when the board rule is enabled."
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_allows_done_from_review_when_review_toggle_enabled() -> None:
|
||||
engine = await _make_engine()
|
||||
try:
|
||||
async with await _make_session(engine) as session:
|
||||
_board, task, agent = await _seed_board_task_and_agent(
|
||||
session,
|
||||
task_status="review",
|
||||
require_approval_for_done=False,
|
||||
require_review_before_done=True,
|
||||
)
|
||||
|
||||
updated = await tasks_api.update_task(
|
||||
payload=TaskUpdate(status="done"),
|
||||
task=task,
|
||||
session=session,
|
||||
actor=ActorContext(actor_type="agent", agent=agent),
|
||||
)
|
||||
|
||||
assert updated.status == "done"
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_rejects_status_change_with_pending_approval_when_toggle_enabled() -> (
|
||||
None
|
||||
):
|
||||
engine = await _make_engine()
|
||||
try:
|
||||
async with await _make_session(engine) as session:
|
||||
board, task, agent = await _seed_board_task_and_agent(
|
||||
session,
|
||||
task_status="inbox",
|
||||
require_approval_for_done=False,
|
||||
block_status_changes_with_pending_approval=True,
|
||||
)
|
||||
session.add(
|
||||
Approval(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
task_id=task.id,
|
||||
action_type="task.execute",
|
||||
confidence=70,
|
||||
status="pending",
|
||||
),
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _update_task_status(
|
||||
session,
|
||||
task=task,
|
||||
agent=agent,
|
||||
status="in_progress",
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 409
|
||||
detail = exc.value.detail
|
||||
assert isinstance(detail, dict)
|
||||
assert detail["message"] == (
|
||||
"Task status cannot be changed while a linked approval is pending."
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_allows_status_change_with_pending_approval_when_toggle_disabled() -> (
|
||||
None
|
||||
):
|
||||
engine = await _make_engine()
|
||||
try:
|
||||
async with await _make_session(engine) as session:
|
||||
board, task, agent = await _seed_board_task_and_agent(
|
||||
session,
|
||||
task_status="inbox",
|
||||
require_approval_for_done=False,
|
||||
block_status_changes_with_pending_approval=False,
|
||||
)
|
||||
session.add(
|
||||
Approval(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
task_id=task.id,
|
||||
action_type="task.execute",
|
||||
confidence=70,
|
||||
status="pending",
|
||||
),
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
updated = await tasks_api.update_task(
|
||||
payload=TaskUpdate(status="in_progress"),
|
||||
task=task,
|
||||
session=session,
|
||||
actor=ActorContext(actor_type="agent", agent=agent),
|
||||
)
|
||||
|
||||
assert updated.status == "in_progress"
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_rejects_status_change_for_pending_multi_task_link_when_toggle_enabled() -> (
|
||||
None
|
||||
):
|
||||
engine = await _make_engine()
|
||||
try:
|
||||
async with await _make_session(engine) as session:
|
||||
board, task, agent = await _seed_board_task_and_agent(
|
||||
session,
|
||||
task_status="inbox",
|
||||
require_approval_for_done=False,
|
||||
block_status_changes_with_pending_approval=True,
|
||||
)
|
||||
primary_task_id = uuid4()
|
||||
session.add(Task(id=primary_task_id, board_id=board.id, title="Primary"))
|
||||
|
||||
approval_id = uuid4()
|
||||
session.add(
|
||||
Approval(
|
||||
id=approval_id,
|
||||
board_id=board.id,
|
||||
task_id=primary_task_id,
|
||||
action_type="task.batch_execute",
|
||||
confidence=73,
|
||||
status="pending",
|
||||
),
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
session.add(ApprovalTaskLink(approval_id=approval_id, task_id=task.id))
|
||||
await session.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _update_task_status(
|
||||
session,
|
||||
task=task,
|
||||
agent=agent,
|
||||
status="in_progress",
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 409
|
||||
finally:
|
||||
await engine.dispose()
|
||||
23
backend/tests/test_template_size_budget.py
Normal file
23
backend/tests/test_template_size_budget.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# ruff: noqa: S101
|
||||
"""Template size guardrails for injected heartbeat context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
HEARTBEAT_CONTEXT_LIMIT = 20_000
|
||||
TEMPLATES_DIR = Path(__file__).resolve().parents[1] / "templates"
|
||||
|
||||
|
||||
def test_heartbeat_templates_fit_in_injected_context_limit() -> None:
|
||||
"""Heartbeat templates must stay under gateway injected-context truncation limit."""
|
||||
targets = (
|
||||
"HEARTBEAT_LEAD.md",
|
||||
"HEARTBEAT_AGENT.md",
|
||||
"MAIN_HEARTBEAT.md",
|
||||
)
|
||||
for name in targets:
|
||||
size = (TEMPLATES_DIR / name).stat().st_size
|
||||
assert size <= HEARTBEAT_CONTEXT_LIMIT, (
|
||||
f"{name} is {size} chars (limit {HEARTBEAT_CONTEXT_LIMIT})"
|
||||
)
|
||||
Reference in New Issue
Block a user