2026-07-25 05:39:44 +02:00
|
|
|
"""Board Programs API route coverage — CEO-only list + run-now."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from http import HTTPStatus
|
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
|
from uuid import UUID, uuid4
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
import pytest_asyncio
|
|
|
|
|
from fastapi import FastAPI
|
|
|
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
|
from roboco.api.deps import get_agent_context, get_db
|
|
|
|
|
from roboco.api.routes.board_programs import router as board_programs_router
|
|
|
|
|
from roboco.config import settings as cfg
|
|
|
|
|
from roboco.db.tables import (
|
|
|
|
|
AgentTable,
|
|
|
|
|
BoardProgramCycleTable,
|
|
|
|
|
ProjectTable,
|
|
|
|
|
SystemSettingTable,
|
|
|
|
|
TaskTable,
|
|
|
|
|
)
|
|
|
|
|
from roboco.foundation import identity as _foundation
|
|
|
|
|
from roboco.models import AgentRole, AgentStatus, TaskStatus, Team
|
|
|
|
|
from roboco.models.permissions import AgentContext
|
2026-07-25 17:13:32 +02:00
|
|
|
from roboco.services.task import (
|
|
|
|
|
BARFLY_SOURCE,
|
|
|
|
|
CORONER_SOURCE,
|
|
|
|
|
DOGFOOD_SOURCE,
|
|
|
|
|
LIBRARIAN_SOURCE,
|
|
|
|
|
MEGAPHONE_SOURCE,
|
|
|
|
|
MIRROR_SOURCE,
|
|
|
|
|
PERISCOPE_SOURCE,
|
|
|
|
|
PEST_CONTROL_SOURCE,
|
|
|
|
|
ROADMAP_SOURCE,
|
|
|
|
|
SCALES_SOURCE,
|
|
|
|
|
SENTINEL_SOURCE,
|
|
|
|
|
SPACKLE_SOURCE,
|
|
|
|
|
WAR_ROOM_SOURCE,
|
|
|
|
|
X_FEATURE_EXPLORATION_SOURCE,
|
|
|
|
|
)
|
|
|
|
|
from roboco.services.x_credentials import get_x_credentials_service
|
2026-07-25 05:39:44 +02:00
|
|
|
from sqlalchemy import delete, update
|
|
|
|
|
|
|
|
|
|
CEO_UUID = _foundation.AGENTS["ceo"].uuid
|
|
|
|
|
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
|
|
|
|
PO_UUID = _foundation.AGENTS["product-owner"].uuid
|
2026-07-25 17:13:32 +02:00
|
|
|
HOM_UUID = _foundation.AGENTS["head-marketing"].uuid
|
2026-07-25 05:39:44 +02:00
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from collections.abc import AsyncIterator
|
|
|
|
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _seed_agents(session: AsyncSession) -> None:
|
|
|
|
|
for uuid, slug, role in (
|
|
|
|
|
(CEO_UUID, "ceo", AgentRole.CEO),
|
|
|
|
|
(SYSTEM_UUID, "system", AgentRole.SYSTEM),
|
|
|
|
|
(PO_UUID, "product-owner", AgentRole.PRODUCT_OWNER),
|
2026-07-25 17:13:32 +02:00
|
|
|
(HOM_UUID, "head-marketing", AgentRole.HEAD_MARKETING),
|
2026-07-25 05:39:44 +02:00
|
|
|
):
|
|
|
|
|
if await session.get(AgentTable, uuid) is not None:
|
|
|
|
|
continue
|
|
|
|
|
session.add(
|
|
|
|
|
AgentTable(
|
|
|
|
|
id=uuid,
|
|
|
|
|
name=slug,
|
|
|
|
|
slug=slug,
|
|
|
|
|
role=role,
|
|
|
|
|
team=None,
|
|
|
|
|
status=AgentStatus.ACTIVE,
|
|
|
|
|
model_config={},
|
|
|
|
|
system_prompt="x",
|
|
|
|
|
capabilities=[],
|
|
|
|
|
permissions={},
|
|
|
|
|
metrics={},
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
await session.flush()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _arm_roadmap(session: AsyncSession, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
"""Arms roadmap two ways: the new per-program settings-store key (what
|
|
|
|
|
Task 7 makes writable, consulted by ``BoardProgramEngine.enabled``) AND
|
|
|
|
|
the legacy ``roadmap_engine_enabled`` config flag — ``RoadmapEngine.
|
|
|
|
|
run_cycle`` (the migrated originator itself, unchanged since before the
|
|
|
|
|
registry existed) independently checks the legacy flag and no-ops
|
|
|
|
|
without it, regardless of the registry-level settings-store override.
|
|
|
|
|
|
|
|
|
|
Also seeds the project ``RoadmapEngine._roboco_project`` resolves against
|
|
|
|
|
(a unique slug per call + a matching ``self_heal_project_slug`` override —
|
|
|
|
|
``db_session`` is a real, cross-test-persistent database within one
|
|
|
|
|
pytest run, so a fixed slug like "roboco-api" would collide the second
|
|
|
|
|
a sibling test also arms roadmap)."""
|
|
|
|
|
monkeypatch.setattr(cfg, "roadmap_engine_enabled", True)
|
|
|
|
|
key = "board_program.roadmap.enabled"
|
|
|
|
|
existing = await session.get(SystemSettingTable, key)
|
|
|
|
|
if existing is None:
|
|
|
|
|
session.add(SystemSettingTable(key=key, value="true"))
|
|
|
|
|
else:
|
|
|
|
|
existing.value = "true"
|
|
|
|
|
|
|
|
|
|
slug = f"roboco-api-{uuid4().hex[:8]}"
|
|
|
|
|
monkeypatch.setattr(cfg, "self_heal_project_slug", slug)
|
|
|
|
|
session.add(
|
|
|
|
|
ProjectTable(
|
|
|
|
|
id=uuid4(),
|
|
|
|
|
name="RoboCo",
|
|
|
|
|
slug=slug,
|
|
|
|
|
git_url="https://example.com/roboco.git",
|
|
|
|
|
assigned_cell=Team.BACKEND,
|
|
|
|
|
created_by=SYSTEM_UUID,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
await session.flush()
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 17:13:32 +02:00
|
|
|
async def _arm_war_room(session: AsyncSession, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
"""Arms war_room via its settings-store key (no legacy flag exists for
|
|
|
|
|
it — ``program_armed``'s ``_legacy_enabled`` returns False for an
|
|
|
|
|
unregistered legacy alias), seeds the project ``WarRoomEngine.
|
|
|
|
|
_roboco_project`` resolves against (mirrors ``_arm_roadmap``'s unique-
|
|
|
|
|
slug-per-call rationale), and seeds X credentials — ``WarRoomEngine``'s
|
|
|
|
|
own creds gate would otherwise no-op every call, mirroring XEngine's
|
|
|
|
|
release/spotlight guard."""
|
|
|
|
|
key = "board_program.war_room.enabled"
|
|
|
|
|
existing = await session.get(SystemSettingTable, key)
|
|
|
|
|
if existing is None:
|
|
|
|
|
session.add(SystemSettingTable(key=key, value="true"))
|
|
|
|
|
else:
|
|
|
|
|
existing.value = "true"
|
|
|
|
|
|
|
|
|
|
slug = f"roboco-api-{uuid4().hex[:8]}"
|
|
|
|
|
monkeypatch.setattr(cfg, "self_heal_project_slug", slug)
|
|
|
|
|
session.add(
|
|
|
|
|
ProjectTable(
|
|
|
|
|
id=uuid4(),
|
|
|
|
|
name="RoboCo",
|
|
|
|
|
slug=slug,
|
|
|
|
|
git_url="https://example.com/roboco.git",
|
|
|
|
|
assigned_cell=Team.BACKEND,
|
|
|
|
|
created_by=SYSTEM_UUID,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
await session.flush()
|
|
|
|
|
|
|
|
|
|
await get_x_credentials_service(session).set_credentials(
|
|
|
|
|
api_key="ak-test",
|
|
|
|
|
api_secret="as-test",
|
|
|
|
|
access_token="at-test",
|
|
|
|
|
access_token_secret="ats-test",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 05:39:44 +02:00
|
|
|
def _build_app(db_session: AsyncSession, role: AgentRole, agent_id: UUID) -> FastAPI:
|
|
|
|
|
app = FastAPI()
|
|
|
|
|
app.include_router(board_programs_router, prefix="/api/board-programs")
|
|
|
|
|
|
|
|
|
|
async def _override_db() -> AsyncIterator[AsyncSession]:
|
|
|
|
|
yield db_session
|
|
|
|
|
|
|
|
|
|
async def _override_agent() -> AgentContext:
|
|
|
|
|
return AgentContext(agent_id=agent_id, role=role, team=None)
|
|
|
|
|
|
|
|
|
|
app.dependency_overrides[get_db] = _override_db
|
|
|
|
|
app.dependency_overrides[get_agent_context] = _override_agent
|
|
|
|
|
return app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
|
|
|
async def ceo_client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
|
|
|
|
|
await _seed_agents(db_session)
|
|
|
|
|
app = _build_app(db_session, AgentRole.CEO, CEO_UUID)
|
|
|
|
|
transport = ASGITransport(app=app)
|
|
|
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
|
|
|
yield client
|
|
|
|
|
app.dependency_overrides.clear()
|
|
|
|
|
# run-now's route handler commits explicitly (write-route convention), so
|
|
|
|
|
# anything a test wrote through it (settings-store overrides, an opened
|
2026-07-25 17:13:32 +02:00
|
|
|
# ledger row, the board_roadmap/x_feature_exploration/board_pest_control
|
|
|
|
|
# task it originates) would otherwise outlive this test in the shared,
|
|
|
|
|
# cross-test-persistent DB and poison every later real-DB board-program
|
|
|
|
|
# unit test (dedup checks, settings-store PK collisions, ledger
|
|
|
|
|
# scalar_one() lookups) — not just roadmap's, so every registered
|
|
|
|
|
# program's exploration source is swept here, not only the one this
|
|
|
|
|
# module's own tests happen to exercise today. Purge unconditionally — a
|
|
|
|
|
# no-op for the tests here that never wrote anything.
|
2026-07-25 05:39:44 +02:00
|
|
|
await db_session.execute(
|
|
|
|
|
delete(SystemSettingTable).where(SystemSettingTable.key.like("board_program.%"))
|
|
|
|
|
)
|
|
|
|
|
await db_session.execute(delete(BoardProgramCycleTable))
|
|
|
|
|
await db_session.execute(
|
|
|
|
|
update(TaskTable)
|
|
|
|
|
.where(
|
2026-07-25 17:13:32 +02:00
|
|
|
TaskTable.source.in_(
|
|
|
|
|
[
|
|
|
|
|
ROADMAP_SOURCE,
|
|
|
|
|
X_FEATURE_EXPLORATION_SOURCE,
|
|
|
|
|
PEST_CONTROL_SOURCE,
|
|
|
|
|
PERISCOPE_SOURCE,
|
|
|
|
|
CORONER_SOURCE,
|
|
|
|
|
SENTINEL_SOURCE,
|
|
|
|
|
SPACKLE_SOURCE,
|
|
|
|
|
SCALES_SOURCE,
|
|
|
|
|
MIRROR_SOURCE,
|
|
|
|
|
MEGAPHONE_SOURCE,
|
|
|
|
|
LIBRARIAN_SOURCE,
|
|
|
|
|
WAR_ROOM_SOURCE,
|
|
|
|
|
BARFLY_SOURCE,
|
|
|
|
|
DOGFOOD_SOURCE,
|
|
|
|
|
]
|
|
|
|
|
),
|
2026-07-25 05:39:44 +02:00
|
|
|
TaskTable.status.notin_([TaskStatus.COMPLETED, TaskStatus.CANCELLED]),
|
|
|
|
|
)
|
|
|
|
|
.values(status=TaskStatus.CANCELLED)
|
|
|
|
|
)
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2026-07-25 17:13:32 +02:00
|
|
|
async def test_list_returns_every_registered_program(ceo_client: AsyncClient) -> None:
|
2026-07-25 05:39:44 +02:00
|
|
|
resp = await ceo_client.get("/api/board-programs")
|
|
|
|
|
assert resp.status_code == HTTPStatus.OK
|
|
|
|
|
body = resp.json()
|
2026-07-25 17:13:32 +02:00
|
|
|
assert {p["key"] for p in body} == {
|
|
|
|
|
"roadmap",
|
|
|
|
|
"x_feature",
|
|
|
|
|
"pest_control",
|
|
|
|
|
"periscope",
|
|
|
|
|
"coroner",
|
|
|
|
|
"sentinel",
|
|
|
|
|
"spackle",
|
|
|
|
|
"scales",
|
|
|
|
|
"mirror",
|
|
|
|
|
"megaphone",
|
|
|
|
|
"librarian",
|
|
|
|
|
"war_room",
|
|
|
|
|
"barfly",
|
|
|
|
|
"dogfood",
|
|
|
|
|
}
|
|
|
|
|
pest_control = next(p for p in body if p["key"] == "pest_control")
|
|
|
|
|
assert pest_control["role"] == "product_owner"
|
|
|
|
|
assert pest_control["trigger"] == "cron"
|
|
|
|
|
assert pest_control["scope"] == "project"
|
|
|
|
|
spackle = next(p for p in body if p["key"] == "spackle")
|
|
|
|
|
assert spackle["role"] == "product_owner"
|
|
|
|
|
assert spackle["trigger"] == "cron"
|
|
|
|
|
assert spackle["scope"] == "project"
|
|
|
|
|
mirror = next(p for p in body if p["key"] == "mirror")
|
|
|
|
|
assert mirror["role"] == "head_marketing"
|
|
|
|
|
assert mirror["trigger"] == "cron"
|
|
|
|
|
assert mirror["scope"] == "project"
|
|
|
|
|
barfly = next(p for p in body if p["key"] == "barfly")
|
|
|
|
|
assert barfly["role"] == "head_marketing"
|
|
|
|
|
assert barfly["trigger"] == "cron"
|
|
|
|
|
assert barfly["scope"] == "org"
|
2026-07-25 05:39:44 +02:00
|
|
|
roadmap = next(p for p in body if p["key"] == "roadmap")
|
|
|
|
|
assert roadmap["role"] == "product_owner"
|
|
|
|
|
assert roadmap["trigger"] == "cron"
|
|
|
|
|
assert roadmap["scope"] == "org"
|
2026-07-25 17:13:32 +02:00
|
|
|
coroner = next(p for p in body if p["key"] == "coroner")
|
|
|
|
|
assert coroner["role"] == "auditor"
|
|
|
|
|
assert coroner["trigger"] == "event"
|
|
|
|
|
assert coroner["scope"] == "org"
|
|
|
|
|
war_room = next(p for p in body if p["key"] == "war_room")
|
|
|
|
|
assert war_room["role"] == "head_marketing"
|
|
|
|
|
assert war_room["trigger"] == "event"
|
|
|
|
|
assert war_room["scope"] == "org"
|
2026-07-25 05:39:44 +02:00
|
|
|
assert roadmap["open_cycle"] is False
|
|
|
|
|
assert roadmap["last_opened_at"] is None
|
|
|
|
|
# Not asserted == [] — org-scoped "eligible" means every active project
|
|
|
|
|
# (default-eligible, opt-out only), and db_session is a real,
|
|
|
|
|
# cross-test-persistent database within one pytest run: sibling suites
|
|
|
|
|
# seed their own projects that legitimately show up here too.
|
|
|
|
|
assert isinstance(roadmap["opted_in_project_slugs"], list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_run_now_opens_a_cycle_then_conflicts_on_retry(
|
|
|
|
|
db_session: AsyncSession,
|
|
|
|
|
ceo_client: AsyncClient,
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""One test, not two — ``RoadmapEngine.run_cycle``'s own dedup
|
|
|
|
|
(``list_open_roadmap_cycles``) is system-wide (any open ``board_roadmap``
|
|
|
|
|
task, not scoped to this test's project), and ``db_session`` is a real,
|
|
|
|
|
cross-test-persistent database within one pytest run: a leftover open
|
|
|
|
|
cycle from a sibling test would make the FIRST call here 409 too."""
|
|
|
|
|
await _arm_roadmap(db_session, monkeypatch)
|
|
|
|
|
|
|
|
|
|
first = await ceo_client.post("/api/board-programs/roadmap/run-now")
|
|
|
|
|
assert first.status_code == HTTPStatus.OK
|
|
|
|
|
body = first.json()
|
|
|
|
|
assert body["open_cycle"] is True
|
|
|
|
|
assert body["last_opened_at"] is not None
|
|
|
|
|
|
|
|
|
|
second = await ceo_client.post("/api/board-programs/roadmap/run-now")
|
|
|
|
|
assert second.status_code == HTTPStatus.CONFLICT
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 17:13:32 +02:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_run_now_opens_a_cycle_for_event_program_war_room(
|
|
|
|
|
db_session: AsyncSession,
|
|
|
|
|
ceo_client: AsyncClient,
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""War Room is EVENT-triggered same as Coroner (never cron-due — see
|
|
|
|
|
test_list_returns_every_registered_program's assertion), but UNLIKE
|
|
|
|
|
Coroner its ``_ORIGINATORS`` entry is a REAL originator
|
|
|
|
|
(``WarRoomEngine.run_cycle``, not an always-None stub): run-now must
|
|
|
|
|
actually open a cycle through the route, proving the EVENT contract
|
|
|
|
|
holds without needing a stub — ``open_program_cycle`` never checks
|
|
|
|
|
trigger kind, only the cron loop does."""
|
|
|
|
|
await _arm_war_room(db_session, monkeypatch)
|
|
|
|
|
|
|
|
|
|
first = await ceo_client.post("/api/board-programs/war_room/run-now")
|
|
|
|
|
assert first.status_code == HTTPStatus.OK
|
|
|
|
|
body = first.json()
|
|
|
|
|
assert body["open_cycle"] is True
|
|
|
|
|
assert body["last_opened_at"] is not None
|
|
|
|
|
|
|
|
|
|
second = await ceo_client.post("/api/board-programs/war_room/run-now")
|
|
|
|
|
assert second.status_code == HTTPStatus.CONFLICT
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 05:39:44 +02:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_run_now_unknown_key_is_404(ceo_client: AsyncClient) -> None:
|
|
|
|
|
resp = await ceo_client.post("/api/board-programs/not-a-real-program/run-now")
|
|
|
|
|
assert resp.status_code == HTTPStatus.NOT_FOUND
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
|
|
|
|
|
await _seed_agents(db_session)
|
|
|
|
|
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
|
|
|
|
|
transport = ASGITransport(app=app)
|
|
|
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
|
|
|
resp = await client.get("/api/board-programs")
|
|
|
|
|
assert resp.status_code == HTTPStatus.FORBIDDEN
|
|
|
|
|
app.dependency_overrides.clear()
|