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>
423 lines
13 KiB
Python
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)
|