mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Quality Gates
This commit is contained in:
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user