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:
Renn F
2026-05-06 00:32:52 +02:00
parent b6903490f1
commit 64c48356d0
46 changed files with 2994 additions and 206 deletions
+163
View File
@@ -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()
+117
View File
@@ -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
+2 -7
View File
@@ -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:
+111
View File
@@ -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
+73
View File
@@ -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"