Files
roboco/tests/unit/services/test_optimal_grounding.py
T
6cf99a1b0a [beb8cae1] Type-gate tests/ under mypy — fix all errors and flip quality gate (#156) (#157)
* [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>
2026-06-14 13:43:46 +02:00

152 lines
5.4 KiB
Python

"""Grounding-layer regression tests for the Optimal/RAG stack.
Covers three failure modes that surfaced at runtime:
1. ``get_optimal_service`` published a not-yet-initialized singleton while
``initialize()`` was still awaiting, so a concurrent caller hit
"OptimalService not initialized. Call initialize() first." during indexing.
2. ``roboco_kb_search`` forwarded the legacy alias ``index_types=['docs']``,
which is not a valid ``IndexType`` value ('documentation' is), producing a
400 at the route.
3. The mentor route let exceptions from ``mentor.ask`` escape as a bare 500
with no log of the true upstream cause.
"""
from __future__ import annotations
import asyncio
from typing import Any
import pytest
from roboco.mcp.optimal_server import normalize_index_types
from roboco.models.optimal import IndexType
from roboco.services import optimal as optimal_module
from roboco.services.optimal import (
OptimalService,
close_optimal_service,
get_optimal_service,
)
from roboco.services.optimal_brain.indexes.base import BaseIndexPlugin
# ---------------------------------------------------------------------------
# Sub-issue 2: kb_search must not forward the invalid 'docs' alias
# ---------------------------------------------------------------------------
def test_normalize_index_types_maps_docs_alias_to_documentation() -> None:
"""The legacy 'docs' alias must become the valid 'documentation' value.
``IndexType('docs')`` raises ``ValueError`` — the only valid value is
``IndexType.DOCUMENTATION`` whose string value is 'documentation'.
"""
assert normalize_index_types(["docs"]) == ["documentation"]
# Every normalized value must be a constructible IndexType.
normalized = normalize_index_types(["docs"])
assert normalized is not None
for value in normalized:
IndexType(value)
def test_normalize_index_types_passes_valid_values_through() -> None:
assert normalize_index_types(["documentation", "decisions"]) == [
"documentation",
"decisions",
]
def test_normalize_index_types_none_returns_none() -> None:
assert normalize_index_types(None) is None
# ---------------------------------------------------------------------------
# Sub-issue 1: the init entrypoint must never expose an uninitialized singleton
# ---------------------------------------------------------------------------
class _SlowInitService(OptimalService):
"""OptimalService whose initialize() yields control mid-flight.
This reproduces the publish-before-initialize race: while one coroutine
is awaiting inside ``initialize()``, a second coroutine calls
``get_optimal_service()``. With the old code the second caller received
the instance with ``_initialized == False``.
"""
init_calls = 0
async def initialize(self) -> None:
type(self).init_calls += 1
# Cooperatively yield so a concurrent get_optimal_service() can run
# during the window the old code left the instance unpublished/uninit.
await asyncio.sleep(0)
self._initialized = True
@pytest.mark.asyncio
async def test_get_optimal_service_never_returns_uninitialized(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Concurrent callers must all receive a fully-initialized singleton.
The indexing entrypoint calls ``_get_plugin`` which raises
"OptimalService not initialized" when ``_initialized`` is False. This test
asserts no concurrent caller can observe that state.
"""
await close_optimal_service()
_SlowInitService.init_calls = 0
monkeypatch.setattr(optimal_module, "OptimalService", _SlowInitService)
try:
results: Any = await asyncio.gather(
get_optimal_service(),
get_optimal_service(),
get_optimal_service(),
)
for svc in results:
assert svc._initialized is True
# All callers share the one singleton, initialized exactly once.
assert len({id(s) for s in results}) == 1
assert _SlowInitService.init_calls == 1
finally:
await close_optimal_service()
@pytest.mark.asyncio
async def test_indexing_entrypoint_does_not_raise_not_initialized(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A caller obtaining the service mid-init must not hit _get_plugin's guard."""
await close_optimal_service()
_SlowInitService.init_calls = 0
monkeypatch.setattr(optimal_module, "OptimalService", _SlowInitService)
async def _use_service() -> None:
svc = await get_optimal_service()
# Mirror what index_documentation does first: resolve the plugin,
# which raises RuntimeError("OptimalService not initialized...") if
# the singleton was published before initialize() completed.
svc._plugins[IndexType.DOCUMENTATION] = _FakePlugin()
svc._get_plugin(IndexType.DOCUMENTATION)
try:
await asyncio.gather(_use_service(), _use_service())
finally:
await close_optimal_service()
class _FakePlugin(BaseIndexPlugin):
"""Minimal stand-in so _get_plugin returns without a real plugin."""
@property
def index_type(self) -> IndexType:
return IndexType.DOCUMENTATION
def prepare_metadata(self, content: str, **kwargs: Any) -> dict[str, Any]:
return {}
def build_source_uri(self, doc_id: str | None = None, **kwargs: Any) -> str | None:
return None
async def close(self) -> None: # pragma: no cover - never awaited here
return None