Files
roboco/tests/integration/test_playbook_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

146 lines
5.2 KiB
Python

"""PlaybookService — draft + Auditor curation transitions (real Postgres)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from roboco.config import settings as cfg
from roboco.models.base import PlaybookStatus
from roboco.models.playbook import PlaybookCreate
from roboco.services.base import ConflictError, NotFoundError
from roboco.services.playbook import PlaybookService
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
def _create(title: str = "Retry a flaky pg test", **kw: Any) -> PlaybookCreate:
base: dict[str, Any] = {
"title": title,
"problem": "A pg integration test fails intermittently on connection reset.",
"procedure": "1. Wrap the fixture in a retry.\n2. Assert idempotency.",
"tags": ["backend"],
"scope": "org",
}
base.update(kw)
return PlaybookCreate(**base)
@pytest.mark.asyncio
async def test_draft_creates_a_draft_with_derived_slug(
db_session: AsyncSession,
) -> None:
svc = PlaybookService(db_session)
pb = await svc.draft(_create(title="Retry Flaky PG!"), created_by=uuid4())
assert pb.status == PlaybookStatus.DRAFT
assert pb.slug == "retry-flaky-pg"
@pytest.mark.asyncio
async def test_draft_then_approve_flips_status_and_stamps(
db_session: AsyncSession,
) -> None:
svc = PlaybookService(db_session)
auditor = uuid4()
pb = await svc.draft(_create(), created_by=uuid4())
approved = await svc.approve(pb.id, approver_id=auditor)
assert approved.status == PlaybookStatus.APPROVED
assert approved.approved_by == auditor
assert approved.approved_at is not None
@pytest.mark.asyncio
async def test_reject_archives(db_session: AsyncSession) -> None:
svc = PlaybookService(db_session)
pb = await svc.draft(_create(), created_by=uuid4())
out = await svc.reject(
pb.id, approver_id=uuid4(), reason="duplicate of an existing one"
)
assert out.status == PlaybookStatus.ARCHIVED
@pytest.mark.asyncio
async def test_list_drafts_and_approved_partition(db_session: AsyncSession) -> None:
svc = PlaybookService(db_session)
d = await svc.draft(_create(title="Draft one"), created_by=uuid4())
a = await svc.draft(_create(title="Approved one"), created_by=uuid4())
await svc.approve(a.id, approver_id=uuid4())
draft_ids = {p.id for p in await svc.list_drafts()}
approved_ids = {p.id for p in await svc.list_approved()}
assert d.id in draft_ids and d.id not in approved_ids
assert a.id in approved_ids and a.id not in draft_ids
@pytest.mark.asyncio
async def test_duplicate_slug_raises_conflict(db_session: AsyncSession) -> None:
svc = PlaybookService(db_session)
await svc.draft(_create(title="Same Title"), created_by=uuid4())
with pytest.raises(ConflictError):
await svc.draft(_create(title="Same Title"), created_by=uuid4())
@pytest.mark.asyncio
async def test_approve_missing_raises_notfound(db_session: AsyncSession) -> None:
svc = PlaybookService(db_session)
with pytest.raises(NotFoundError):
await svc.approve(uuid4(), approver_id=uuid4())
@pytest.mark.asyncio
async def test_source_task_id_is_recorded(db_session: AsyncSession) -> None:
svc = PlaybookService(db_session)
task_id = uuid4()
pb = await svc.draft(_create(source_task_id=task_id), created_by=uuid4())
assert str(task_id) in pb.source_task_ids
@pytest.mark.asyncio
async def test_approve_indexes_when_org_memory_on(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(cfg, "org_memory_enabled", True)
fake_optimal = AsyncMock()
fake_optimal.index_playbook = AsyncMock()
monkeypatch.setattr(
"roboco.services.optimal.get_optimal_service",
AsyncMock(return_value=fake_optimal),
)
svc = PlaybookService(db_session)
pb = await svc.draft(_create(title="Index me"), created_by=uuid4())
await svc.approve(pb.id, approver_id=uuid4())
fake_optimal.index_playbook.assert_awaited_once()
@pytest.mark.asyncio
async def test_approve_does_not_index_when_off(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(cfg, "org_memory_enabled", False)
getter = AsyncMock()
monkeypatch.setattr("roboco.services.optimal.get_optimal_service", getter)
svc = PlaybookService(db_session)
pb = await svc.draft(_create(title="Do not index"), created_by=uuid4())
await svc.approve(pb.id, approver_id=uuid4())
getter.assert_not_awaited()
@pytest.mark.asyncio
async def test_approve_survives_index_failure(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(cfg, "org_memory_enabled", True)
fake_optimal = AsyncMock()
fake_optimal.index_playbook = AsyncMock(side_effect=RuntimeError("ollama down"))
monkeypatch.setattr(
"roboco.services.optimal.get_optimal_service",
AsyncMock(return_value=fake_optimal),
)
svc = PlaybookService(db_session)
pb = await svc.draft(_create(title="Resilient"), created_by=uuid4())
approved = await svc.approve(pb.id, approver_id=uuid4()) # must not raise
assert approved.status == PlaybookStatus.APPROVED