Files
roboco/tests/integration/test_provider_service.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

423 lines
13 KiB
Python

"""ProviderService coverage — list/get/create/update/delete/decrypt.
Drives a real `db_session` via the project's Postgres-backed conftest.
Provider rows are encrypted at rest with Fernet; tests round-trip
plaintext → ciphertext → plaintext through `get_decrypted_token` and
exercise the tri-state semantics of ``ProviderUpdate.auth_token``.
"""
from __future__ import annotations
import uuid
from typing import TYPE_CHECKING, cast
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import ModelAssignmentTable
from roboco.models.base import ModelProvider
from roboco.services import provider as provider_module
from roboco.services.base import ConflictError, NotFoundError
from roboco.services.provider import (
ProviderCreate,
ProviderService,
ProviderUpdate,
get_provider_service,
)
from roboco.utils.crypto import EncryptionError
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
@pytest_asyncio.fixture
async def provider_svc(db_session: AsyncSession) -> AsyncIterator[ProviderService]:
yield ProviderService(db_session)
@pytest.mark.asyncio
async def test_create_provider_with_token_encrypts(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(
name=f"anthropic-{uuid4().hex[:6]}",
type=ModelProvider.ANTHROPIC,
auth_token="sk-test-secret",
)
)
assert row.auth_token_encrypted is not None
assert row.auth_token_encrypted != "sk-test-secret"
@pytest.mark.asyncio
async def test_create_provider_without_token_leaves_null(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"p-{uuid4().hex[:6]}", type=ModelProvider.LOCAL)
)
assert row.auth_token_encrypted is None
@pytest.mark.asyncio
async def test_create_provider_duplicate_name_raises(
provider_svc: ProviderService,
) -> None:
name = f"dup-{uuid4().hex[:6]}"
await provider_svc.create_provider(
ProviderCreate(name=name, type=ModelProvider.ANTHROPIC)
)
with pytest.raises(ConflictError):
await provider_svc.create_provider(
ProviderCreate(name=name, type=ModelProvider.OPENAI)
)
@pytest.mark.asyncio
async def test_get_provider_returns_row(provider_svc: ProviderService) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"g-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
)
fetched = await provider_svc.get_provider(cast("uuid.UUID", row.id))
assert fetched is not None
assert fetched.id == row.id
@pytest.mark.asyncio
async def test_get_provider_returns_none_when_missing(
provider_svc: ProviderService,
) -> None:
assert await provider_svc.get_provider(uuid4()) is None
@pytest.mark.asyncio
async def test_get_provider_or_raise_raises(provider_svc: ProviderService) -> None:
with pytest.raises(NotFoundError):
await provider_svc.get_provider_or_raise(uuid4())
@pytest.mark.asyncio
async def test_get_by_name(provider_svc: ProviderService) -> None:
name = f"by-name-{uuid4().hex[:6]}"
row = await provider_svc.create_provider(
ProviderCreate(name=name, type=ModelProvider.ANTHROPIC)
)
found = await provider_svc.get_by_name(name)
assert found is not None
assert found.id == row.id
@pytest.mark.asyncio
async def test_list_providers_excludes_disabled_by_default(
provider_svc: ProviderService,
) -> None:
enabled = await provider_svc.create_provider(
ProviderCreate(
name=f"on-{uuid4().hex[:6]}", type=ModelProvider.ANTHROPIC, enabled=True
)
)
disabled = await provider_svc.create_provider(
ProviderCreate(
name=f"off-{uuid4().hex[:6]}", type=ModelProvider.LOCAL, enabled=False
)
)
visible = await provider_svc.list_providers()
visible_ids = {p.id for p in visible}
assert enabled.id in visible_ids
assert disabled.id not in visible_ids
@pytest.mark.asyncio
async def test_list_providers_include_disabled(provider_svc: ProviderService) -> None:
disabled = await provider_svc.create_provider(
ProviderCreate(
name=f"x-{uuid4().hex[:6]}", type=ModelProvider.LOCAL, enabled=False
)
)
every = await provider_svc.list_providers(include_disabled=True)
assert disabled.id in {p.id for p in every}
@pytest.mark.asyncio
async def test_update_provider_changes_name(provider_svc: ProviderService) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"old-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
)
new_name = f"new-{uuid4().hex[:6]}"
updated = await provider_svc.update_provider(
cast("uuid.UUID", row.id), ProviderUpdate(name=new_name)
)
assert updated is not None
assert updated.name == new_name
@pytest.mark.asyncio
async def test_update_provider_duplicate_name_raises(
provider_svc: ProviderService,
) -> None:
a = await provider_svc.create_provider(
ProviderCreate(name=f"a-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
)
b = await provider_svc.create_provider(
ProviderCreate(name=f"b-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
)
with pytest.raises(ConflictError):
await provider_svc.update_provider(
cast("uuid.UUID", b.id), ProviderUpdate(name=a.name)
)
@pytest.mark.asyncio
async def test_update_provider_clears_base_url_with_empty_string(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(
name=f"url-{uuid4().hex[:6]}",
type=ModelProvider.OLLAMA_CLOUD,
base_url="https://example.com",
)
)
updated = await provider_svc.update_provider(
cast("uuid.UUID", row.id), ProviderUpdate(base_url="")
)
assert updated is not None
assert updated.base_url is None
@pytest.mark.asyncio
async def test_update_provider_token_tristate_clear(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(
name=f"t-{uuid4().hex[:6]}",
type=ModelProvider.OLLAMA_CLOUD,
auth_token="initial",
)
)
updated = await provider_svc.update_provider(
cast("uuid.UUID", row.id), ProviderUpdate(clear_auth_token=True)
)
assert updated is not None
assert updated.auth_token_encrypted is None
@pytest.mark.asyncio
async def test_update_provider_token_tristate_set(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"s-{uuid4().hex[:6]}", type=ModelProvider.ANTHROPIC)
)
updated = await provider_svc.update_provider(
cast("uuid.UUID", row.id), ProviderUpdate(auth_token="new-secret")
)
assert updated is not None
assert updated.auth_token_encrypted is not None
@pytest.mark.asyncio
async def test_update_provider_token_tristate_unchanged(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(
name=f"u-{uuid4().hex[:6]}",
type=ModelProvider.ANTHROPIC,
auth_token="initial",
)
)
original_token = row.auth_token_encrypted
updated = await provider_svc.update_provider(
cast("uuid.UUID", row.id),
ProviderUpdate(enabled=False), # no auth_token field
)
assert updated is not None
assert updated.auth_token_encrypted == original_token # unchanged
assert updated.enabled is False
@pytest.mark.asyncio
async def test_update_provider_returns_none_for_missing(
provider_svc: ProviderService,
) -> None:
assert (
await provider_svc.update_provider(uuid4(), ProviderUpdate(enabled=False))
is None
)
@pytest.mark.asyncio
async def test_delete_provider(provider_svc: ProviderService) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"d-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
)
await provider_svc.delete_provider(cast("uuid.UUID", row.id))
assert await provider_svc.get_provider(cast("uuid.UUID", row.id)) is None
@pytest.mark.asyncio
async def test_delete_provider_raises_when_referenced(
db_session: AsyncSession, provider_svc: ProviderService
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"r-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
)
# Insert a model assignment that references this provider.
assignment = ModelAssignmentTable(
id=uuid4(),
scope="role",
scope_value="developer",
provider_config_id=row.id,
model_name="claude-haiku-4-5",
)
db_session.add(assignment)
await db_session.flush()
with pytest.raises(ConflictError):
await provider_svc.delete_provider(cast("uuid.UUID", row.id))
@pytest.mark.asyncio
async def test_get_decrypted_token_round_trip(provider_svc: ProviderService) -> None:
plaintext = "sk-roundtrip-secret"
row = await provider_svc.create_provider(
ProviderCreate(
name=f"rt-{uuid4().hex[:6]}",
type=ModelProvider.ANTHROPIC,
auth_token=plaintext,
)
)
decrypted = await provider_svc.get_decrypted_token(cast("uuid.UUID", row.id))
assert decrypted == plaintext
@pytest.mark.asyncio
async def test_get_decrypted_token_returns_none_when_unset(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"nt-{uuid4().hex[:6]}", type=ModelProvider.LOCAL)
)
assert await provider_svc.get_decrypted_token(cast("uuid.UUID", row.id)) is None
@pytest.mark.asyncio
async def test_get_decrypted_token_returns_none_for_missing_provider(
provider_svc: ProviderService,
) -> None:
assert await provider_svc.get_decrypted_token(uuid4()) is None
# ---------------------------------------------------------------------------
# Encryption error paths (lines 115-117, 162-164)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_provider_encrypt_failure_propagates(
provider_svc: ProviderService,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If encrypt_token fails, error is logged + raised."""
def _boom(_token: str) -> str:
raise EncryptionError("bad key")
monkeypatch.setattr(provider_module, "encrypt_token", _boom)
with pytest.raises(EncryptionError):
await provider_svc.create_provider(
ProviderCreate(
name=f"e-{uuid4().hex[:6]}",
type=ModelProvider.ANTHROPIC,
auth_token="some-token",
)
)
@pytest.mark.asyncio
async def test_update_provider_encrypt_failure_propagates(
provider_svc: ProviderService,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If encrypt_token fails on update, error propagates."""
row = await provider_svc.create_provider(
ProviderCreate(name=f"eu-{uuid4().hex[:6]}", type=ModelProvider.ANTHROPIC)
)
def _boom(_token: str) -> str:
raise EncryptionError("bad key")
monkeypatch.setattr(provider_module, "encrypt_token", _boom)
with pytest.raises(EncryptionError):
await provider_svc.update_provider(
cast("uuid.UUID", row.id), ProviderUpdate(auth_token="new-secret")
)
# ---------------------------------------------------------------------------
# Decryption error path (lines 218-224)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_decrypted_token_decrypt_failure_propagates(
provider_svc: ProviderService,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If decrypt_token raises, error propagates."""
row = await provider_svc.create_provider(
ProviderCreate(
name=f"dec-{uuid4().hex[:6]}",
type=ModelProvider.ANTHROPIC,
auth_token="initial",
)
)
def _boom(_blob: str) -> str:
raise EncryptionError("decrypt failed")
monkeypatch.setattr(provider_module, "decrypt_token", _boom)
with pytest.raises(EncryptionError):
await provider_svc.get_decrypted_token(cast("uuid.UUID", row.id))
# ---------------------------------------------------------------------------
# _apply_name_change identity branch (line 141)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_update_provider_same_name_is_noop(
provider_svc: ProviderService,
) -> None:
"""Updating to the same name short-circuits in _apply_name_change (line 141)."""
name = f"same-{uuid4().hex[:6]}"
row = await provider_svc.create_provider(
ProviderCreate(name=name, type=ModelProvider.ANTHROPIC)
)
updated = await provider_svc.update_provider(
cast("uuid.UUID", row.id), ProviderUpdate(name=name)
)
assert updated is not None
assert updated.name == name
# ---------------------------------------------------------------------------
# Factory (line 229)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_provider_service_factory(db_session: AsyncSession) -> None:
"""Factory returns a configured ProviderService."""
svc = get_provider_service(db_session)
assert isinstance(svc, ProviderService)