Quality Gates

This commit is contained in:
Renn F
2026-05-05 03:19:43 +02:00
parent 4829f93a68
commit 85ef124c8f
23 changed files with 2459 additions and 456 deletions
+139 -18
View File
@@ -72,8 +72,9 @@ class _StubGit:
message: str,
task_id: UUID,
files: list[str] | None = None,
actor_agent_id: Any = None,
) -> dict[str, Any]:
del branch_name, files
del branch_name, files, actor_agent_id
sha = uuid4().hex[:40]
commits = list(self._task.commits or [])
commits.append({"sha": sha, "message": message, "task_id": str(task_id)})
@@ -87,23 +88,38 @@ class _StubGit:
"deletions": 0,
}
async def push_branch(self, branch_name: str) -> tuple[str, int]:
del branch_name
async def push_branch(
self, branch_name: str, *, actor_agent_id: Any = None
) -> tuple[str, int]:
del branch_name, actor_agent_id
return ("ok", 0)
async def create_pr(
self, branch_name: str, *, parent: str, is_root_pr: bool
self,
branch_name: str,
*,
parent: str,
is_root_pr: bool,
actor_agent_id: Any = None,
) -> dict[str, Any]:
del branch_name, parent
del branch_name, parent, actor_agent_id
self._task.pr_number = _PR_NUMBER
self._task.pr_url = _PR_URL
# Mirrors git._record_pr_atomically — production sets pr_created
# via mark_pr_created which is what the parallel-completion gate
# in _maybe_advance_to_pm_review reads.
self._task.pr_created = True
await self._session.flush()
return {"pr_number": _PR_NUMBER, "pr_url": _PR_URL, "is_root_pr": is_root_pr}
async def diff(self, *, branch_name: str) -> str: # noqa: ARG002
async def diff(
self, *, branch_name: str, base: Any = None, actor_agent_id: Any = None
) -> str:
del branch_name, base, actor_agent_id
return "stub diff"
async def pr_target(self, pr_number: int) -> str: # noqa: ARG002
async def pr_target(self, pr_number: int, *, actor_agent_id: Any = None) -> str:
del pr_number, actor_agent_id
return "main"
async def pr_merge(self, **kwargs: Any) -> dict[str, Any]:
@@ -204,7 +220,33 @@ async def lifecycle_setup(
permissions={},
metrics={},
)
db_session.add_all([dev_agent, qa_agent])
doc_agent = AgentTable(
id=uuid4(),
name="BE Doc",
slug=f"be-doc-{uuid4().hex[:8]}",
role=AgentRole.DOCUMENTER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="doc",
capabilities=["docs"],
permissions={},
metrics={},
)
cell_pm_agent = AgentTable(
id=uuid4(),
name="BE Cell PM",
slug=f"be-pm-{uuid4().hex[:8]}",
role=AgentRole.CELL_PM,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="cell_pm",
capabilities=["coord"],
permissions={},
metrics={},
)
db_session.add_all([dev_agent, qa_agent, doc_agent, cell_pm_agent])
await db_session.flush()
task = TaskTable(
@@ -233,6 +275,8 @@ async def lifecycle_setup(
"project": project,
"dev_agent": dev_agent,
"qa_agent": qa_agent,
"doc_agent": doc_agent,
"cell_pm_agent": cell_pm_agent,
"task": task,
}
@@ -337,13 +381,90 @@ async def test_dev_full_chain_through_awaiting_qa(
assert final.self_verified is True, "P1-3: self_verified set by auto-verify"
# TODO P2-1 follow-up — extend the chain past awaiting_qa:
# - QA: claim_review → pass → awaiting_documentation
# - Documenter: claim_doc_task → i_documented → awaiting_pm_review
# - Cell PM: complete on the leaf → completed (or submit_up to a parent)
# - Main PM: complete on the root → awaiting_ceo_approval
# Each stage needs the role's agent seeded (lifecycle_setup already has
# dev + qa; add doc + cell_pm + main_pm) plus journal entries with the
# right scope (pass needs journal:learning; complete needs journal:decision).
# The _StubGit class above already covers commit/push/pr_create/pr_target
# /pr_merge for the merge stages.
@pytest.mark.asyncio
async def test_full_chain_through_doc_handoff(
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
) -> None:
"""Extend the dev chain: QA pass → documenter → awaiting_pm_review.
Verifies QA pass clears active_claimant_id (P1-4 + P1-5),
docs_complete transitions to awaiting_pm_review, and reassignment
to the cell PM happens on hand-off.
"""
task = lifecycle_setup["task"]
dev_agent = lifecycle_setup["dev_agent"]
qa_agent = lifecycle_setup["qa_agent"]
doc_agent = lifecycle_setup["doc_agent"]
cell_pm_agent = lifecycle_setup["cell_pm_agent"]
task_service = TaskService(db_session)
stub_git = _StubGit(db_session, task)
deps = ChoreographerDeps(
task=task_service,
work_session=_mock_work_session(),
git=stub_git,
a2a=AsyncMock(),
journal=_mock_journal_with_reflect(),
audit=AsyncMock(),
evidence_repo=_mock_evidence_repo(),
)
c = Choreographer(deps)
# Drive the dev side first (same as test_dev_full_chain_through_awaiting_qa).
await c.i_will_work_on(dev_agent.id, task.id, plan="add the route")
await stub_git.commit(
branch_name=_BRANCH,
message=f"[{str(task.id)[:8]}] feat(api): add /healthz",
task_id=task.id,
)
await task_service.add_progress(task.id, dev_agent.id, "implemented /healthz")
await c.submit_for_qa(dev_agent.id, task.id)
env = await c.i_am_done(dev_agent.id, task.id, "tests pass; route works")
assert env.error is None
assert env.status == "awaiting_qa"
# QA path: claim_review → pass.
env = await c.claim_review(qa_agent.id, task.id)
assert env.error is None, f"claim_review failed: {env.message}"
qa_notes = (
"Reviewed the diff; route returns 200 OK with timestamp. Tests cover "
"both acceptance criteria. Approving."
)
env = await c.pass_review(qa_agent.id, task.id, notes=qa_notes)
assert env.error is None, f"pass_review failed: {env.message}"
assert env.status == "awaiting_documentation"
after_qa = await task_service.get(task.id)
assert after_qa is not None
assert after_qa.active_claimant_id is None, (
"P1-4 + P1-5: QA pass must clear active_claimant_id for next role"
)
# Documenter path: claim_doc_task → i_documented.
env = await c.claim_doc_task(doc_agent.id, task.id)
assert env.error is None, f"claim_doc_task failed: {env.message}"
env = await c.i_documented(
doc_agent.id,
task.id,
notes="Documented /healthz behaviour in docs/api/health.md",
files=["docs/api/health.md"],
)
assert env.error is None, f"i_documented failed: {env.message}"
assert env.status == "awaiting_pm_review", (
"P2-1: i_documented must transition awaiting_documentation → awaiting_pm_review"
)
after_docs = await task_service.get(task.id)
assert after_docs is not None
assert after_docs.assigned_to == cell_pm_agent.id, (
"P2-1: docs_complete must reassign to the cell PM for the team"
)
# TODO P2-1 follow-up — final stages (cell_pm complete + main_pm complete +
# CEO approval) require additional setup: a parent task hierarchy for
# the merge chain, plus a real `git.pr_merge` simulation that updates
# the underlying repo. The _StubGit class covers the API surface; what's
# missing is the seeded parent task + main_pm agent.
@@ -0,0 +1,148 @@
"""P0-7 / S-01: branch creation atomicity.
When ``_ensure_branch_for_task`` raises (git checkout fails, push fails,
no token, etc.), ``_finalize_claim`` must roll back the claim fields it
just flushed otherwise the task is left CLAIMED with branch_name=NULL
and the next claim attempt collides on a non-idempotent
``git checkout -b``.
This test exercises the rollback path against a real Postgres session
by patching ``_ensure_branch_for_task`` to raise.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.models.base import (
AgentRole,
AgentStatus,
TaskNature,
TaskStatus,
TaskType,
Team,
)
from roboco.services.task import TaskService
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
@pytest_asyncio.fixture
async def claim_setup(db_session: AsyncSession) -> AsyncIterator[dict[str, Any]]:
system_agent = AgentTable(
id=uuid4(),
name="System",
slug=f"system-{uuid4().hex[:8]}",
role=AgentRole.SYSTEM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="system",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(system_agent)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="Atom Test",
slug=f"atom-{uuid4().hex[:8]}",
git_url="https://github.com/example/atom.git",
default_branch="main",
protected_branches=["main"],
assigned_cell=Team.BACKEND,
created_by=system_agent.id,
is_active=True,
)
db_session.add(project)
await db_session.flush()
dev = AgentTable(
id=uuid4(),
name="BE Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=["python"],
permissions={},
metrics={},
)
db_session.add(dev)
await db_session.flush()
task = TaskTable(
id=uuid4(),
title="Task that will fail at branch creation",
description="",
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
team=Team.BACKEND,
project_id=project.id,
created_by=system_agent.id,
assigned_to=dev.id,
acceptance_criteria=["does the thing"],
# No branch_name — claim path will try to create one.
)
db_session.add(task)
await db_session.flush()
yield {"task": task, "dev": dev, "project": project}
@pytest.mark.asyncio
async def test_finalize_claim_rolls_back_on_branch_failure(
db_session: AsyncSession, claim_setup: dict[str, Any]
) -> None:
"""git failure during _ensure_branch_for_task must revert claim fields.
Without rollback the task is left CLAIMED with branch_name=NULL and
`git checkout -b` is non-idempotent on retry.
"""
task = claim_setup["task"]
dev = claim_setup["dev"]
svc = TaskService(db_session)
# Snapshot pre-claim state so we can assert exact rollback.
pre_status = task.status
pre_assigned = task.assigned_to
pre_claimed_by = task.claimed_by
pre_claimed_at = task.claimed_at
pre_heartbeat = task.last_heartbeat_at
pre_claimant = task.active_claimant_id
async def boom(_self: Any, _task: Any, _agent_id: Any) -> str:
raise RuntimeError("simulated: git checkout -b failed")
with (
patch.object(TaskService, "_ensure_branch_for_task", boom),
pytest.raises(RuntimeError, match="git checkout -b failed"),
):
await svc.claim(task.id, dev.id)
# Re-read the task from a clean state via a fresh fetch.
refreshed = await svc.get(task.id)
assert refreshed is not None
assert refreshed.status == pre_status, "P0-7: status must roll back"
assert refreshed.assigned_to == pre_assigned, "P0-7: assigned_to must roll back"
assert refreshed.claimed_by == pre_claimed_by, "P0-7: claimed_by must roll back"
assert refreshed.claimed_at == pre_claimed_at, "P0-7: claimed_at must roll back"
assert refreshed.last_heartbeat_at == pre_heartbeat, (
"P0-7: heartbeat must roll back"
)
assert refreshed.active_claimant_id == pre_claimant, (
"P1-4 + P0-7: active_claimant_id must roll back too"
)
@@ -0,0 +1,149 @@
"""P2-8: startup orphan-claim reconciler.
The orchestrator's `_reconcile_orphan_claims_on_startup` rolls back
tasks left in CLAIMED/IN_PROGRESS with `branch_name IS NULL` the
half-state from a pre-P0-7 crash where `_finalize_claim` flushed
status=CLAIMED before branch creation failed.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.models.base import (
AgentRole,
AgentStatus,
TaskNature,
TaskStatus,
TaskType,
Team,
)
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.services.task import TaskService
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
@pytest_asyncio.fixture
async def orphan_setup(
db_session: AsyncSession,
) -> AsyncIterator[dict[str, Any]]:
system_agent = AgentTable(
id=uuid4(),
name="System",
slug=f"system-{uuid4().hex[:8]}",
role=AgentRole.SYSTEM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="system",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(system_agent)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="Reconciler Test",
slug=f"recon-{uuid4().hex[:8]}",
git_url="https://github.com/example/recon.git",
default_branch="main",
protected_branches=["main"],
assigned_cell=Team.BACKEND,
created_by=system_agent.id,
is_active=True,
)
db_session.add(project)
await db_session.flush()
dev = AgentTable(
id=uuid4(),
name="BE Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=["python"],
permissions={},
metrics={},
)
db_session.add(dev)
await db_session.flush()
# ORPHAN: status=CLAIMED, assigned_to=dev, branch_name=NULL.
orphan = TaskTable(
id=uuid4(),
title="Orphan from prior crash",
description="",
status=TaskStatus.CLAIMED,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
team=Team.BACKEND,
project_id=project.id,
created_by=system_agent.id,
assigned_to=dev.id,
claimed_by=dev.id,
acceptance_criteria=[""],
)
# HEALTHY: status=CLAIMED with branch — must NOT be rolled back.
healthy = TaskTable(
id=uuid4(),
title="Healthy claim",
description="",
status=TaskStatus.CLAIMED,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
team=Team.BACKEND,
project_id=project.id,
created_by=system_agent.id,
assigned_to=dev.id,
claimed_by=dev.id,
branch_name="feature/backend/healthy",
acceptance_criteria=[""],
)
db_session.add_all([orphan, healthy])
await db_session.flush()
yield {"orphan": orphan, "healthy": healthy, "dev": dev}
@pytest.mark.asyncio
async def test_reconciler_rolls_back_orphan_claims(
db_session: AsyncSession, orphan_setup: dict[str, Any]
) -> None:
"""CLAIMED task with branch_name=NULL → reconciled to PENDING."""
orphan = orphan_setup["orphan"]
healthy = orphan_setup["healthy"]
svc = TaskService(db_session)
orch = AgentOrchestrator.__new__(AgentOrchestrator)
# Drive the logic directly via the test-injectable helper so we avoid
# the orchestrator's session-factory dance.
await orch._reconcile_with_service(svc)
refreshed_orphan = await svc.get(orphan.id)
refreshed_healthy = await svc.get(healthy.id)
assert refreshed_orphan is not None
assert str(refreshed_orphan.status) == "pending", (
"P2-8: orphan must be rolled back to pending"
)
assert refreshed_orphan.assigned_to is None
assert refreshed_orphan.active_claimant_id is None
# Healthy claim untouched.
assert refreshed_healthy is not None
assert str(refreshed_healthy.status) == "claimed"
assert refreshed_healthy.branch_name == "feature/backend/healthy"
+261
View File
@@ -0,0 +1,261 @@
"""ProjectService coverage — register/list/update/delete + token round-trip.
Driven by the real Postgres ``db_session`` fixture so the test exercises
the same SQLAlchemy paths the production code does.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import AgentTable
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.project import ProjectCreate, ProjectUpdate
from roboco.services.base import ConflictError, NotFoundError
from roboco.services.project import ProjectService
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
@pytest_asyncio.fixture
async def project_setup(
db_session: AsyncSession,
) -> AsyncIterator[dict]:
"""Seed a system agent so created_by FK is satisfied."""
system = AgentTable(
id=uuid4(),
name="System",
slug=f"system-{uuid4().hex[:8]}",
role=AgentRole.SYSTEM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="system",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(system)
await db_session.flush()
svc = ProjectService(db_session)
yield {"svc": svc, "creator_id": system.id}
def _project_payload(slug_suffix: str) -> ProjectCreate:
return ProjectCreate(
name=f"Project {slug_suffix}",
slug=f"proj-{slug_suffix}",
git_url=f"https://github.com/example/{slug_suffix}.git",
assigned_cell=Team.BACKEND,
)
@pytest.mark.asyncio
async def test_create_project(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
assert project.id is not None
@pytest.mark.asyncio
async def test_create_project_with_git_token_encrypts(project_setup: dict) -> None:
svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6])
payload_dict = payload.model_dump()
payload_dict["git_token"] = "ghp_test_token"
project = await svc.create(
ProjectCreate(**payload_dict), project_setup["creator_id"]
)
assert project.git_token_encrypted is not None
assert project.git_token_encrypted != "ghp_test_token"
@pytest.mark.asyncio
async def test_create_project_duplicate_slug_raises(project_setup: dict) -> None:
svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6])
await svc.create(payload, project_setup["creator_id"])
with pytest.raises(ConflictError):
await svc.create(payload, project_setup["creator_id"])
@pytest.mark.asyncio
async def test_get_returns_project(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
fetched = await svc.get(project.id)
assert fetched is not None
assert fetched.id == project.id
@pytest.mark.asyncio
async def test_get_returns_none_for_missing(project_setup: dict) -> None:
svc = project_setup["svc"]
assert await svc.get(uuid4()) is None
@pytest.mark.asyncio
async def test_get_by_slug(project_setup: dict) -> None:
svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6])
created = await svc.create(payload, project_setup["creator_id"])
fetched = await svc.get_by_slug(payload.slug)
assert fetched is not None
assert fetched.id == created.id
@pytest.mark.asyncio
async def test_get_or_raise_raises(project_setup: dict) -> None:
svc = project_setup["svc"]
with pytest.raises(NotFoundError):
await svc.get_or_raise(uuid4())
@pytest.mark.asyncio
async def test_update_changes_name(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
new_name = f"renamed-{uuid4().hex[:6]}"
updated = await svc.update(project.id, ProjectUpdate(name=new_name))
assert updated is not None
assert updated.name == new_name
@pytest.mark.asyncio
async def test_update_clear_git_token(project_setup: dict) -> None:
svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6])
pd = payload.model_dump()
pd["git_token"] = "ghp_initial"
project = await svc.create(ProjectCreate(**pd), project_setup["creator_id"])
assert project.git_token_encrypted is not None
updated = await svc.update(project.id, ProjectUpdate(git_token=""))
assert updated is not None
assert updated.git_token_encrypted is None
@pytest.mark.asyncio
async def test_update_set_git_token(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
updated = await svc.update(project.id, ProjectUpdate(git_token="ghp_new"))
assert updated is not None
assert updated.git_token_encrypted is not None
@pytest.mark.asyncio
async def test_update_returns_none_for_missing(project_setup: dict) -> None:
svc = project_setup["svc"]
assert (await svc.update(uuid4(), ProjectUpdate(name="ghost"))) is None
@pytest.mark.asyncio
async def test_delete_project(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
await svc.delete(project.id)
assert await svc.get(project.id) is None
@pytest.mark.asyncio
async def test_list_all(project_setup: dict) -> None:
svc = project_setup["svc"]
a = await svc.create(_project_payload(uuid4().hex[:6]), project_setup["creator_id"])
b = await svc.create(_project_payload(uuid4().hex[:6]), project_setup["creator_id"])
rows = await svc.list_all()
ids = {p.id for p in rows}
assert a.id in ids
assert b.id in ids
@pytest.mark.asyncio
async def test_list_by_cell(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
rows = await svc.list_by_cell(Team.BACKEND)
assert project.id in {p.id for p in rows}
@pytest.mark.asyncio
async def test_set_workspace_path(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
updated = await svc.set_workspace_path(project.id, "/tmp/test-ws")
assert updated is not None
assert updated.workspace_path == "/tmp/test-ws"
@pytest.mark.asyncio
async def test_get_decrypted_token_round_trip(project_setup: dict) -> None:
svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6])
pd = payload.model_dump()
pd["git_token"] = "ghp_secret"
project = await svc.create(ProjectCreate(**pd), project_setup["creator_id"])
decrypted = await svc.get_decrypted_token(project.id)
assert decrypted == "ghp_secret"
@pytest.mark.asyncio
async def test_get_decrypted_token_returns_none_when_unset(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
assert await svc.get_decrypted_token(project.id) is None
@pytest.mark.asyncio
async def test_get_decrypted_token_by_slug_round_trip(
project_setup: dict,
) -> None:
svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6])
pd = payload.model_dump()
pd["git_token"] = "ghp_slug_secret"
await svc.create(ProjectCreate(**pd), project_setup["creator_id"])
decrypted = await svc.get_decrypted_token_by_slug(payload.slug)
assert decrypted == "ghp_slug_secret"
@pytest.mark.asyncio
async def test_check_agent_access_returns_bool(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
has_access = await svc.check_agent_access(project.id, uuid4(), Team.BACKEND)
assert isinstance(has_access, bool)
@pytest.mark.asyncio
async def test_add_and_remove_allowed_agent(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
new_agent_id = uuid4()
added = await svc.add_allowed_agent(project.id, new_agent_id)
assert added is not None
removed = await svc.remove_allowed_agent(project.id, new_agent_id)
assert removed is not None
+303
View File
@@ -0,0 +1,303 @@
"""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
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import ModelAssignmentTable
from roboco.models.base import ModelProvider
from roboco.services.base import ConflictError, NotFoundError
from roboco.services.provider import (
ProviderCreate,
ProviderService,
ProviderUpdate,
)
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(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(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(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(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(
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(
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(
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(row.id)
assert await provider_svc.get_provider(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(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(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(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