Files
roboco/tests/integration/test_project_service.py
T
2026-05-05 03:19:43 +02:00

262 lines
8.5 KiB
Python

"""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