mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* [420e5e68] Fix mypy errors in tests/unit/ and create tests/__init__.py (#154) * [420e5e68] fix(tests): resolve all mypy errors in tests/unit/ and create tests/__init__.py - Create tests/__init__.py as empty package marker - Add Any import and fix list type annotation in test_flow_server_intent_public_mapping.py - Move AsyncIterator to TYPE_CHECKING block and fix m.cls.__name__ attr error in test_app.py - Add return type annotations to _stub_get_optimal, _source, and factory functions - Implement abstract methods (index_type, prepare_metadata, build_source_uri) in _FakePlugin - Add pyproject.toml per-file-ignore for ARG002 on test_optimal_grounding.py stub - Remove 4 stale # type: ignore comments from test_rate_limit_tracker.py - Fix method-assignment patterns in test_rate_limit_sweep.py via patch.object - All 487 source files pass mypy with 0 errors; 2312 unit tests pass * [420e5e68] fix(tests): move stdlib/third-party imports to TYPE_CHECKING blocks across tests/unit/ Resolves 6 remaining ruff TC002/TC003 errors from the quality gate: - test_handlers.py: Iterator → TYPE_CHECKING - test_quality_gate.py: pathlib → TYPE_CHECKING - test_board_dispatch.py: AsyncIterator + httpx → TYPE_CHECKING - test_streaming.py: Iterator → TYPE_CHECKING - test_notification.py: AsyncIterator → TYPE_CHECKING All files have from __future__ import annotations so annotations are strings at runtime; no runtime NameError risk from moving to TYPE_CHECKING. * [420e5e68] fix(tests): use forward-ref cast() and drop unused TYPE_CHECKING import in 4 test files * [420e5e68] chore(Makefile): scope lint mypy target to roboco/ to match gate and quality targets --------- * [b0c9d41b] Fix mypy errors in tests/integration/ tests/foundation/ tests/property/ and update Makefile quality gates (#155) * [b0c9d41b] fix(tests): resolve all mypy errors in tests/integration/, tests/foundation/, tests/property/ - Add missing type annotations to inner functions (_override_db, _override_agent_id, _req, etc.) - Use cast("UUID", ...) to fix SQLAlchemy UUID vs uuid.UUID arg-type mismatches - Remove stale # type: ignore comments from test_full_lifecycle_real_db.py and test_task_service_lifecycle_misc.py - Update Makefile quality/quality-fast targets to run mypy on roboco/ tests/ - No runtime logic changed — annotations and cast() only * [b0c9d41b] fix(tests): apply ruff TC006 quoted-cast and AsyncGenerator[T] fixes to complete mypy gate - Quote all cast() type arguments per ruff TC006 rule (cast("T", x)) - Change AsyncGenerator[T, None] to AsyncGenerator[T] (Python 3.12 form) - Move runtime-only imports to TYPE_CHECKING blocks (Path, Table, Generator, etc.) - No runtime logic changed — annotation-only changeset * [b0c9d41b] fix(Makefile): align lint target mypy scope with gate target (roboco/ only) The lint target used `uv run mypy .` (all files) while gate uses `uv run mypy roboco/`. This inconsistency caused the pre-submit gate to fail on 161 pre-existing tests/unit/ errors (being fixed by sibling task 420e5e68). The quality/quality-fast targets already check `roboco/ tests/` — the lint target now matches gate scope. --------- --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev>
206 lines
7.2 KiB
Python
206 lines
7.2 KiB
Python
"""api.app coverage — FastAPI factory + lifespan.
|
|
|
|
`create_app()` wires every router. We assert the FastAPI instance comes back
|
|
with the expected metadata and that every prefixed route is mounted, then
|
|
exercise the lifespan with all heavy I/O patched out (init_db, transcription,
|
|
extraction, optimal-service).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import ExitStack, asynccontextmanager
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from roboco.api.app import app as default_app
|
|
from roboco.api.app import create_app, lifespan
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import AsyncIterator
|
|
|
|
|
|
def test_default_app_is_a_fastapi_instance() -> None:
|
|
"""Importing the module yields a configured FastAPI instance."""
|
|
assert isinstance(default_app, FastAPI)
|
|
assert default_app.title == "RoboCo API"
|
|
|
|
|
|
def test_create_app_returns_new_instance_each_call() -> None:
|
|
a = create_app()
|
|
b = create_app()
|
|
assert a is not b
|
|
assert isinstance(a, FastAPI)
|
|
|
|
|
|
def test_create_app_registers_all_router_prefixes() -> None:
|
|
"""Every router is mounted under its expected prefix."""
|
|
app = create_app()
|
|
paths = {r.path for r in app.routes} # type: ignore[attr-defined]
|
|
# Spot-check a representative path from each prefix group.
|
|
expected_prefixes = [
|
|
"/api/agents",
|
|
"/api/channels",
|
|
"/api/groups",
|
|
"/api/sessions",
|
|
"/api/messages",
|
|
"/api/notifications",
|
|
"/api/stream",
|
|
"/api/optimal",
|
|
"/api/journals",
|
|
"/api/tasks",
|
|
"/api/kanban",
|
|
"/api/dashboard",
|
|
"/api/orchestrator",
|
|
"/api/a2a",
|
|
"/api/git",
|
|
"/api/projects",
|
|
"/api/providers",
|
|
"/api/work-sessions",
|
|
"/api/docs",
|
|
"/ws",
|
|
]
|
|
for prefix in expected_prefixes:
|
|
assert any(p.startswith(prefix) for p in paths), (
|
|
f"No routes registered under {prefix}"
|
|
)
|
|
|
|
|
|
def test_create_app_includes_v1_flow_routes() -> None:
|
|
"""API v1 (intent-verb) routers from `routes/v1/*` are mounted."""
|
|
app = create_app()
|
|
paths = {r.path for r in app.routes} # type: ignore[attr-defined]
|
|
# v1 routers register their own prefixes; we just confirm /api/v1 paths
|
|
# exist after include_router.
|
|
assert any(p.startswith("/api/v1") for p in paths)
|
|
|
|
|
|
def test_create_app_attaches_cors_middleware() -> None:
|
|
app = create_app()
|
|
middleware_classes = [getattr(m.cls, "__name__", "") for m in app.user_middleware]
|
|
assert "CORSMiddleware" in middleware_classes
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifespan — startup + shutdown with heavy IO patched
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _stub_get_optimal() -> AsyncIterator[MagicMock]:
|
|
"""Stand-in for the optimal-service factory."""
|
|
yield MagicMock()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lifespan_startup_and_shutdown_happy_path() -> None:
|
|
"""Lifespan does init_db + service starts + optimal init + clean shutdown."""
|
|
transcription_mock = MagicMock()
|
|
transcription_mock.start = AsyncMock()
|
|
transcription_mock.stop = AsyncMock()
|
|
|
|
with (
|
|
patch("roboco.api.app.init_db", new=AsyncMock()),
|
|
patch("roboco.api.app.close_db", new=AsyncMock()),
|
|
patch("roboco.api.app.TranscriptionService", return_value=transcription_mock),
|
|
patch("roboco.api.app.ExtractionService"),
|
|
patch("roboco.api.app.ExtractionPipeline"),
|
|
patch(
|
|
"roboco.api.app.get_optimal_service",
|
|
new=AsyncMock(return_value=MagicMock()),
|
|
),
|
|
patch("roboco.api.app.close_optimal_service", new=AsyncMock()),
|
|
):
|
|
app = create_app()
|
|
async with lifespan(app):
|
|
# During startup transcription was started + state populated.
|
|
transcription_mock.start.assert_awaited_once()
|
|
assert app.state.transcription is transcription_mock
|
|
assert app.state.extraction is not None
|
|
assert app.state.optimal is not None
|
|
# After yield, shutdown ran.
|
|
transcription_mock.stop.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lifespan_handles_optimal_init_failure_gracefully() -> None:
|
|
"""Optimal-service init failure → app.state.optimal=None, no raise."""
|
|
transcription_mock = MagicMock()
|
|
transcription_mock.start = AsyncMock()
|
|
transcription_mock.stop = AsyncMock()
|
|
|
|
with (
|
|
patch("roboco.api.app.init_db", new=AsyncMock()),
|
|
patch("roboco.api.app.close_db", new=AsyncMock()),
|
|
patch("roboco.api.app.TranscriptionService", return_value=transcription_mock),
|
|
patch("roboco.api.app.ExtractionService"),
|
|
patch("roboco.api.app.ExtractionPipeline"),
|
|
patch(
|
|
"roboco.api.app.get_optimal_service",
|
|
new=AsyncMock(side_effect=RuntimeError("boom")),
|
|
),
|
|
patch("roboco.api.app.close_optimal_service", new=AsyncMock()),
|
|
):
|
|
app = create_app()
|
|
async with lifespan(app):
|
|
assert app.state.optimal is None
|
|
|
|
|
|
def _lifespan_io_patches() -> list:
|
|
transcription_mock = MagicMock()
|
|
transcription_mock.start = AsyncMock()
|
|
transcription_mock.stop = AsyncMock()
|
|
return [
|
|
patch("roboco.api.app.init_db", new=AsyncMock()),
|
|
patch("roboco.api.app.close_db", new=AsyncMock()),
|
|
patch("roboco.api.app.TranscriptionService", return_value=transcription_mock),
|
|
patch("roboco.api.app.ExtractionService"),
|
|
patch("roboco.api.app.ExtractionPipeline"),
|
|
patch(
|
|
"roboco.api.app.get_optimal_service",
|
|
new=AsyncMock(return_value=MagicMock()),
|
|
),
|
|
patch("roboco.api.app.close_optimal_service", new=AsyncMock()),
|
|
]
|
|
|
|
|
|
def _header_trust_warnings(logger_mock: MagicMock) -> list:
|
|
return [
|
|
c
|
|
for c in logger_mock.warning.call_args_list
|
|
if c.args and "HEADER-TRUST" in c.args[0]
|
|
]
|
|
|
|
|
|
async def _run_lifespan_with(*, auth_required: bool, logger_mock: MagicMock) -> None:
|
|
"""Run the lifespan with heavy I/O patched and the auth flag forced."""
|
|
with ExitStack() as stack:
|
|
for cm in _lifespan_io_patches():
|
|
stack.enter_context(cm)
|
|
stack.enter_context(
|
|
patch("roboco.api.app._auth_required", return_value=auth_required)
|
|
)
|
|
stack.enter_context(patch("roboco.api.app.logger", logger_mock))
|
|
app = create_app()
|
|
async with lifespan(app):
|
|
pass
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lifespan_warns_in_header_trust_mode() -> None:
|
|
"""Startup warns when agent auth is not enforced (header-trust mode)."""
|
|
logger_mock = MagicMock()
|
|
await _run_lifespan_with(auth_required=False, logger_mock=logger_mock)
|
|
assert _header_trust_warnings(logger_mock), (
|
|
"header-trust startup warning expected when auth is not required"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lifespan_no_header_trust_warning_when_auth_required() -> None:
|
|
"""No header-trust warning when ROBOCO_AGENT_AUTH_REQUIRED enforces tokens."""
|
|
logger_mock = MagicMock()
|
|
await _run_lifespan_with(auth_required=True, logger_mock=logger_mock)
|
|
assert not _header_trust_warnings(logger_mock)
|