mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Gateway/full (#9)
* chore(gateway): scaffold gateway package and test layout
* feat(config): add gateway feature flags, coordination thresholds, commit-validator settings
* feat(gateway): add standardized response envelope with ok/error variants
* feat(gateway): add remediation hint catalog for tracing-gap and invalid-state errors
* feat(gateway): add per-role flow/do tool catalog with developer, qa, doc, pm, board configs
* feat(db): add gateway columns — active_claimant_id, heartbeat, pre_block snapshot, acceptance_criteria_status, qa_evidence_inspected
* feat(db): create gateway_triggers table for dispatcher decision logging
* feat(db): align canonical skill set; substitute qa_review -> code_review across agent seeds
* fix(db/008): make skill alignment in-place + idempotent; preserve column and existing custom skills
* feat(gateway): add claimant_lock for single-active-agent invariant with heartbeat staleness
* feat(gateway): add trigger_filter with stale-cleanup, claimant-queue, and cooldown rules
* feat(gateway): add tracing_gate with plan, progress, journal, acceptance_criteria, qa requirements
* refactor(gateway): drop per-file ruff ignores; refactor tracing_gate with dispatch table + GateContext
* feat(gateway): add merge_chain to resolve PR target by branch hierarchy depth
* feat(gateway): add commit_validator with min-length, banned-words, and conventional-shape hints
* feat(gateway): add evidence_builder for verb-response evidence and capped context_briefing
* feat(gateway): add Choreographer skeleton with per-phase verb signatures and DI protocols
* feat(runtime): add spawn_manifest builder for per-role pre-loaded tool registration
Introduces SpawnInputs dataclass + build_for_role(inputs) + write_manifest()
in roboco/runtime/spawn_manifest.py; reads role_config for allowed verbs/tools,
emits JSON manifest that SDK shim reads at container startup to eliminate ToolSearch.
* feat(runtime): wire gateway pre-spawn check (trigger_filter + claimant_lock) into orchestrator behind ROBOCO_GATEWAY_ENABLED flag
- Add GatewayTriggerTable SQLAlchemy ORM model to roboco/db/tables.py
(matches existing table from migration 007_gateway_triggers_table)
- Add module-level gateway_pre_spawn_check() + helpers to orchestrator.py
(gated: returns ("spawn", "gateway disabled") immediately when flag is False)
- Wire gateway check into _safe_spawn() — the single dispatcher choke-point
for all agent spawns; QUEUE or DROP outcome logs and returns None (no spawn)
- ROBOCO_GATEWAY_ENABLED defaults to False; legacy behaviour is unchanged
* feat(agent_sdk): load tool-manifest.json at startup behind ROBOCO_GATEWAY_ENABLED flag (no agent-visible change yet)
Adds load_tool_manifest() to the SDK server that reads env at call-time
so gateway-enabled agents can obtain their pre-registered tool list at
startup; returns None when the flag is off, leaving the legacy briefing
path completely unchanged.
* fix(optimal_brain): skip indexing when source ID is None to eliminate roboco://journals/None spam
- Add `build_doc_source(kind, id_)` module-level helper in indexes/base.py that
returns None when id_ is None instead of producing a "roboco://journals/None" URI
- Update abstract `build_source_uri` return type to `str | None` so subclasses
can legitimately signal a missing ID
- Short-circuit `ingest()` and `_prepare_docs_for_batch()` in BaseIndexPlugin
when `build_source_uri` returns None (debug log, no push to vector store)
- Fix JournalsIndexPlugin.build_source_uri: `kwargs.get("entry_id")` returns the
kwarg value even when it is None, so fall back to doc_id before calling
build_doc_source
- Fix ConversationsIndexPlugin.build_source_uri: return None when session_id is
None rather than producing "roboco://conversations/None-unknown"
- Add 9 unit tests with a piragi-free conftest that stubs sys.modules
* fix(agent_sdk): inject X-Agent-ID header on notification-poller requests
Both `_check_pending_a2a` and `_auto_ack_a2a_notifications` in
`roboco/mcp/a2a_server.py` were calling the main API without identity
headers, causing orchestrator `Missing X-Agent-ID header` warnings on
`GET /api/v1/notifications/pending-a2a` and the ack-a2a POST.
Add module-level `AGENT_ROLE` constant (mirrors the existing `AGENT_ID`
pattern) and pass `{"X-Agent-ID": AGENT_ID, "X-Agent-Role": AGENT_ROLE}`
on both requests.
* fix(git): use ROBOCO_PUBLIC_BASE_URL for commit-trailer Links instead of hardcoded localhost
* fix(test_runner): call uv run pytest/ruff directly; add make to orchestrator Dockerfile as backstop
FileNotFoundError was propagating as a raw 500 when a project had `make test`
configured but make was not installed in the orchestrator container.
Two fixes:
1. Catch FileNotFoundError in _run_command and re-raise as ValidationError (400)
with a clear message telling the operator to reconfigure the project command
(e.g. replace 'make test' with 'uv run pytest').
2. Add `make` to the orchestrator Dockerfile runner-stage apt-get so projects
that legitimately use make targets continue to work without reconfiguration.
* fix(api/git): resolve project by slug or UUID in git_log endpoint
Add _resolve_project_slug() helper to git routes that tries UUID
lookup first and falls back to slug, matching the pattern already
used in project routes. Apply to all four read-only git endpoints:
status, log, branches, diff.
* fix(a2a): auto-create conversation when conversation_id absent; reject empty IDs in URL builder
* fix(agent_sdk): default subagent model to parent agent's model from spawn manifest, not hardcoded haiku
Inject CLAUDE_CODE_SUBAGENT_MODEL env var into every agent container at
spawn time. Claude Code ≥2.1.x reads this variable to override the
default Task (Agent) subagent model, which otherwise hard-codes
claude-haiku-4-5-20251001. When the parent runs on a non-Anthropic
provider (e.g. Ollama Cloud / minimax-m2.7:cloud) that Anthropic model
is unreachable, so subagent dispatch fails.
The value follows the same provider-aware translation already used for
the --model CLI flag: Anthropic short names go through MODEL_MAP, and
non-Anthropic identifiers are passed verbatim. To avoid calling the
class by name inside a @staticmethod, the shared translation logic is
extracted to the module-level _resolve_agent_cli_model() helper;
_resolve_cli_model() now delegates to it.
Verified: CLAUDE_CODE_SUBAGENT_MODEL is present and honoured in the
Claude Code 2.1.123 binary (grep confirmed the env-var lookup pattern
`if(process.env.CLAUDE_CODE_SUBAGENT_MODEL) return KK(…)`).
* chore(makefile): add quality and quality-fast targets composing every PR gate
* chore(quality): add import-linter dependency and gateway boundary contract
* test(property): scaffold tracing-completeness assertion (filled in Phase 4)
* Format test file to pass ruff check
* fix(gateway): drop Protocol scaffolding from choreographer skeleton; del-statements on unused stub args; clear vulture whitelist
* linting
* feat(gateway): Phase 1 dev cutover — ChoreographerDeps + give_me_work
Add ChoreographerDeps frozen dataclass (7 deps: task, work_session, git,
a2a, journal, audit, evidence_repo), refactor Choreographer.__init__ to
accept the bundle, implement give_me_work + _briefing_for via
evidence_builder.build_context_briefing, and add property accessors for
all deps. All Phase 2-4 stubs gain del-statements and still raise
NotImplementedError. 3 tests added and passing; mypy/ruff/vulture clean.
* feat(gateway): implement i_will_work_on handling pending, claimed, and needs_revision recovery
* feat(gateway): implement i_have_committed with plan-required precondition
Replaces the NotImplementedError stub with the real implementation: looks up
the agent's active task, enforces plan presence before recording, calls
task.add_progress, and returns a structured Envelope. Adds 3 unit tests
(records progress, no active task → invalid_state, no plan → tracing_gap).
* feat(gateway): implement i_am_done with smart catch-up and skill resolution
* feat(gateway): implement i_am_blocked (struggle + escalate) and i_am_idle (with unread soft-block)
* feat(gateway): add ContentActions for commit, note, say, dm, evidence with auto-inject and validation
* feat(api/v2): add /api/v2/flow/dev/* endpoints delegating to Choreographer
Six intent-verb endpoints (give_me_work, i_will_work_on, i_have_committed,
i_am_done, i_am_blocked, i_am_idle) under /api/v2/flow/dev/, each a thin
handler that delegates to Choreographer. Includes Pydantic request schemas,
EvidenceRepo Phase 1 stub (all methods return []), get_choreographer FastAPI
dep wired with all 7 service deps, and 8 unit tests (all passing).
* feat(api/v2): add /api/v2/do/* endpoints for commit, note, say, dm, evidence
* feat(mcp): add roboco-flow MCP server for intent verbs (Phase 1: dev verbs implemented)
* feat(mcp): add roboco-do MCP server for smart-wrapped content tools
* feat(runtime): mount per-agent tool-manifest.json on developer-container spawn; gateway flag enabled for devs only
* docs(prompts): rewrite developer role prompt for gateway-only verbs (~15 lines vs 49)
* chore(mcp): confirm dev manifest excludes legacy task/journal/notify/a2a tools (Phase 1 cutover; servers retired in Phase 4)
* feat(gateway): implement claim_review with inline evidence (kills #15) and qa_evidence_inspected tracking
* feat(gateway): implement pass_review with qa_notes/learning/evidence tracing gates
* feat(gateway): implement fail_review with issue list, tracing gates, and dev A2A handoff
* feat(api/v2): add /api/v2/flow/qa/* endpoints (claim_review, pass, fail, give_me_work, i_am_idle)
* feat(mcp): add QA verbs (claim_review, pass, fail) to roboco-flow MCP server
* docs(prompts): rewrite QA role prompt for gateway verbs; explicitly warn against grep-the-commit anti-pattern
* feat(runtime): enable gateway flag for QA-role spawns (Phase 2 cutover)
* feat(gateway): implement claim_doc_task and i_documented with file-list and notes-min-chars gates
* feat(gateway): implement triage (cell PM) and triage_all (main PM) with priority order
* feat(gateway): implement unblock with pre_block_state restoration (kills #23)
* feat(gateway): implement cell_pm_complete with auto-merge to parent branch (kills #22 for cell scope)
* feat(gateway): implement main_pm_complete (open master PR + escalate to CEO)
* feat(gateway): add complete() dispatcher routing to cell_pm_complete or main_pm_complete by role
* feat(gateway): implement escalate_up routing by role.escalation_target
* feat(api/v2): add /api/v2/flow/{documenter,cell_pm,main_pm}/* endpoints
* feat(mcp): add Doc + PM verbs to roboco-flow MCP server (claim_doc_task, i_documented, triage, triage_all, unblock, complete, escalate_up)
* docs(prompts): rewrite Doc, Cell PM, and Main PM role prompts for gateway verbs
* feat(runtime): enable gateway flag for Doc, Cell PM, and Main PM roles (Phase 3 cutover)
* test(integration): full pending->awaiting_ceo_approval test through dev/QA/doc/cell-PM/main-PM gateway path
* chore(tests): rename unused args to _args in flow_server tests
Cleared RUF059 lint blocker for Phase 3 closeout. The destructured args was only consumed in URL-asserting tests; one variant only checks kwargs["json"], so its args is now _args.
* chore: untrack docs/superpowers/ + add to .gitignore
Plans + spec were inadvertently swept into commits 5d41a4b and de0c5b5 by subagent 'git add -A' calls. Removed from index and gitignored going forward; files remain on disk for ongoing reference. They still exist in history of those two commits — invoke a follow-up filter-repo if a full purge is desired.
* feat(gateway): implement Board escalate_to_ceo with role allow-list
Allows main_pm, product_owner, and head_marketing to escalate tasks to
CEO. Enforces awaiting_pm_review state and journal:decision tracing gate.
Closes Phase 4 Task 1.
* feat(gateway): implement board_triage prioritizing strategic root tasks
Adds Choreographer.board_triage and TaskService.list_strategic_for_board.
PO and Head Marketing get curated lists of strategic-nature root tasks
in awaiting_pm_review. Closes Phase 4 Task 2.
* feat(gateway): implement auditor_triage surfacing long-running blocked-task anomalies
Adds Choreographer.auditor_triage and TaskService.list_long_running_blocked.
The Auditor surfaces tasks blocked >30min as anomalies for reflect-note
observation. Closes Phase 4 Task 3.
* chore(tests): add return + arg type annotations to gateway tests
All gateway test functions now have -> None and parameter annotations. Cleared 63 mypy [no-untyped-def] errors that pre-existed since Phase 1. Mypy now clean across tests/unit/gateway/.
* feat(api/v2): add /api/v2/flow/{board,auditor}/* endpoints
Board: triage, escalate_to_ceo, i_am_idle.
Auditor: triage, i_am_idle (read-only role).
Adds EscalateToCeoRequest schema with reason min_length validation.
Closes Phase 4 Task 4.
* feat(mcp): add Board + Auditor verbs to roboco-flow MCP server
Adds escalate_to_ceo MCP tool used by Board (PO + Head Marketing) and Main PM. Updates the implemented set in _validate_role_compatibility. Auditor uses the existing triage tool with role-routing in URL.
Closes Phase 4 Task 5.
* docs(prompts): rewrite Board (PO, Head-Marketing, Auditor) prompts for gateway verbs
All 3 board identity files + roles/board.md now use the slim, gateway-aware shape (no ToolSearch directive, no state-tool table). Auditor is explicit about its read-only scope. Closes Phase 4 Task 6.
* feat(runtime): enable gateway manifest for ALL roles (Phase 4 cutover)
Adds product_owner, head_marketing, auditor to GATEWAY_ENABLED_ROLES. Every spawned agent now gets a gateway manifest mounted at /app/tool-manifest.json. The legacy briefing path is dead. Closes Phase 4 Task 8.
* test(property): implement tracing-completeness assertion across smoke-test batch
Replaces Phase 0 stub. Asserts the 6 tracing-contract requirements on every
completed task: audit_log agent_id non-null per state-transition row,
DEVELOPER:TASK_REFLECTION journal entry, QA:LEARNING journal entry,
CELL_PM/MAIN_PM:DECISION_LOG journal entry, acceptance_criteria_status
covering every criterion with a referencing_artifact_id, and
qa_evidence_inspected = true.
Uses an in-memory ephemeral Postgres test DB (`roboco_test_<pid>_<rand>`)
provisioned per pytest session, not SQLite — the production schema relies
on Postgres-only types (UUID, ARRAY) the SQLite dialect cannot compile.
Tests requesting db_session/smoke_test_batch are auto-skipped when no
Postgres is reachable on localhost:5432; ROBOCO_TEST_DB_HOST/PORT/USER
override the endpoint.
Schema is built via Base.metadata.create_all + manual ALTER for the
acceptance_criteria_status / qa_evidence_inspected columns, NOT via
`alembic upgrade head`. This sidesteps two pre-existing layer-drift items
that block any fresh migration run today:
1. Migration 001 declares the agentrole Postgres enum with lowercase
values (qa, developer, ...) but the SQLAlchemy ORM binds
Enum(AgentRole) to the StrEnum's uppercase NAMES — production DBs
mask this by being bootstrapped via create_all and stamped at 001.
2. Migration 008 runs UPDATE agents SET skills WHERE id over an
agents.skills column that no migration in this chain ever creates.
Documented in conftest.py so a future migrations cleanup can find them.
Also notes that acceptance_criteria_status/qa_evidence_inspected are in
the DB schema (per migration 006) but are NOT mapped on the ORM TaskTable
nor on the Pydantic Task model — services that read them via
`task.qa_evidence_inspected` rely on those values being set on raw rows.
The property test uses raw SQL to read the columns directly, matching the
DB-level contract.
Closes Phase 4 Task 11.
Side change: pyproject.toml — adds asyncpg.* to the existing
[[tool.mypy.overrides]] ignore_missing_imports list (asyncpg ships no
py.typed marker), matching the convention used for redis, anthropic,
piragi, etc.
Test count: 1; backend: Postgres (localhost test DB).
* style(mcp/flow_server): single-line _post call after format pass
* fix(db): map 7 gateway columns from migration 006 to TaskTable + Task model
active_claimant_id, last_heartbeat_at, pre_block_state, pre_block_assignee, pre_block_metadata, acceptance_criteria_status, qa_evidence_inspected: present in DB since migration 006 but absent from the ORM mapping. Gateway code (tracing_gate, choreographer, claimant_lock) reads these via task.<attr>; without the mapping, runtime would AttributeError. Closes PHASE4-BUG-A.
* fix(db): repair alembic chain — neutralize 008, add 009 enum reconcile, ORM uses values_callable
Three coordinated changes that close PHASE4-BUG-B:
1. roboco/db/tables.py — introduce _str_enum() helper that wraps Enum() with values_callable=lambda obj: [m.value for m in obj]. Apply to all 23 StrEnum-typed mapped columns. ORM now serializes by .value (lowercase) to match alembic 001's declared enum values; default Enum() was using .name (uppercase) which never matched.
2. alembic/versions/008_align_skills.py — replace with documented no-op. The original migration referenced agents.skills, a column that has never existed in any migration (the agents table has capabilities, not skills). The substitution intent (qa_review -> code_review) was already satisfied statically in roboco/agents_config.py.
3. alembic/versions/009_enum_reconcile.py — new migration that:
- Adds missing enum values: agentrole.system, team.fullstack, taskstatus.quarantined.
- Detects uppercase drift from a Base.metadata.create_all bootstrap and rebuilds agentrole/team/taskstatus enums with lowercase members + USING lower(col::text)::enum on every column referenced. No-op if already lowercase.
Tests stay green: 281 passed.
* feat(services): backfill 36 gateway-shaped methods for Choreographer
The gateway Choreographer was wired to call methods that the underlying
services did not expose. This adds them as thin wrappers + queries (most
alias canonical methods; a handful are gateway-specific variants).
TaskService — 26 methods: aliases (submit_verification, submit_qa,
list_blocked_for_team, list_blocked_all_teams,
list_awaiting_pm_review_for_team, list_assigned_for_agent), agent
queries (agent_for, qa_agent_for_team, documenter_for_team,
cell_pm_for_team, get_active_task_for_agent, list_paused_for_agent),
triage queries (list_awaiting_main_pm_all, all_subtasks_terminal),
state setters (set_plan, mark_evidence_inspected, mark_agent_idle),
QA/Doc claim variants (qa_claim, doc_claim, qa_pass, qa_fail),
PM completion (cell_pm_complete with merge_commit), unblock with
state restore (unblock_with_restore), and escalation
(escalate, escalate_up_to_role). Also adds GatewayAgentView
dataclass that unifies DB and config-derived agent attributes.
JournalService — 4 methods: existence checks (has_decision_for_task,
has_learning_for_task, has_reflect_for_task) + write_struggle.
GitService — 4 methods: branch-keyed entry points (create_pr,
pr_merge, pr_target, diff) plus push_branch helper. Each derives
project + workspace from the task that owns the branch / PR.
WorkSessionService — 2 methods: files_changed + has_unpushed_commits.
PR existence is the proxy for pushed (no per-commit push column).
Choreographer: switched git.push(branch_name) call to push_branch()
to dispatch to the new gateway-shaped helper.
* test(services): unit tests for 36 gateway-backfill methods
Adds happy-path + edge tests for every method added in the prior
backfill commit. Total 61 new tests across:
- tests/unit/services/test_task.py (36)
- tests/unit/services/test_journal.py (8)
- tests/unit/services/test_git.py (10)
- tests/unit/services/test_work_session.py (7)
Each test mocks at the session boundary (no DB) and stubs adjacent
service methods via a dynamic _bind helper to avoid mypy
[method-assign] noise without resorting to type:ignore comments.
* test(gateway): switch dev catch-up assertion to push_branch
The Choreographer's catch-up sequence was renamed from git.push(branch)
to git.push_branch(branch) when GitService got a gateway-shaped helper
in the prior commit. This updates the existing assertion to match.
* feat(mcp): add roboco-git-readonly server with status/log/diff/branches
Slim FastMCP server exposing the four read-only git tools every role
needs (status, log, diff, branch_list) by forwarding to /api/v1/git/*
on the orchestrator. Replaces the read-only half of the legacy
roboco-git server; write operations now go through gateway verbs in
roboco-flow / roboco-do.
The endpoint shapes mirror the panel-facing API (project_slug,
include_remote, staged/file_path) so the same backend handlers serve
both human and agent traffic.
* refactor(mcp): delete legacy task/journal/notify/a2a/message/project servers
Phase 4 cutover: agents now reach every state-changing surface through
the gateway (roboco-flow intent verbs + roboco-do content tools), with
roboco-git-readonly + roboco-optimal + roboco-docs covering reads. The
seven legacy MCP servers + their handler trees are dead code from the
agent side, so they're removed:
roboco/mcp/task_server.py (1020 LOC)
roboco/mcp/journal_server.py (512 LOC)
roboco/mcp/notify_server.py (440 LOC)
roboco/mcp/a2a_server.py (790 LOC)
roboco/mcp/message_server.py (682 LOC)
roboco/mcp/project_server.py (667 LOC)
roboco/mcp/tasks/ (handlers+utils) (~4300 LOC)
roboco/mcp/test/ (in-container runner; replaced by gateway evidence
+ manual smoke)
roboco/mcp/git/ (full server; read-only half migrates to the new
slim roboco-git-readonly module, write half is
owned by gateway verbs)
Orchestrator updates:
- _generate_mcp_config registers only roboco-flow, roboco-do,
roboco-git-readonly, roboco-optimal, and (for docs roles) roboco-docs.
No more per-role legacy fan-out.
- base_allow flips to mcp__roboco-flow__*, mcp__roboco-do__*,
mcp__roboco-optimal__*, mcp__roboco-git-readonly__*. Role-specific
allow lists are reduced to file IO scoping, since gateway verbs
enforce role policy server-side.
- TRACEABILITY_TRIGGER_TOOLS rewritten in terms of the gateway servers
(mcp__roboco-flow__* / mcp__roboco-do__*) instead of the now-deleted
per-tool list.
Test fix: tests/unit/services/test_a2a.py imported _handle_send_chat_message
from the deleted a2a_server. The four MCP-layer URL-builder tests (empty
conversation_id guard) are dropped — the equivalent boundary now lives
in /api/v2/do/* which has its own integration coverage. The two
service-layer nil-UUID guard tests are kept; they exercise A2AService
directly and remain meaningful (the panel still uses the v1 chat surface,
where a buggy caller could pass the nil UUID).
Net: ~9600 LOC removed from roboco/mcp/. quality-fast green:
338 tests pass, mypy clean, ruff clean. No /api/v1/* router changes —
those endpoints stay live for the panel UI which still uses every
lifecycle action; agents have no prompts that name them so the path is
dead code from the agent side.
* docs(claude.md): replace legacy MCP listing with gateway/verb-surface section
Phase 4 cutover: agents go through roboco-flow + roboco-do (gateway), not the deleted task/journal/notify/a2a/message/project servers. Document the verb surface per role + the Envelope response shape so future Claude Code sessions land in the correct mental model. Closes Phase 4 Task 13.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
Renn F
parent
254cc93fd5
commit
62bda0c497
@@ -0,0 +1,84 @@
|
||||
"""Unit tests: _resolve_project_slug accepts slug or UUID."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from roboco.api.routes.git import _resolve_project_slug
|
||||
|
||||
_HTTP_404 = 404
|
||||
|
||||
|
||||
def _make_project(slug: str, uid: UUID) -> MagicMock:
|
||||
"""Return a minimal project-like object."""
|
||||
project = MagicMock()
|
||||
project.slug = slug
|
||||
project.id = uid
|
||||
return project
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_project_slug_accepts_slug() -> None:
|
||||
"""A plain slug string resolves to the project's slug."""
|
||||
project = _make_project("roboco", uuid4())
|
||||
mock_service = MagicMock()
|
||||
mock_service.get_by_slug = AsyncMock(return_value=project)
|
||||
|
||||
with patch("roboco.api.routes.git.get_project_service", return_value=mock_service):
|
||||
result = await _resolve_project_slug("roboco", MagicMock())
|
||||
|
||||
assert result == "roboco"
|
||||
mock_service.get_by_slug.assert_awaited_once_with("roboco")
|
||||
mock_service.get.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_project_slug_accepts_uuid() -> None:
|
||||
"""A UUID string resolves to the project's slug."""
|
||||
uid = uuid4()
|
||||
project = _make_project("roboco", uid)
|
||||
mock_service = MagicMock()
|
||||
mock_service.get = AsyncMock(return_value=project)
|
||||
|
||||
with patch("roboco.api.routes.git.get_project_service", return_value=mock_service):
|
||||
result = await _resolve_project_slug(str(uid), MagicMock())
|
||||
|
||||
assert result == "roboco"
|
||||
mock_service.get.assert_awaited_once_with(UUID(str(uid)))
|
||||
mock_service.get_by_slug.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_project_slug_raises_404_for_missing_slug() -> None:
|
||||
"""Unknown slug raises HTTPException 404."""
|
||||
mock_service = MagicMock()
|
||||
mock_service.get_by_slug = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch("roboco.api.routes.git.get_project_service", return_value=mock_service),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
await _resolve_project_slug("nonexistent", MagicMock())
|
||||
|
||||
assert exc_info.value.status_code == _HTTP_404
|
||||
assert "nonexistent" in exc_info.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_project_slug_raises_404_for_missing_uuid() -> None:
|
||||
"""UUID that matches no project raises HTTPException 404."""
|
||||
uid = uuid4()
|
||||
mock_service = MagicMock()
|
||||
mock_service.get = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch("roboco.api.routes.git.get_project_service", return_value=mock_service),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
await _resolve_project_slug(str(uid), MagicMock())
|
||||
|
||||
assert exc_info.value.status_code == _HTTP_404
|
||||
assert str(uid) in exc_info.value.detail
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Unit tests for /api/v2/do/* endpoints.
|
||||
|
||||
Uses a minimal FastAPI test client built from the do router only.
|
||||
No DB required — ContentActions is mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from roboco.api.deps import get_content_actions
|
||||
from roboco.api.routes.v2.do import router
|
||||
from roboco.services.gateway.content_actions import ContentActions
|
||||
|
||||
_HTTP_200 = 200
|
||||
_HTTP_422 = 422
|
||||
|
||||
_AGENT_ID = str(uuid4())
|
||||
_TASK_ID = str(uuid4())
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID}
|
||||
|
||||
|
||||
def _make_envelope(
|
||||
status: str = "ok",
|
||||
task_id: str | None = None,
|
||||
extra: dict | None = None,
|
||||
) -> MagicMock:
|
||||
"""Return a mock Envelope whose as_dict() returns a predictable payload."""
|
||||
env = MagicMock()
|
||||
payload: dict = {"status": status, "task_id": task_id, "next": "continue"}
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
env.as_dict.return_value = payload
|
||||
return env
|
||||
|
||||
|
||||
def _build_app(mock_actions: MagicMock) -> FastAPI:
|
||||
"""Build minimal FastAPI app with the do router and a mocked dep."""
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_content_actions] = lambda: mock_actions
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_descriptive_message_returns_ok() -> None:
|
||||
"""POST /api/v2/do/commit with a descriptive message returns 200 ok."""
|
||||
mock_actions = MagicMock(spec=ContentActions)
|
||||
mock_actions.commit = AsyncMock(
|
||||
return_value=_make_envelope(status="ok", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_actions))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/do/commit",
|
||||
json={"message": "add user authentication endpoint"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "ok"
|
||||
mock_actions.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_wip_message_returns_invalid_state() -> None:
|
||||
"""POST /api/v2/do/commit with 'wip' returns invalid_state envelope."""
|
||||
mock_actions = MagicMock(spec=ContentActions)
|
||||
mock_actions.commit = AsyncMock(return_value=_make_envelope(status="invalid_state"))
|
||||
client = TestClient(_build_app(mock_actions))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/do/commit",
|
||||
json={"message": "wip"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "invalid_state"
|
||||
mock_actions.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_note_reflect_scope_returns_ok() -> None:
|
||||
"""POST /api/v2/do/note with scope='reflect' returns 200 ok."""
|
||||
mock_actions = MagicMock(spec=ContentActions)
|
||||
mock_actions.note = AsyncMock(return_value=_make_envelope(status="noted"))
|
||||
client = TestClient(_build_app(mock_actions))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/do/note",
|
||||
json={"text": "learned how HyDE works", "scope": "reflect"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "noted"
|
||||
mock_actions.note.assert_awaited_once()
|
||||
assert mock_actions.note.call_args.kwargs["scope"] == "reflect"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_note_garbage_scope_returns_invalid_state() -> None:
|
||||
"""POST /api/v2/do/note with scope='garbage' returns invalid_state envelope."""
|
||||
mock_actions = MagicMock(spec=ContentActions)
|
||||
mock_actions.note = AsyncMock(return_value=_make_envelope(status="invalid_state"))
|
||||
client = TestClient(_build_app(mock_actions))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/do/note",
|
||||
json={"text": "some note", "scope": "garbage"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "invalid_state"
|
||||
mock_actions.note.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_say_with_explicit_task_id_returns_ok() -> None:
|
||||
"""POST /api/v2/do/say with task_id explicit returns 200 ok."""
|
||||
mock_actions = MagicMock(spec=ContentActions)
|
||||
mock_actions.say = AsyncMock(
|
||||
return_value=_make_envelope(status="posted", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_actions))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/do/say",
|
||||
json={"channel": "backend-cell", "text": "PR is ready", "task_id": _TASK_ID},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "posted"
|
||||
mock_actions.say.assert_awaited_once()
|
||||
assert str(mock_actions.say.call_args.kwargs["task_id"]) == _TASK_ID
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_say_without_task_id_auto_injects() -> None:
|
||||
"""POST /api/v2/do/say with task_id null passes None; ContentActions injects."""
|
||||
mock_actions = MagicMock(spec=ContentActions)
|
||||
mock_actions.say = AsyncMock(
|
||||
return_value=_make_envelope(status="posted", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_actions))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/do/say",
|
||||
json={"channel": "backend-cell", "text": "stand-up update"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
# task_id was None in the request body — handler passes it through as None
|
||||
call_kwargs = mock_actions.say.call_args.kwargs
|
||||
assert call_kwargs["task_id"] is None
|
||||
mock_actions.say.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_with_no_task_context_returns_invalid_state() -> None:
|
||||
"""POST /api/v2/do/dm with no task context returns invalid_state envelope."""
|
||||
mock_actions = MagicMock(spec=ContentActions)
|
||||
mock_actions.dm = AsyncMock(return_value=_make_envelope(status="invalid_state"))
|
||||
client = TestClient(_build_app(mock_actions))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/do/dm",
|
||||
json={"recipient": "be-qa-1", "text": "please review"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "invalid_state"
|
||||
mock_actions.dm.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evidence_with_task_id_returns_evidence_envelope() -> None:
|
||||
"""POST /api/v2/do/evidence with task_id returns 200 with evidence in response."""
|
||||
evidence_payload = {"commits": ["abc123"], "diff_summary": "added 3 files"}
|
||||
mock_actions = MagicMock(spec=ContentActions)
|
||||
mock_actions.evidence = AsyncMock(
|
||||
return_value=_make_envelope(
|
||||
status="in_progress", task_id=_TASK_ID, extra={"evidence": evidence_payload}
|
||||
)
|
||||
)
|
||||
client = TestClient(_build_app(mock_actions))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/do/evidence",
|
||||
json={"task_id": _TASK_ID},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "in_progress"
|
||||
assert "evidence" in body
|
||||
assert body["evidence"]["commits"] == ["abc123"]
|
||||
mock_actions.evidence.assert_awaited_once()
|
||||
assert str(mock_actions.evidence.call_args.kwargs["task_id"]) == _TASK_ID
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Unit tests for /api/v2/flow/auditor/* endpoints.
|
||||
|
||||
Uses a minimal FastAPI test client built from the new router only.
|
||||
No DB required — Choreographer is mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from roboco.api.deps import get_choreographer
|
||||
from roboco.api.routes.v2.flow_auditor import router
|
||||
|
||||
_HTTP_200 = 200
|
||||
|
||||
_AGENT_ID = str(uuid4())
|
||||
_TASK_ID = str(uuid4())
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID}
|
||||
|
||||
|
||||
def _make_envelope(
|
||||
status: str = "ok", task_id: str | None = None, **extra: object
|
||||
) -> MagicMock:
|
||||
"""Return a mock Envelope whose as_dict() returns a predictable payload."""
|
||||
env = MagicMock()
|
||||
payload: dict[str, object] = {"status": status, "task_id": task_id, "next": "..."}
|
||||
payload.update(extra)
|
||||
env.as_dict.return_value = payload
|
||||
return env
|
||||
|
||||
|
||||
def _build_app(mock_choreographer: MagicMock) -> FastAPI:
|
||||
"""Build minimal FastAPI app with the flow_auditor router and a mocked dep."""
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_choreographer] = lambda: mock_choreographer
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_triage_returns_envelope() -> None:
|
||||
"""POST /api/v2/flow/auditor/triage returns 200 with envelope shape."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.auditor_triage = AsyncMock(
|
||||
return_value=_make_envelope(status="blocked", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/auditor/triage",
|
||||
json={},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "blocked"
|
||||
mock_chore.auditor_triage.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_am_idle_returns_envelope() -> None:
|
||||
"""POST /api/v2/flow/auditor/i_am_idle delegates to Choreographer.i_am_idle."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.i_am_idle = AsyncMock(return_value=_make_envelope(status="idle"))
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/auditor/i_am_idle",
|
||||
json={},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "idle"
|
||||
mock_chore.i_am_idle.assert_awaited_once()
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Unit tests for /api/v2/flow/board/* endpoints.
|
||||
|
||||
Uses a minimal FastAPI test client built from the new router only.
|
||||
No DB required — Choreographer is mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from roboco.api.deps import get_choreographer
|
||||
from roboco.api.routes.v2.flow_board import router
|
||||
|
||||
_HTTP_200 = 200
|
||||
_HTTP_422 = 422
|
||||
|
||||
_AGENT_ID = str(uuid4())
|
||||
_TASK_ID = str(uuid4())
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID}
|
||||
|
||||
|
||||
def _make_envelope(
|
||||
status: str = "ok", task_id: str | None = None, **extra: object
|
||||
) -> MagicMock:
|
||||
"""Return a mock Envelope whose as_dict() returns a predictable payload."""
|
||||
env = MagicMock()
|
||||
payload: dict[str, object] = {"status": status, "task_id": task_id, "next": "..."}
|
||||
payload.update(extra)
|
||||
env.as_dict.return_value = payload
|
||||
return env
|
||||
|
||||
|
||||
def _build_app(mock_choreographer: MagicMock) -> FastAPI:
|
||||
"""Build minimal FastAPI app with the flow_board router and a mocked dep."""
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_choreographer] = lambda: mock_choreographer
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_triage_returns_envelope() -> None:
|
||||
"""POST /api/v2/flow/board/triage returns 200 with envelope shape."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.board_triage = AsyncMock(
|
||||
return_value=_make_envelope(status="awaiting_pm_review", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/board/triage",
|
||||
json={},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "awaiting_pm_review"
|
||||
mock_chore.board_triage.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escalate_to_ceo_returns_envelope() -> None:
|
||||
"""POST /api/v2/flow/board/escalate_to_ceo forwards task_id and reason."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.escalate_to_ceo = AsyncMock(
|
||||
return_value=_make_envelope(status="awaiting_ceo_approval", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/board/escalate_to_ceo",
|
||||
json={"task_id": _TASK_ID, "reason": "Strategic call needs CEO sign-off."},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "awaiting_ceo_approval"
|
||||
mock_chore.escalate_to_ceo.assert_awaited_once()
|
||||
call_args = mock_chore.escalate_to_ceo.call_args
|
||||
assert str(call_args.args[1]) == _TASK_ID
|
||||
assert call_args.args[2] == "Strategic call needs CEO sign-off."
|
||||
|
||||
|
||||
def test_escalate_to_ceo_validates_reason_required() -> None:
|
||||
"""POST escalate_to_ceo rejects empty reason (min_length=1)."""
|
||||
mock_chore = MagicMock()
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/board/escalate_to_ceo",
|
||||
json={"task_id": _TASK_ID, "reason": ""},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_am_idle_returns_envelope() -> None:
|
||||
"""POST /api/v2/flow/board/i_am_idle delegates to Choreographer.i_am_idle."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.i_am_idle = AsyncMock(return_value=_make_envelope(status="idle"))
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/board/i_am_idle",
|
||||
json={},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "idle"
|
||||
mock_chore.i_am_idle.assert_awaited_once()
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Unit tests for /api/v2/flow/cell_pm/* endpoints.
|
||||
|
||||
Uses a minimal FastAPI test client built from the new router only.
|
||||
No DB required — Choreographer is mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from roboco.api.deps import get_choreographer
|
||||
from roboco.api.routes.v2.flow_cell_pm import router
|
||||
|
||||
_HTTP_200 = 200
|
||||
_HTTP_422 = 422
|
||||
|
||||
_AGENT_ID = str(uuid4())
|
||||
_TASK_ID = str(uuid4())
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID}
|
||||
|
||||
|
||||
def _make_envelope(
|
||||
status: str = "ok", task_id: str | None = None, **extra: object
|
||||
) -> MagicMock:
|
||||
"""Return a mock Envelope whose as_dict() returns a predictable payload."""
|
||||
env = MagicMock()
|
||||
payload: dict[str, object] = {"status": status, "task_id": task_id, "next": "..."}
|
||||
payload.update(extra)
|
||||
env.as_dict.return_value = payload
|
||||
return env
|
||||
|
||||
|
||||
def _build_app(mock_choreographer: MagicMock) -> FastAPI:
|
||||
"""Build minimal FastAPI app with the flow_cell_pm router and a mocked dep."""
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_choreographer] = lambda: mock_choreographer
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_give_me_work_returns_envelope() -> None:
|
||||
"""POST /api/v2/flow/cell_pm/give_me_work returns 200 with envelope shape."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.give_me_work = AsyncMock(return_value=_make_envelope(status="idle"))
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/cell_pm/give_me_work",
|
||||
json={},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "idle"
|
||||
mock_chore.give_me_work.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_triage_returns_envelope() -> None:
|
||||
"""POST /api/v2/flow/cell_pm/triage returns 200 with task or idle status."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.triage = AsyncMock(
|
||||
return_value=_make_envelope(status="awaiting_pm_review", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/cell_pm/triage",
|
||||
json={},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "awaiting_pm_review"
|
||||
mock_chore.triage.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unblock_dispatches_task_id_with_restore_true() -> None:
|
||||
"""POST /api/v2/flow/cell_pm/unblock forwards task_id and restore=True."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.unblock = AsyncMock(
|
||||
return_value=_make_envelope(status="in_progress", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/cell_pm/unblock",
|
||||
json={"task_id": _TASK_ID},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "in_progress"
|
||||
mock_chore.unblock.assert_awaited_once()
|
||||
call_kwargs = mock_chore.unblock.call_args.kwargs
|
||||
assert call_kwargs["restore"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unblock_with_restore_false() -> None:
|
||||
"""POST /api/v2/flow/cell_pm/unblock forwards restore=False when specified."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.unblock = AsyncMock(
|
||||
return_value=_make_envelope(status="in_progress", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/cell_pm/unblock",
|
||||
json={"task_id": _TASK_ID, "restore": False},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
mock_chore.unblock.assert_awaited_once()
|
||||
call_kwargs = mock_chore.unblock.call_args.kwargs
|
||||
assert call_kwargs["restore"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_dispatches_task_and_notes() -> None:
|
||||
"""POST /api/v2/flow/cell_pm/complete forwards task_id and notes."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.complete = AsyncMock(
|
||||
return_value=_make_envelope(status="completed", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/cell_pm/complete",
|
||||
json={"task_id": _TASK_ID, "notes": "All subtasks done, PR merged."},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
mock_chore.complete.assert_awaited_once()
|
||||
call_args = mock_chore.complete.call_args
|
||||
assert str(call_args.args[1]) == _TASK_ID
|
||||
assert call_args.args[2] == "All subtasks done, PR merged."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escalate_up_dispatches_reason() -> None:
|
||||
"""POST /api/v2/flow/cell_pm/escalate_up forwards task_id and reason."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.escalate_up = AsyncMock(
|
||||
return_value=_make_envelope(status="awaiting_pm_review", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/cell_pm/escalate_up",
|
||||
json={"task_id": _TASK_ID, "reason": "Cross-cell dependency needs Main PM."},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
mock_chore.escalate_up.assert_awaited_once()
|
||||
call_args = mock_chore.escalate_up.call_args
|
||||
assert call_args.args[2] == "Cross-cell dependency needs Main PM."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_am_idle_dispatches_agent_id() -> None:
|
||||
"""POST /api/v2/flow/cell_pm/i_am_idle delegates to Choreographer.i_am_idle."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.i_am_idle = AsyncMock(return_value=_make_envelope(status="idle"))
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/cell_pm/i_am_idle",
|
||||
json={},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "idle"
|
||||
mock_chore.i_am_idle.assert_awaited_once()
|
||||
|
||||
|
||||
def test_complete_rejects_empty_notes() -> None:
|
||||
"""POST complete rejects empty notes (min_length=1)."""
|
||||
mock_chore = MagicMock()
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/cell_pm/complete",
|
||||
json={"task_id": _TASK_ID, "notes": ""},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_422
|
||||
|
||||
|
||||
def test_escalate_up_rejects_empty_reason() -> None:
|
||||
"""POST escalate_up rejects empty reason (min_length=1)."""
|
||||
mock_chore = MagicMock()
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/cell_pm/escalate_up",
|
||||
json={"task_id": _TASK_ID, "reason": ""},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_422
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Unit tests for /api/v2/flow/dev/* endpoints.
|
||||
|
||||
Uses a minimal FastAPI test client built from the new router only.
|
||||
No DB required — Choreographer is mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from roboco.api.deps import get_choreographer
|
||||
from roboco.api.routes.v2.flow_dev import router
|
||||
|
||||
_HTTP_200 = 200
|
||||
_HTTP_422 = 422
|
||||
|
||||
_AGENT_ID = str(uuid4())
|
||||
_TASK_ID = str(uuid4())
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID}
|
||||
|
||||
|
||||
def _make_envelope(status: str = "ok", task_id: str | None = None) -> MagicMock:
|
||||
"""Return a mock Envelope whose as_dict() returns a predictable payload."""
|
||||
env = MagicMock()
|
||||
env.as_dict.return_value = {"status": status, "task_id": task_id, "next": "..."}
|
||||
return env
|
||||
|
||||
|
||||
def _build_app(mock_choreographer: MagicMock) -> FastAPI:
|
||||
"""Build minimal FastAPI app with the flow_dev router and a mocked dep."""
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_choreographer] = lambda: mock_choreographer
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_give_me_work_returns_envelope() -> None:
|
||||
"""POST /api/v2/flow/dev/give_me_work returns 200 with envelope shape."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.give_me_work = AsyncMock(return_value=_make_envelope(status="idle"))
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/dev/give_me_work",
|
||||
json={},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "idle"
|
||||
mock_chore.give_me_work.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_will_work_on_dispatches_task_id() -> None:
|
||||
"""POST /api/v2/flow/dev/i_will_work_on forwards task_id and plan."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.i_will_work_on = AsyncMock(
|
||||
return_value=_make_envelope(status="in_progress", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/dev/i_will_work_on",
|
||||
json={"task_id": _TASK_ID, "plan": "implement the feature"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "in_progress"
|
||||
mock_chore.i_will_work_on.assert_awaited_once()
|
||||
call_args = mock_chore.i_will_work_on.call_args
|
||||
# second positional arg is task_id (UUID), third is plan
|
||||
assert str(call_args.args[1]) == _TASK_ID
|
||||
assert call_args.args[2] == "implement the feature"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_have_committed_dispatches_message() -> None:
|
||||
"""POST /api/v2/flow/dev/i_have_committed forwards commit message."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.i_have_committed = AsyncMock(
|
||||
return_value=_make_envelope(status="in_progress")
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/dev/i_have_committed",
|
||||
json={"message": "add auth endpoint"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
mock_chore.i_have_committed.assert_awaited_once()
|
||||
assert mock_chore.i_have_committed.call_args.args[1] == "add auth endpoint"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_am_done_dispatches_task_and_notes() -> None:
|
||||
"""POST /api/v2/flow/dev/i_am_done forwards task_id and notes."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.i_am_done = AsyncMock(
|
||||
return_value=_make_envelope(status="awaiting_qa", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/dev/i_am_done",
|
||||
json={"task_id": _TASK_ID, "notes": "all tests pass"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "awaiting_qa"
|
||||
mock_chore.i_am_done.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_am_blocked_dispatches_reason() -> None:
|
||||
"""POST /api/v2/flow/dev/i_am_blocked forwards task_id and reason."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.i_am_blocked = AsyncMock(
|
||||
return_value=_make_envelope(status="blocked", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/dev/i_am_blocked",
|
||||
json={"task_id": _TASK_ID, "reason": "waiting for design spec"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
mock_chore.i_am_blocked.assert_awaited_once()
|
||||
assert mock_chore.i_am_blocked.call_args.args[2] == "waiting for design spec"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_am_idle_dispatches_agent_id() -> None:
|
||||
"""POST /api/v2/flow/dev/i_am_idle delegates to Choreographer.i_am_idle."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.i_am_idle = AsyncMock(return_value=_make_envelope(status="idle"))
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/dev/i_am_idle",
|
||||
json={},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "idle"
|
||||
mock_chore.i_am_idle.assert_awaited_once()
|
||||
|
||||
|
||||
def test_i_have_committed_rejects_empty_message() -> None:
|
||||
"""POST i_have_committed rejects empty message (min_length=1)."""
|
||||
mock_chore = MagicMock()
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/dev/i_have_committed",
|
||||
json={"message": ""},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_422
|
||||
|
||||
|
||||
def test_i_am_blocked_rejects_empty_reason() -> None:
|
||||
"""POST i_am_blocked rejects empty reason (min_length=1)."""
|
||||
mock_chore = MagicMock()
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/dev/i_am_blocked",
|
||||
json={"task_id": _TASK_ID, "reason": ""},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_422
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Unit tests for /api/v2/flow/documenter/* endpoints.
|
||||
|
||||
Uses a minimal FastAPI test client built from the new router only.
|
||||
No DB required — Choreographer is mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from roboco.api.deps import get_choreographer
|
||||
from roboco.api.routes.v2.flow_doc import router
|
||||
|
||||
_HTTP_200 = 200
|
||||
_HTTP_422 = 422
|
||||
|
||||
_AGENT_ID = str(uuid4())
|
||||
_TASK_ID = str(uuid4())
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID}
|
||||
|
||||
|
||||
def _make_envelope(
|
||||
status: str = "ok", task_id: str | None = None, **extra: object
|
||||
) -> MagicMock:
|
||||
"""Return a mock Envelope whose as_dict() returns a predictable payload."""
|
||||
env = MagicMock()
|
||||
payload: dict[str, object] = {"status": status, "task_id": task_id, "next": "..."}
|
||||
payload.update(extra)
|
||||
env.as_dict.return_value = payload
|
||||
return env
|
||||
|
||||
|
||||
def _build_app(mock_choreographer: MagicMock) -> FastAPI:
|
||||
"""Build minimal FastAPI app with the flow_doc router and a mocked dep."""
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_choreographer] = lambda: mock_choreographer
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_give_me_work_returns_envelope() -> None:
|
||||
"""POST /api/v2/flow/documenter/give_me_work returns 200 with envelope shape."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.give_me_work = AsyncMock(return_value=_make_envelope(status="idle"))
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/documenter/give_me_work",
|
||||
json={},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "idle"
|
||||
mock_chore.give_me_work.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_doc_task_dispatches_task_id() -> None:
|
||||
"""POST /api/v2/flow/documenter/claim_doc_task forwards task_id."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.claim_doc_task = AsyncMock(
|
||||
return_value=_make_envelope(status="awaiting_documentation", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/documenter/claim_doc_task",
|
||||
json={"task_id": _TASK_ID},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "awaiting_documentation"
|
||||
mock_chore.claim_doc_task.assert_awaited_once()
|
||||
call_args = mock_chore.claim_doc_task.call_args
|
||||
assert str(call_args.args[1]) == _TASK_ID
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_documented_dispatches_notes_and_files() -> None:
|
||||
"""POST /api/v2/flow/documenter/i_documented forwards notes and files."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.i_documented = AsyncMock(
|
||||
return_value=_make_envelope(status="awaiting_pm_review", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/documenter/i_documented",
|
||||
json={
|
||||
"task_id": _TASK_ID,
|
||||
"notes": "Documented the auth endpoint in docs/api/auth.md",
|
||||
"files": ["docs/api/auth.md"],
|
||||
},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "awaiting_pm_review"
|
||||
mock_chore.i_documented.assert_awaited_once()
|
||||
call_args = mock_chore.i_documented.call_args
|
||||
assert str(call_args.args[1]) == _TASK_ID
|
||||
assert call_args.args[2] == "Documented the auth endpoint in docs/api/auth.md"
|
||||
assert call_args.args[3] == ["docs/api/auth.md"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_am_idle_dispatches_agent_id() -> None:
|
||||
"""POST /api/v2/flow/documenter/i_am_idle delegates to Choreographer.i_am_idle."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.i_am_idle = AsyncMock(return_value=_make_envelope(status="idle"))
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/documenter/i_am_idle",
|
||||
json={},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "idle"
|
||||
mock_chore.i_am_idle.assert_awaited_once()
|
||||
|
||||
|
||||
def test_i_documented_rejects_empty_notes() -> None:
|
||||
"""POST i_documented rejects empty notes (min_length=1)."""
|
||||
mock_chore = MagicMock()
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/documenter/i_documented",
|
||||
json={"task_id": _TASK_ID, "notes": "", "files": ["docs/readme.md"]},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_422
|
||||
|
||||
|
||||
def test_i_documented_rejects_empty_files_list() -> None:
|
||||
"""POST i_documented rejects empty files list (min_length=1)."""
|
||||
mock_chore = MagicMock()
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/documenter/i_documented",
|
||||
json={"task_id": _TASK_ID, "notes": "some docs", "files": []},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_422
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Unit tests for /api/v2/flow/main_pm/* endpoints.
|
||||
|
||||
Uses a minimal FastAPI test client built from the new router only.
|
||||
No DB required — Choreographer is mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from roboco.api.deps import get_choreographer
|
||||
from roboco.api.routes.v2.flow_main_pm import router
|
||||
|
||||
_HTTP_200 = 200
|
||||
_HTTP_422 = 422
|
||||
|
||||
_AGENT_ID = str(uuid4())
|
||||
_TASK_ID = str(uuid4())
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID}
|
||||
|
||||
|
||||
def _make_envelope(
|
||||
status: str = "ok", task_id: str | None = None, **extra: object
|
||||
) -> MagicMock:
|
||||
"""Return a mock Envelope whose as_dict() returns a predictable payload."""
|
||||
env = MagicMock()
|
||||
payload: dict[str, object] = {"status": status, "task_id": task_id, "next": "..."}
|
||||
payload.update(extra)
|
||||
env.as_dict.return_value = payload
|
||||
return env
|
||||
|
||||
|
||||
def _build_app(mock_choreographer: MagicMock) -> FastAPI:
|
||||
"""Build minimal FastAPI app with the flow_main_pm router and a mocked dep."""
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_choreographer] = lambda: mock_choreographer
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_triage_all_returns_envelope() -> None:
|
||||
"""POST /api/v2/flow/main_pm/triage_all returns 200 with task or idle status."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.triage_all = AsyncMock(
|
||||
return_value=_make_envelope(status="awaiting_pm_review", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/main_pm/triage_all",
|
||||
json={},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "awaiting_pm_review"
|
||||
mock_chore.triage_all.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_calls_main_pm_complete_directly() -> None:
|
||||
"""POST /api/v2/flow/main_pm/complete calls main_pm_complete (not dispatch)."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.main_pm_complete = AsyncMock(
|
||||
return_value=_make_envelope(status="awaiting_ceo_approval", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/main_pm/complete",
|
||||
json={"task_id": _TASK_ID, "notes": "Root task done, escalating to CEO."},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "awaiting_ceo_approval"
|
||||
mock_chore.main_pm_complete.assert_awaited_once()
|
||||
call_args = mock_chore.main_pm_complete.call_args
|
||||
assert str(call_args.args[1]) == _TASK_ID
|
||||
assert call_args.args[2] == "Root task done, escalating to CEO."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escalate_up_dispatches_reason() -> None:
|
||||
"""POST /api/v2/flow/main_pm/escalate_up forwards task_id and reason."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.escalate_up = AsyncMock(
|
||||
return_value=_make_envelope(status="awaiting_ceo_approval", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/main_pm/escalate_up",
|
||||
json={"task_id": _TASK_ID, "reason": "Needs CEO sign-off on architecture."},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
mock_chore.escalate_up.assert_awaited_once()
|
||||
call_args = mock_chore.escalate_up.call_args
|
||||
assert call_args.args[2] == "Needs CEO sign-off on architecture."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unblock_dispatches_task_id_with_restore_true() -> None:
|
||||
"""POST /api/v2/flow/main_pm/unblock forwards task_id with restore=True default."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.unblock = AsyncMock(
|
||||
return_value=_make_envelope(status="in_progress", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/main_pm/unblock",
|
||||
json={"task_id": _TASK_ID},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
mock_chore.unblock.assert_awaited_once()
|
||||
call_kwargs = mock_chore.unblock.call_args.kwargs
|
||||
assert call_kwargs["restore"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_am_idle_dispatches_agent_id() -> None:
|
||||
"""POST /api/v2/flow/main_pm/i_am_idle delegates to Choreographer.i_am_idle."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.i_am_idle = AsyncMock(return_value=_make_envelope(status="idle"))
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/main_pm/i_am_idle",
|
||||
json={},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "idle"
|
||||
mock_chore.i_am_idle.assert_awaited_once()
|
||||
|
||||
|
||||
def test_complete_rejects_empty_notes() -> None:
|
||||
"""POST complete rejects empty notes (min_length=1)."""
|
||||
mock_chore = MagicMock()
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/main_pm/complete",
|
||||
json={"task_id": _TASK_ID, "notes": ""},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_422
|
||||
|
||||
|
||||
def test_escalate_up_rejects_empty_reason() -> None:
|
||||
"""POST escalate_up rejects empty reason (min_length=1)."""
|
||||
mock_chore = MagicMock()
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/main_pm/escalate_up",
|
||||
json={"task_id": _TASK_ID, "reason": ""},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_422
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Unit tests for /api/v2/flow/qa/* endpoints.
|
||||
|
||||
Uses a minimal FastAPI test client built from the new router only.
|
||||
No DB required — Choreographer is mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from roboco.api.deps import get_choreographer
|
||||
from roboco.api.routes.v2.flow_qa import router
|
||||
|
||||
_HTTP_200 = 200
|
||||
_HTTP_422 = 422
|
||||
|
||||
_AGENT_ID = str(uuid4())
|
||||
_TASK_ID = str(uuid4())
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID}
|
||||
|
||||
|
||||
def _make_envelope(
|
||||
status: str = "ok", task_id: str | None = None, **extra: object
|
||||
) -> MagicMock:
|
||||
"""Return a mock Envelope whose as_dict() returns a predictable payload."""
|
||||
env = MagicMock()
|
||||
payload: dict[str, object] = {"status": status, "task_id": task_id, "next": "..."}
|
||||
payload.update(extra)
|
||||
env.as_dict.return_value = payload
|
||||
return env
|
||||
|
||||
|
||||
def _build_app(mock_choreographer: MagicMock) -> FastAPI:
|
||||
"""Build minimal FastAPI app with the flow_qa router and a mocked dep."""
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_choreographer] = lambda: mock_choreographer
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_give_me_work_returns_envelope() -> None:
|
||||
"""POST /api/v2/flow/qa/give_me_work returns 200 with envelope shape."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.give_me_work = AsyncMock(return_value=_make_envelope(status="idle"))
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/qa/give_me_work",
|
||||
json={},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "idle"
|
||||
mock_chore.give_me_work.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_review_dispatches_task_id() -> None:
|
||||
"""POST /api/v2/flow/qa/claim_review returns 200 with evidence.pr_url in body."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.claim_review = AsyncMock(
|
||||
return_value=_make_envelope(
|
||||
status="claimed",
|
||||
task_id=_TASK_ID,
|
||||
evidence={"pr_url": "https://github.com/org/repo/pull/42"},
|
||||
)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/qa/claim_review",
|
||||
json={"task_id": _TASK_ID},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["evidence"]["pr_url"] == "https://github.com/org/repo/pull/42"
|
||||
mock_chore.claim_review.assert_awaited_once()
|
||||
call_args = mock_chore.claim_review.call_args
|
||||
assert str(call_args.args[1]) == _TASK_ID
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pass_review_with_notes_returns_awaiting_documentation() -> None:
|
||||
"""POST /api/v2/flow/qa/pass with notes returns 200 with awaiting_documentation."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.pass_review = AsyncMock(
|
||||
return_value=_make_envelope(status="awaiting_documentation", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/qa/pass",
|
||||
json={
|
||||
"task_id": _TASK_ID,
|
||||
"notes": "All acceptance criteria met, tests green.",
|
||||
},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "awaiting_documentation"
|
||||
mock_chore.pass_review.assert_awaited_once()
|
||||
call_args = mock_chore.pass_review.call_args
|
||||
assert call_args.args[2] == "All acceptance criteria met, tests green."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pass_review_short_notes_returns_tracing_gap_envelope() -> None:
|
||||
"""POST /api/v2/flow/qa/pass with minimal notes relies on choreographer to gate."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.pass_review = AsyncMock(
|
||||
return_value=_make_envelope(status="tracing_gap", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/qa/pass",
|
||||
json={"task_id": _TASK_ID, "notes": "ok"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "tracing_gap"
|
||||
mock_chore.pass_review.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fail_review_with_issues_returns_needs_revision() -> None:
|
||||
"""POST /api/v2/flow/qa/fail with issues returns 200 with needs_revision status."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.fail_review = AsyncMock(
|
||||
return_value=_make_envelope(status="needs_revision", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/qa/fail",
|
||||
json={
|
||||
"task_id": _TASK_ID,
|
||||
"issues": ["Missing error handling", "No unit tests"],
|
||||
},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_200
|
||||
body = resp.json()
|
||||
assert body["status"] == "needs_revision"
|
||||
mock_chore.fail_review.assert_awaited_once()
|
||||
call_args = mock_chore.fail_review.call_args
|
||||
assert call_args.args[2] == ["Missing error handling", "No unit tests"]
|
||||
|
||||
|
||||
def test_fail_review_rejects_empty_issues_list() -> None:
|
||||
"""POST /api/v2/flow/qa/fail with empty issues list is rejected with 422."""
|
||||
mock_chore = MagicMock()
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v2/flow/qa/fail",
|
||||
json={"task_id": _TASK_ID, "issues": []},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_422
|
||||
Reference in New Issue
Block a user