mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
test: lift coverage 41% → 76% (+1068 tests across 36 files)
Service-level tests now exercise provider, permissions, project, journal, messaging, work_session, metrics, kanban, extraction, learning, notification, dashboard, llm_routing, a2a, task, repository_base, audit, db_seed, branch_name, indexed_document, query_helpers, agent. API route tests cover provider, journal, project, sessions, dashboard, work_session, tasks, a2a, groups, notifications, agents, channels, messages, kanban, api_resources. Pure-function helpers covered: handlers, deps_helpers, middleware, middleware_docs, transcription, pr templates, agents_config, errors, logging, journal/notification/channel/a2a access, task_lifecycle, streaming, converters, crypto, schemas (common + websocket), events, permissions extras. pyproject ruff per-file-ignores extended for tests so PLR2004 (status code magic values), PLC0415 (lazy imports), PLR0913 (fixture params), ARG001 (unused fixture deps), SIM105, and E501 don't fight test idioms.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
"""api.utils.errors coverage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from roboco.api.utils.errors import (
|
||||
conflict,
|
||||
forbidden,
|
||||
handle_service_error,
|
||||
not_found,
|
||||
service_error_handler,
|
||||
service_unavailable,
|
||||
unauthorized,
|
||||
validation_error,
|
||||
)
|
||||
from roboco.services.base import (
|
||||
ConflictError,
|
||||
NotFoundError,
|
||||
ServiceError,
|
||||
ServiceUnavailableError,
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_not_found_with_id() -> None:
|
||||
e = not_found("Task", "abc-123")
|
||||
assert e.status_code == 404
|
||||
assert "abc-123" in e.detail
|
||||
|
||||
|
||||
def test_not_found_without_id() -> None:
|
||||
e = not_found("Task")
|
||||
assert e.status_code == 404
|
||||
assert e.detail == "Task not found"
|
||||
|
||||
|
||||
def test_forbidden_basic() -> None:
|
||||
e = forbidden("edit task")
|
||||
assert e.status_code == 403
|
||||
assert "edit task" in e.detail
|
||||
|
||||
|
||||
def test_forbidden_with_reason() -> None:
|
||||
e = forbidden("edit", reason="not owner")
|
||||
assert e.status_code == 403
|
||||
assert "not owner" in e.detail
|
||||
|
||||
|
||||
def test_unauthorized_default() -> None:
|
||||
e = unauthorized()
|
||||
assert e.status_code == 401
|
||||
|
||||
|
||||
def test_unauthorized_custom() -> None:
|
||||
e = unauthorized("Missing token")
|
||||
assert e.detail == "Missing token"
|
||||
|
||||
|
||||
def test_validation_error_basic() -> None:
|
||||
e = validation_error("bad input")
|
||||
assert e.status_code == 400
|
||||
|
||||
|
||||
def test_validation_error_with_field() -> None:
|
||||
e = validation_error("required", field="title")
|
||||
assert "title" in e.detail
|
||||
|
||||
|
||||
def test_conflict_basic() -> None:
|
||||
e = conflict("duplicate")
|
||||
assert e.status_code == 409
|
||||
|
||||
|
||||
def test_conflict_with_resource() -> None:
|
||||
e = conflict("duplicate", resource_type="Channel")
|
||||
assert "Channel" in e.detail
|
||||
|
||||
|
||||
def test_service_unavailable_basic() -> None:
|
||||
e = service_unavailable("Orchestrator")
|
||||
assert e.status_code == 503
|
||||
|
||||
|
||||
def test_service_unavailable_with_reason() -> None:
|
||||
e = service_unavailable("Orchestrator", reason="not init")
|
||||
assert "not init" in e.detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# handle_service_error translation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_handle_not_found() -> None:
|
||||
e = handle_service_error(NotFoundError(resource_type="Task", resource_id="abc"))
|
||||
assert e.status_code == 404
|
||||
|
||||
|
||||
def test_handle_validation_error() -> None:
|
||||
e = handle_service_error(ValidationError("bad", field="x"))
|
||||
assert e.status_code == 400
|
||||
|
||||
|
||||
def test_handle_conflict() -> None:
|
||||
e = handle_service_error(ConflictError("dup", resource_type="Channel"))
|
||||
assert e.status_code == 409
|
||||
|
||||
|
||||
def test_handle_unauthorized() -> None:
|
||||
e = handle_service_error(UnauthorizedError(action="edit", reason="x"))
|
||||
assert e.status_code == 403
|
||||
|
||||
|
||||
def test_handle_service_unavailable() -> None:
|
||||
e = handle_service_error(ServiceUnavailableError(service_name="X", reason="r"))
|
||||
assert e.status_code == 503
|
||||
|
||||
|
||||
def test_handle_generic_service_error() -> None:
|
||||
e = handle_service_error(ServiceError("oops"))
|
||||
assert e.status_code == 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decorator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_error_handler_translates() -> None:
|
||||
@service_error_handler
|
||||
async def my_route() -> str:
|
||||
raise NotFoundError(resource_type="X", resource_id="1")
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await my_route()
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_error_handler_passes_through_value() -> None:
|
||||
@service_error_handler
|
||||
async def my_route() -> str:
|
||||
return "ok"
|
||||
|
||||
result = await my_route()
|
||||
assert result == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_error_handler_does_not_catch_other_exceptions() -> None:
|
||||
@service_error_handler
|
||||
async def my_route() -> str:
|
||||
raise ValueError("not a service error")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await my_route()
|
||||
@@ -0,0 +1,117 @@
|
||||
"""api.middleware coverage — pure-function status mapping + handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from roboco.api.middleware import (
|
||||
get_status_code,
|
||||
setup_middleware,
|
||||
)
|
||||
from roboco.exceptions import (
|
||||
AuthenticationError,
|
||||
InvalidStateError,
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
RobocoError,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_status_code
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_status_code_for_not_found() -> None:
|
||||
assert get_status_code(NotFoundError("Task", "abc")) == 404
|
||||
|
||||
|
||||
def test_get_status_code_for_validation() -> None:
|
||||
assert get_status_code(ValidationError("x")) == 422
|
||||
|
||||
|
||||
def test_get_status_code_for_invalid_state() -> None:
|
||||
assert get_status_code(InvalidStateError("pending", "complete")) == 409
|
||||
|
||||
|
||||
def test_get_status_code_for_permission() -> None:
|
||||
assert get_status_code(PermissionDeniedError("x")) == 403
|
||||
|
||||
|
||||
def test_get_status_code_for_auth() -> None:
|
||||
assert get_status_code(AuthenticationError("x")) == 401
|
||||
|
||||
|
||||
def test_get_status_code_for_generic() -> None:
|
||||
"""Unknown RobocoError subclass defaults to 400."""
|
||||
assert get_status_code(RobocoError("x", code="other")) == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Middleware integration via TestClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/ok")
|
||||
async def _ok():
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/raise")
|
||||
async def _raise():
|
||||
raise RuntimeError("boom")
|
||||
|
||||
@app.get("/notfound")
|
||||
async def _nf():
|
||||
raise NotFoundError("Resource", "abc")
|
||||
|
||||
@app.get("/http-error")
|
||||
async def _he():
|
||||
raise HTTPException(status_code=403, detail="nope")
|
||||
|
||||
setup_middleware(app)
|
||||
return app
|
||||
|
||||
|
||||
def test_middleware_adds_correlation_id_header() -> None:
|
||||
client = TestClient(_make_app())
|
||||
response = client.get("/ok")
|
||||
assert response.status_code == 200
|
||||
assert "X-Correlation-ID" in response.headers
|
||||
|
||||
|
||||
def test_middleware_uses_provided_correlation_id() -> None:
|
||||
client = TestClient(_make_app())
|
||||
cid = "test-correlation-12345"
|
||||
response = client.get("/ok", headers={"X-Correlation-ID": cid})
|
||||
assert response.headers["X-Correlation-ID"] == cid
|
||||
|
||||
|
||||
def test_middleware_adds_response_time_header() -> None:
|
||||
client = TestClient(_make_app())
|
||||
response = client.get("/ok")
|
||||
assert "X-Response-Time-Ms" in response.headers
|
||||
|
||||
|
||||
def test_roboco_exception_translates_to_404(caplog) -> None:
|
||||
client = TestClient(_make_app(), raise_server_exceptions=False)
|
||||
response = client.get("/notfound")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_http_exception_handler_returns_standardized_format() -> None:
|
||||
client = TestClient(_make_app(), raise_server_exceptions=False)
|
||||
response = client.get("/http-error")
|
||||
assert response.status_code == 403
|
||||
body = response.json()
|
||||
assert "error" in body
|
||||
|
||||
|
||||
def test_generic_exception_returns_500() -> None:
|
||||
client = TestClient(_make_app(), raise_server_exceptions=False)
|
||||
response = client.get("/raise")
|
||||
assert response.status_code == 500
|
||||
body = response.json()
|
||||
assert "error" in body
|
||||
@@ -13,7 +13,6 @@ from roboco.api.middleware_docs import (
|
||||
)
|
||||
from roboco.exceptions import PermissionDeniedError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _strip_path_prefixes
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -70,15 +69,11 @@ def test_agent_matches_slug() -> None:
|
||||
|
||||
|
||||
def test_agent_matches_role() -> None:
|
||||
assert _agent_matches_permission(
|
||||
"be-pm", "cell_pm", "backend", "cell_pm"
|
||||
)
|
||||
assert _agent_matches_permission("be-pm", "cell_pm", "backend", "cell_pm")
|
||||
|
||||
|
||||
def test_agent_matches_team() -> None:
|
||||
assert _agent_matches_permission(
|
||||
"be-dev-1", "developer", "backend", "team:backend"
|
||||
)
|
||||
assert _agent_matches_permission("be-dev-1", "developer", "backend", "team:backend")
|
||||
|
||||
|
||||
def test_agent_does_not_match_different_team() -> None:
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""api.schemas.common coverage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from roboco.api.schemas.common import (
|
||||
ApiResponse,
|
||||
ErrorCode,
|
||||
ErrorDetail,
|
||||
ListResponse,
|
||||
error_response,
|
||||
list_response,
|
||||
success_response,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# success_response
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_success_response_basic() -> None:
|
||||
out = success_response({"key": "value"})
|
||||
assert out["status"] == "success"
|
||||
assert out["data"] == {"key": "value"}
|
||||
|
||||
|
||||
def test_success_response_with_guidance() -> None:
|
||||
out = success_response({"x": 1}, guidance="next step")
|
||||
assert out["guidance"] == "next step"
|
||||
|
||||
|
||||
def test_success_response_with_next_step() -> None:
|
||||
out = success_response({"x": 1}, next_step="EXECUTE")
|
||||
assert out["next_step"] == "EXECUTE"
|
||||
|
||||
|
||||
def test_success_response_with_all_fields() -> None:
|
||||
out = success_response({"x": 1}, guidance="g", next_step="EXECUTE")
|
||||
assert out["guidance"] == "g"
|
||||
assert out["next_step"] == "EXECUTE"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# error_response
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_error_response_basic() -> None:
|
||||
out = error_response("NOT_FOUND", "missing")
|
||||
assert out["error"]["code"] == "NOT_FOUND"
|
||||
assert out["error"]["message"] == "missing"
|
||||
|
||||
|
||||
def test_error_response_with_details() -> None:
|
||||
out = error_response("INVALID", "bad", details={"field": "x"})
|
||||
assert out["error"]["details"] == {"field": "x"}
|
||||
|
||||
|
||||
def test_error_response_with_hint() -> None:
|
||||
out = error_response("RAG_FAILED", "x", hint="try later")
|
||||
assert out["error"]["hint"] == "try later"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_response
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_response_no_more() -> None:
|
||||
out = list_response(items=[1, 2, 3], total=3, offset=0, limit=20)
|
||||
assert out["has_more"] is False
|
||||
assert out["items"] == [1, 2, 3]
|
||||
|
||||
|
||||
def test_list_response_with_more() -> None:
|
||||
out = list_response(items=[1, 2], total=10, offset=0, limit=2)
|
||||
assert out["has_more"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error codes constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_error_codes_defined() -> None:
|
||||
assert ErrorCode.NOT_FOUND == "NOT_FOUND"
|
||||
assert ErrorCode.ACCESS_DENIED == "ACCESS_DENIED"
|
||||
assert ErrorCode.TASK_NOT_FOUND == "TASK_NOT_FOUND"
|
||||
assert ErrorCode.PERMISSION_DENIED == "PERMISSION_DENIED"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_error_detail_model() -> None:
|
||||
e = ErrorDetail(code="X", message="m")
|
||||
assert e.code == "X"
|
||||
assert e.details is None
|
||||
|
||||
|
||||
def test_api_response_model() -> None:
|
||||
r = ApiResponse[dict](status="success", data={"k": "v"})
|
||||
assert r.status == "success"
|
||||
assert r.data == {"k": "v"}
|
||||
|
||||
|
||||
def test_list_response_model() -> None:
|
||||
r = ListResponse[int](items=[1, 2], total=2)
|
||||
assert r.total == 2
|
||||
assert r.has_more is False
|
||||
@@ -0,0 +1,73 @@
|
||||
"""api.schemas.websocket coverage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from roboco.api.schemas.websocket import (
|
||||
NewMessageBroadcast,
|
||||
WSAgentStream,
|
||||
WSMessage,
|
||||
WSMessageDelete,
|
||||
WSMessageEdit,
|
||||
WSMessageNew,
|
||||
WSNotification,
|
||||
WSSessionClosed,
|
||||
)
|
||||
|
||||
|
||||
def test_new_message_broadcast() -> None:
|
||||
bcast = NewMessageBroadcast(
|
||||
channel_id=uuid4(),
|
||||
session_id=uuid4(),
|
||||
message_id=uuid4(),
|
||||
agent_id=uuid4(),
|
||||
content="hello",
|
||||
message_type="dialogue",
|
||||
)
|
||||
assert bcast.content == "hello"
|
||||
|
||||
|
||||
def test_ws_message_base() -> None:
|
||||
msg = WSMessage(type="custom")
|
||||
assert msg.type == "custom"
|
||||
|
||||
|
||||
def test_ws_message_new() -> None:
|
||||
msg = WSMessageNew(
|
||||
message_id=uuid4(),
|
||||
agent_id=uuid4(),
|
||||
content="hi",
|
||||
message_type="dialogue",
|
||||
)
|
||||
assert msg.type == "message.new"
|
||||
|
||||
|
||||
def test_ws_message_edit() -> None:
|
||||
msg = WSMessageEdit(message_id=uuid4(), content="edited")
|
||||
assert msg.type == "message.edit"
|
||||
|
||||
|
||||
def test_ws_message_delete() -> None:
|
||||
msg = WSMessageDelete(message_id=uuid4())
|
||||
assert msg.type == "message.delete"
|
||||
|
||||
|
||||
def test_ws_agent_stream() -> None:
|
||||
msg = WSAgentStream(agent_id=uuid4(), chunk="thinking...")
|
||||
assert msg.type == "agent.stream"
|
||||
|
||||
|
||||
def test_ws_session_closed() -> None:
|
||||
msg = WSSessionClosed(session_id=uuid4(), reason="timeout")
|
||||
assert msg.type == "session.closed"
|
||||
|
||||
|
||||
def test_ws_notification() -> None:
|
||||
msg = WSNotification(
|
||||
notification_id=uuid4(),
|
||||
notification_type="MENTION",
|
||||
subject="hi",
|
||||
priority="normal",
|
||||
)
|
||||
assert msg.type == "notification"
|
||||
Reference in New Issue
Block a user