Files
roboco/tests/unit/services/test_pitch_service.py
T
5612375cba Feat/v0.13.0 (#270)
* feat(release): add release-manager feature flag (default off)

* feat(release): change classification + semver-bump derivation

* feat(release): readiness audit (changelog/version-ref/docs/migration/gate)

* feat(release): release-manager engine proposes a gated release

* feat(release): fail-closed release executor (bump, gate, publish)

* feat(release): CEO approve/reject release-proposal surface

* docs(release): document the gated release manager

* feat(memory): add org-memory feature flags (default off)

* feat(memory): add playbooks table + status enum + migration

* feat(memory): playbook service with auditor curation transitions

* feat(memory): playbooks RAG index plugin

* feat(memory): index a playbook into RAG on approval

* feat(memory): distill a high-signal lesson at task completion

* feat(memory): keep private journal reflections out of the shared RAG corpus

* feat(memory): draft_playbook verb + auditor curation verbs

* fix(ci): resolve mypy tests/ errors blocking the gate (UUID casts, annotations)

* feat(memory): auto-inject similar lessons/playbooks into the briefing

* feat(memory): auditor playbook review queue (api + panel)

* docs(memory): document the org-memory loop + playbook verbs

* fix(provisioning): idempotent pitch provisioning (reuse product/project by slug on re-approval)

* fix(memory): add chunks_playbooks to the chunk schema + isolate release route tests

- Migration 030's CHUNK_TABLES was missing chunks_playbooks, breaking the
  IndexType<->migration parity guard once the PLAYBOOKS index landed. The
  upgrade is ALTER ... IF EXISTS so adding it is safe on any DB shape.
- The release-route fixture's approve/reject paths call db.commit() (real
  behavior), so a held proposal outlived the per-test rollback and leaked
  into engine tests that read the global list_open_release_proposals().
  Tear down source=release_manager rows after each test.
- Make the gather_snapshot real-repo smoke version-agnostic (semver match)
  so it stops pinning the literal repo version.

* chore(release): 0.13.0

* ++

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-26 01:43:08 +02:00

269 lines
9.1 KiB
Python

"""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)
# 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)
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)
)
@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