Files
roboco/tests/unit/services/test_pitch_service.py
T

269 lines
9.1 KiB
Python
Raw Normal View History

"""roboco.services.pitch — CRUD + approve/reject orchestration (mocked deps).
The approve path constructs real domain models (ProjectCreate, ProductCellMapping,
TaskCreateRequest) but the downstream services and the GitHub provisioner are
faked, so the test exercises the orchestration logic without a DB or network.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.db.tables import PitchTable
from roboco.foundation.identity import Team
from roboco.models.pitch import PitchCreate, PitchStatus
from roboco.services import pitch as pitch_module
from roboco.services.base import ConflictError
from roboco.services.github_provisioning import (
GitHubProvisioningService,
ProvisionedRepo,
ProvisioningDisabledError,
)
from roboco.services.pitch import PitchService
def _session() -> MagicMock:
s = MagicMock()
s.add = MagicMock()
s.flush = AsyncMock()
return s
def _pitch(**kw: Any) -> PitchTable:
defaults: dict[str, Any] = {
"id": uuid4(),
"title": "Widget",
"slug": "widget",
"problem": "people need widgets",
"proposed_solution": "build a widget service",
"target_cells": ["backend"],
"status": "proposed",
"created_by": uuid4(),
}
defaults.update(kw)
return PitchTable(**defaults)
class _FakeProvisioning(GitHubProvisioningService):
def __init__(self, *, enabled: bool = True) -> None:
self._enabled = enabled
self.created: list[str] = []
@property
def enabled(self) -> bool:
return self._enabled
async def create_repo(
self, name: str, description: str = "", *, private: bool = True
) -> ProvisionedRepo:
_ = (description, private)
self.created.append(name)
return ProvisionedRepo(
full_name=f"org/{name}",
clone_url=f"https://github.com/org/{name}.git",
html_url=f"https://github.com/org/{name}",
)
async def close(self) -> None:
return None
def _patch_topology(monkeypatch: pytest.MonkeyPatch) -> dict[str, MagicMock]:
proj = MagicMock()
proj.id = uuid4()
project_svc = MagicMock()
project_svc.create = AsyncMock(return_value=proj)
2026-06-26 01:43:08 +02:00
# Default: nothing pre-exists, so provisioning takes the create path. The
# idempotency tests override these to return an existing row.
project_svc.get_by_slug = AsyncMock(return_value=None)
monkeypatch.setattr(pitch_module, "get_project_service", lambda _s: project_svc)
prod = MagicMock()
prod.id = uuid4()
product_svc = MagicMock()
product_svc.create = AsyncMock(return_value=prod)
2026-06-26 01:43:08 +02:00
product_svc.get_by_slug = AsyncMock(return_value=None)
product_svc.update = AsyncMock(return_value=prod)
monkeypatch.setattr(pitch_module, "get_product_service", lambda _s: product_svc)
task = MagicMock()
task.id = uuid4()
task_svc = MagicMock()
task_svc.create = AsyncMock(return_value=task)
monkeypatch.setattr(pitch_module, "get_task_service", lambda _s: task_svc)
main_pm = MagicMock()
main_pm.id = uuid4()
agent_svc = MagicMock()
agent_svc.get_by_slug = AsyncMock(return_value=main_pm)
monkeypatch.setattr(pitch_module, "get_agent_service", lambda _s: agent_svc)
return {"project": project_svc, "product": product_svc, "task": task_svc}
@pytest.mark.asyncio
async def test_create_persists(monkeypatch: pytest.MonkeyPatch) -> None:
session = _session()
svc = PitchService(session)
monkeypatch.setattr(svc, "get_by_slug", AsyncMock(return_value=None))
pitch = await svc.create(
PitchCreate(
title="Widget",
slug="widget",
problem="p",
proposed_solution="s",
target_cells=[Team.BACKEND, Team.FRONTEND],
),
created_by=uuid4(),
)
assert pitch.slug == "widget"
assert pitch.status == PitchStatus.PROPOSED.value
assert pitch.target_cells == ["backend", "frontend"]
session.add.assert_called_once()
session.flush.assert_awaited_once()
@pytest.mark.asyncio
async def test_create_conflict_on_duplicate_slug(
monkeypatch: pytest.MonkeyPatch,
) -> None:
svc = PitchService(_session())
monkeypatch.setattr(svc, "get_by_slug", AsyncMock(return_value=_pitch()))
with pytest.raises(ConflictError):
await svc.create(
PitchCreate(
title="Widget",
slug="widget",
problem="p",
proposed_solution="s",
target_cells=[Team.BACKEND],
),
created_by=uuid4(),
)
@pytest.mark.asyncio
async def test_reject_sets_status(monkeypatch: pytest.MonkeyPatch) -> None:
svc = PitchService(_session())
pitch = _pitch()
monkeypatch.setattr(svc, "get", AsyncMock(return_value=pitch))
result = await svc.reject(pitch.id, "not aligned with the charter", uuid4())
assert result.status == PitchStatus.REJECTED.value
assert result.decision_notes == "not aligned with the charter"
@pytest.mark.asyncio
async def test_approve_single_cell_provisions_project_and_task(
monkeypatch: pytest.MonkeyPatch,
) -> None:
svc = PitchService(_session())
pitch = _pitch(target_cells=["backend"])
monkeypatch.setattr(svc, "get", AsyncMock(return_value=pitch))
svcs = _patch_topology(monkeypatch)
prov = _FakeProvisioning(enabled=True)
result = await svc.approve(
pitch.id, "approved for build", uuid4(), provisioning=prov
)
assert result.status == PitchStatus.PROVISIONED.value
assert result.seed_task_id is not None
assert result.provisioned_project_ids is not None
assert len(result.provisioned_project_ids) == len(pitch.target_cells)
assert result.provisioned_product_id is None
assert prov.created == ["widget"]
svcs["product"].create.assert_not_called()
svcs["task"].create.assert_awaited_once()
@pytest.mark.asyncio
async def test_approve_multi_cell_creates_product(
monkeypatch: pytest.MonkeyPatch,
) -> None:
svc = PitchService(_session())
pitch = _pitch(slug="multi", target_cells=["backend", "frontend"])
monkeypatch.setattr(svc, "get", AsyncMock(return_value=pitch))
svcs = _patch_topology(monkeypatch)
prov = _FakeProvisioning(enabled=True)
result = await svc.approve(pitch.id, "approved", uuid4(), provisioning=prov)
assert result.provisioned_product_id is not None
assert result.provisioned_project_ids is not None
assert len(result.provisioned_project_ids) == len(pitch.target_cells)
assert prov.created == ["multi-backend", "multi-frontend"]
svcs["product"].create.assert_awaited_once()
@pytest.mark.asyncio
async def test_approve_rejects_when_not_proposed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
svc = PitchService(_session())
monkeypatch.setattr(
svc, "get", AsyncMock(return_value=_pitch(status="provisioned"))
)
with pytest.raises(ConflictError):
await svc.approve(uuid4(), "x", uuid4(), provisioning=_FakeProvisioning())
@pytest.mark.asyncio
async def test_approve_blocked_when_provisioning_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
svc = PitchService(_session())
monkeypatch.setattr(svc, "get", AsyncMock(return_value=_pitch()))
with pytest.raises(ProvisioningDisabledError):
await svc.approve(
uuid4(), "x", uuid4(), provisioning=_FakeProvisioning(enabled=False)
)
2026-06-26 01:43:08 +02:00
@pytest.mark.asyncio
async def test_approve_reuses_existing_project(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Re-approval after a partial provision reuses a committed Project by slug —
no repo re-create, no slug collision (idempotent provisioning)."""
svcs = _patch_topology(monkeypatch)
existing = MagicMock()
existing.id = uuid4()
svcs["project"].get_by_slug = AsyncMock(return_value=existing)
svc = PitchService(_session())
monkeypatch.setattr(
svc, "get", AsyncMock(return_value=_pitch(target_cells=["backend"]))
)
prov = _FakeProvisioning()
await svc.approve(
uuid4(), "ship it, aligned with the charter", uuid4(), provisioning=prov
)
assert prov.created == [] # repo NOT re-created
svcs["project"].create.assert_not_awaited() # Project reused, not re-created
@pytest.mark.asyncio
async def test_approve_reuses_existing_product(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Multi-cell re-approval reuses the committed Product and refreshes its cell
map (no uq_product_projects_product_team collision)."""
svcs = _patch_topology(monkeypatch)
existing = MagicMock()
existing.id = uuid4()
svcs["product"].get_by_slug = AsyncMock(return_value=existing)
svc = PitchService(_session())
monkeypatch.setattr(
svc, "get", AsyncMock(return_value=_pitch(target_cells=["backend", "frontend"]))
)
await svc.approve(
uuid4(),
"ship it, aligned with the charter",
uuid4(),
provisioning=_FakeProvisioning(),
)
svcs["product"].update.assert_awaited_once() # reused + cell map refreshed
svcs["product"].create.assert_not_awaited() # NOT re-created