Files
roboco/tests/integration/test_tasks_routes.py
T
110aaa7a77 Chore: v1 removal gateway canonical (#46)
* chore(agent_sdk): remove dead /traceability/remind endpoint and reminder map

The TRACEABILITY_REMINDERS dict and its /traceability/remind endpoint were
keyed entirely on pre-gateway tool names (roboco_task_*, roboco_journal_*,
roboco_message_send, roboco_session_create_for_tasks) deleted in the gateway
cutover. The endpoint had zero callers; v2 enforces traceability server-side
in the Choreographer.

* fix(bootstrap,seeds): onboarding prompts call give_me_work(), not deleted roboco_task_scan()

The startup prompt and the seeded cell/all-hands channel onboarding messages
instructed agents to call roboco_task_scan() — a tool removed in the gateway
cutover. Point them at the live give_me_work() flow verb.

* fix: replace remaining deleted v1 tool names with gateway verbs

Spawn prompts, onboarding strings, remediation messages, and comments still
referenced pre-gateway tools deleted in the cutover (roboco_task_*,
roboco_agent_idle, roboco_notify_*, roboco_message_send,
roboco_session_create_for_tasks, roboco_journal_*, roboco_escalate). Rewrote
each to the correct role-scoped gateway verb (give_me_work/i_will_work_on for
workers, triage for PMs, i_am_done vs complete, notify/notify_ack, escalate_up,
unclaim, i_documented, open_session, note). Updated one enforcement-message
test that matched the old tool name by coincidence.

* test: guard against deleted v1 tool names reappearing in roboco/

Scans roboco/ for the deleted pre-gateway tool names; excludes the orphaned
roboco/agents/ subtree (removed in a later phase).

* chore(exceptions): drop 8 unused pre-gateway exception classes + their tests

LLMError, RAGError, AlreadyExistsError, TaskBlockedError, TaskClaimError,
AgentNotAvailableError, AgentBusyError, NotificationPermissionError were never
raised in production. SessionClosedError/DatabaseError are kept (live + tested).

* chore(models): drop unused pre-gateway notification/channel/handoff factories

Removes create_task_assignment/_blocker_escalation/_review_request/
_documentation_request/_priority_change/_alert/_broadcast, create_cell_channel/
_cross_cell_channel/_announcements_channel, create_handoff (+ HandoffParams),
ProactiveContext, and A2APartType. The gateway choreographer builds these
server-side now. Drops the matching dead-code tests.

* chore(services): drop unused pre-gateway permission/messaging/audit/optimal/remediation methods

These pre-gateway helpers (channel-permission checks, channel-membership ops,
permission-denial audit hooks, doc ingestion, two remediation hints) have no
production caller — the gateway role_config + enforcement layer replaced them.
Drops the matching dead-code tests; live methods (send_message, the SESSION_*
flow, log_task_action_denial, etc.) are untouched.

* chore(orchestrator,ws,events,config): drop unused pre-gateway lifecycle/broadcast/roster symbols

orchestrator: get_running_agents, is_agent_busy, queue_priority_work,
get_all_instances (+ their OrchestratorAccessProtocol declarations in events.py).
websocket: broadcast_new_message, broadcast_session_closed (no event type emits
them). agents_config: ALL_PMS/ALL_DEVS/ALL_QA/CELL_PMS roster constants (ALL_DOCS
stays — it gates docs-write workspace perms).

* refactor(agents): delete orphaned pre-gateway agent subtree + dead organization model

The Gateway/full cutover replaced the Python agent-class implementations with
the server-side Choreographer; the classes survived only as a self-referential
island. Removes roboco/agents/{base,mixins,factory,board,developer,documenter,
pm,qa,orchestrator}.py and roboco/agents/factories/{board,cells,developers,
documenters,pms,qa}.py, plus roboco/models/organization.py (Cell/Board/
Organization — used only by those factories). Keeps factories/_base.py
(compose_prompt — the live prompt-layer composer the orchestrator calls at
spawn) behind minimal package __init__ files.

* chore(db): drop dead tasks.execution_log + outputs columns (migration 015)

Both JSON columns had zero readers/writers in code, tests, and migrations —
execution progress is tracked via progress_updates and artifacts via
commits/documents. Removes the ORM columns, the Pydantic Task.execution_log/
outputs fields, the ExecutionLog/FileRef models (+ their __init__ exports), and
the now-invalid kwargs from test fixtures. Migration 015 (down_revision
014_drop_pm_approvals) verified live: upgrade drops, downgrade re-adds.
Apply on the NAS with 'alembic upgrade head' at next deploy.

* chore(config): drop 16 unread Settings fields

Verified unused (no settings.X, no self.X property use, no getattr-by-name):
app_name, reload, workers, openai_api_key, secret_key, access_token_expire_minutes,
algorithm, log_level, log_format, the four session_* limits, message_max_length,
commit_subject_min_chars, commit_banned_words, agent_budget_sweep_interval_seconds.
Removes the empty Logging + Sessions&Messages sections and orphaned .env.example
vars. Kept: redis_db/redis_password (redis_url property), agent_sla_* (read via
getattr in task_lifecycle), encryption_key, and all live thresholds.

NOTE: commit_banned_words/commit_subject_min_chars and
agent_budget_sweep_interval_seconds were feature-config never wired to their
consumer (commit validator / budget sweep) — removed as dead, but flagged in
case the intent was to wire them.

* test(lifecycle): give i_will_work_on calls a substantive plan (#171 contract)

The real-DB lifecycle tests called i_will_work_on with a 13-char plan and no
risks/technical_considerations, so the substantive-plan gate (#171) rejected
them with incomplete_input — failing on master. Supply a >=150-char plan plus
technical_considerations and risks (mirroring tests/unit/gateway/
test_choreographer_dev.py). All 6 now pass; gate runs with no deselect.

* feat(gateway): wire commit-validator thresholds to settings

commit_subject_min_chars and commit_banned_words were config defined but never
read — the gateway commit() gate used the validator's hardcoded module defaults.
Re-add the two Settings fields and pass them through validate_commit_message in
content_actions.commit(), so config is the source of truth (validator defaults
remain the standalone/CI fallback). Adds wiring tests that monkeypatch settings
and assert the gate honors them.

* refactor(orchestrator): retire gateway_enabled flag; trigger_filter is unconditional

The gateway_enabled Settings field gated only the trigger_filter spawn-cooldown
(never the agent tool surface). Prod ran it on; the Phase-0 'legacy dispatch
path' it guarded no longer exists. Remove the field + the early-return branch in
gateway_pre_spawn_check so the cooldown runs for every spawn, drop the now-dead
ROBOCO_GATEWAY_ENABLED from docker-compose.yml, and update the stale Phase-0
comments + cooldown test. The per-container ROBOCO_GATEWAY_ENABLED env (set by
_append_manifest_args, read by agent_sdk to load the manifest) is unaffected.

* refactor(api): relabel /api/v2 -> /api/v1 as the canonical gateway surface

The gateway is the only agent API now, so the 'v2' label (with no v1) was
misleading. Renames roboco/api/routes/v2 -> routes/v1, schemas/v2 -> schemas/v1
(+ the matching test dirs and test_v2_role_dep/test_schemas_v2_flow files),
rewrites every /api/v2 path, routes.v2/schemas.v2 import, and v2-* router tag to
v1, and refreshes the stale 'v2' comments/docstrings. The panel is untouched (it
uses the unversioned /api/* REST routes). flow_server/do_server now POST to
/api/v1/*.

* docs(scripts): reset_runtime_state header matches actual SQL behavior

The header claimed it preserves groups + journals, but the .sql wipes both
(verified live: groups 6->0, journals 5->0; only agents/projects/channels
survive). Correct the wiped/preserved lists to match.

* refactor(gateway): extract _build_rich_plan to drop i_will_work_on under the complexity gate

i_will_work_on was cyclomatic rank C (11) — one over the xenon --max-absolute B
threshold — because of the five `x or default` fallbacks in the rich_plan dict.
Move that dict into a small _build_rich_plan helper (behaviour identical); both
methods are now rank B. make quality is fully green (xenon was its last failure;
bandit already passed — its 34 findings are all LOW severity, filtered by -ll).

* feat(foundation): add canonical CELL_TEAMS set; dedupe cell-subset literals

* feat(db): add ProductTable + ProductProjectTable ORM (per-cell project map)

* feat(task): add additive nullable product_id (ORM + model + DTO + create threading)

* feat(task): thread product_id through create_subtask/route/response

* feat(db): migration 016 — products, product_projects, tasks.product_id

* fix(db): document migration 016 plan deviations (revision len, FK name)

Two values in migration 016 intentionally diverge from the Task 2.4 plan
literals; this strengthens the in-file justification so the deviations are
self-documenting and verifiable.

- revision id (plan line 623): the plan's 36-char
  "016_add_products_and_task_product_id" overflows alembic_version.version_num
  (VARCHAR(32)) — alembic upgrade head raises asyncpg
  StringDataRightTruncationError. Kept at 27 chars
  ("016_add_products_product_id") so Step 4's live round-trip stays green.
- downgrade FK name (plan line 683): roboco/db/base.py sets a metadata
  naming_convention, so the FK upgrade() creates is
  "fk_tasks_product_id_products", not the Postgres default
  "tasks_product_id_fkey". The plan literal does not exist in the DB and
  would fail the downgrade with "constraint does not exist".

Both verified via the live upgrade/downgrade round-trip on a throwaway DB.

Issue 3 note: the prior commit (b896cac) also touched
tests/unit/api/test_schemas_tasks.py (added product_id=None to the
task_to_response stub). That line is load-bearing — task_to_response reads
task.product_id (added in Task 2.3, commit 67afa6b) — and belongs to Task 2.3's
scope; it is left in place because removing it breaks 4 tests and history is
not rewritten.

* refactor(db): trim migration 016 deviation notes to plan-faithful form

Reverts the out-of-scope documentation expansion (commit 1a4f296), which
was a second undocumented commit beyond Task 2.4's single plan-specified
commit and only bloated the migration docstring/comments.

The migration file now matches the plan-specified commit (b896cac) byte for
byte: the two necessary deviations from the plan literals stay (revision id
shortened to fit alembic_version.version_num VARCHAR(32); downgrade FK name
follows db/base.py's metadata naming_convention), each kept to a concise
inline note in the plan's header style.

The Task 2.3-scoped test stub line (tests/unit/api/test_schemas_tasks.py
product_id=None) is load-bearing — task_to_response reads task.product_id —
and is left in place; history is not rewritten.

Verified: live alembic upgrade head + downgrade to 015 round-trip on a
throwaway DB drops products/product_projects/tasks.product_id cleanly, and
make quality is green.

* refactor(test): annotate db_session and drop type: ignore in migration 016 test

Annotate the test_products_tables_and_task_fk_exist param as
db_session: AsyncSession (imported under TYPE_CHECKING) and remove the
# type: ignore[no-untyped-def] suppression, matching the typed db_session
pattern used across tests/integration/.

* feat(models): Product + ProductCreate/Update + ProductCellMapping (cell-validated)

* refactor(models): minimize ProductCellMapping config override to use_enum_values

The previous override re-declared validate_assignment, populate_by_name,
and extra=forbid, which RobocoBase already supplies. Pydantic merges
model_config across inheritance, so overriding only use_enum_values=False
is sufficient to keep team as a real Team enum (required so team in
CELL_TEAMS and enum identity hold for callers) while inheriting the rest
of the base config.

* fix(models): document ProductCellMapping use_enum_values override as plan-mandated

Resolves SPEC-COMPLIANCE review notes for Task 3.1 (Product domain models).

1. The ProductCellMapping use_enum_values=False override is a deviation from a
   bare project.py mirror, but it is mandated by the plan's own Task 3.1 code:
   RobocoBase sets use_enum_values=True, which coerces team to the plain string
   "backend". The plan's Step 1 test asserts m.team is Team.BACKEND (enum
   identity) and the Step 3 validator formats its error with v.value, both of
   which require team to remain a real Team enum. The override is therefore
   necessary; this commit relabels the comment to cite the specific spec lines
   that force it instead of leaving it as an unexplained departure. Downstream
   Task 3.2 (_replace_cells / project_for) already tolerates either form and the
   ORM stores the same value regardless, so the override has no behavioral reach
   beyond the in-memory enum identity the plan's test checks.

2. test_product_model.py hoists 'from uuid import uuid4' to module level rather
   than inline (as the plan's verbatim Step 1 code shows) because the global
   Pylint PLC0415 rule (import-outside-top-level) forbids inline imports and
   there is no per-file-ignore for tests/unit/models/. The hoisted form is the
   only ruff-clean rendering of the plan's test; left unchanged here.

3. Task 3.1 landed across two commits (c616d95 create, 6ebad255 refactor) rather
   than the plan's single Step 5 commit. Earlier history is intentionally not
   rewritten; this single follow-up commit brings the model to its final
   spec-faithful, fully-documented state.

* feat(service): ProductService CRUD + project_for per-cell resolver

* feat(api): Product CRUD routes + schemas, wired into the app

* fix(api): roll back and map cell-replacement IntegrityError on product update

update_product replaced cells via ProductService._replace_cells without
any try/except, so a duplicate-team cell (uq_product_projects_product_team)
or a non-existent project_id (product_projects.project_id FK) raised an
IntegrityError at flush, poisoning the AsyncSession and surfacing an
unhandled 500 with no rollback. Wrap the update + commit in a try/except
that rolls back and maps the UNIQUE violation to 409 and the FK violation
to 422, mirroring create_product's rollback discipline. Add integration
tests covering both client-error paths.

* fix(api): map create_product cell-mapping IntegrityError to 409/422

create_product only caught the slug conflict ('already exists' in str(e))
and bare-raised everything else, so a cells entry whose project_id does not
reference any project let the product_projects.project_id FK IntegrityError
propagate out of the route as an unhandled 500. The matching update_product
path was already hardened (uq_product_projects_product_team -> 409, FK
violation -> 422); apply the same mapping in create_product so a bad
project_id (or a duplicate-team cell) is a client error, not a server error.
The slug conflict is now caught as ConflictError directly instead of via a
broad except + string match.

* feat(gateway): add optional project_id to delegate inputs/request/routes

* feat(gateway): per-cell project routing (override -> product map -> parent) + product_id inheritance

* feat(task): approve_and_start — reassign board task to Main PM (CEO gate #1)

* feat(api): POST /tasks/{id}/approve-and-start (CEO gate #1, notes-required)

* test(api): cover approve-and-start 404-before-notes-gate for missing task

* feat(panel): Product types + Task.product_id

* feat(panel): productsApi + hooks + tasksApi.approveAndStart

* feat(panel): Products management screen + sidebar nav

* feat(panel): Approve & Start button (CEO gate #1)

* fix(api): narrow delete_product to IntegrityError + cover 204/409 delete paths

* test(task): assert approve_and_start persists + appends the audit note

* refactor(db): migration 016 names the tasks.product_id FK explicitly (house style)

* fix(db): make migrations authoritative + self-heal orphan product tables

init_db() no longer silently falls back to create_all when alembic upgrade
fails. That fallback masked migration failures and, since create_all cannot
ALTER an existing table, left the schema inconsistent — turning an unapplied
migration 016 into a crash loop: 016's CREATE TABLE products failed, the
upgrade rolled back, create_all re-created an empty orphan products table, and
every later boot failed again on the now-existing table while tasks.product_id
never got added. Now a migration failure is raised so the real error surfaces.

Migration 016 additionally drops EMPTY orphan products/product_projects tables
left by the old fallback before creating them, so an already-polluted DB
self-heals on the next deploy with no manual SQL. Skipped in offline (--sql)
mode; refuses to drop a table that holds rows.

* fix(db): create_all is the schema source of truth; alembic for increments

The Alembic chain is incomplete relative to the ORM — columns/tables like
notifications.delivered_at and the RAG indexed_documents table have NO migration
and have only ever been materialized by create_all. Tests don't catch this
because the test DB is also built via create_all, so migrations are never
exercised. The prior 'migrations are authoritative' init_db (and before it, the
create_all-only-on-failure fallback) therefore left a migrate-only boot with
missing columns/tables.

init_db now reflects reality:
  - Fresh DB  -> create_all builds the full current ORM schema, then stamp
                 Alembic at head so later incremental migrations apply.
  - Existing  -> run pending migrations (a real failure is raised, not masked),
                 then create_all(checkfirst) to gap-fill any missing ORM tables.
create_all cannot add a column to an existing table, so an ORM column added
without a migration needs a fresh rebuild of that table to appear.

* fix(db): migration 017 reconciles the Alembic chain with the full ORM schema

For years the live schema was built by create_all, not migrations, so the chain
drifted — tables/columns/indexes in the ORM had no migration (the
indexed_documents table, notifications.delivered_at, ~15 indexes, plus
timestamptz/server-default metadata). With init_db no longer masking that via a
create_all fallback, a migrate-only boot was missing those objects.

017 was produced by 'alembic revision --autogenerate' against Base.metadata,
reviewed, and verified: on a fresh DB, 'alembic upgrade head' (001..017) now
reproduces the create_all schema EXACTLY — a re-run of autogenerate detects zero
changes — and the 017 upgrade/downgrade round-trips cleanly. The migration chain
is now complete: migrate-only and create_all converge.

Also updates the init_db tests to assert the new behaviour (raise on an existing
DB's migration failure; create_all + stamp head on a fresh DB) instead of the
removed silent fallback.

* feat(panel): Product picker in the New Task form (drives per-cell routing)

The Products screen and Approve & Start button shipped, but the task-creation
form had no way to attach a Product — so a human couldn't set product_id from
the UI, which is exactly what drives per-cell project routing of delegated
subtasks. Adds an optional Product dropdown (Advanced -> Git config) populated
from useProducts(); 'None' falls back to the single project.

* fix(db): seed data is preserved on a fresh DB (run migrations, not bare create_all)

The previous fresh-DB path (create_all + stamp head) built the tables but never
ran the migration chain, so migration-embedded SEED DATA was skipped — most
visibly the AI providers seeded in 004. After a DB reset that left
provider_configs empty, so PUT /api/providers/ollama-key 404'd (the handler
raises NotFoundError when the Ollama provider row is missing).

Since migration 017 made the chain reproduce the full ORM schema, init_db now
runs 'alembic upgrade head' from base on a fresh DB — building every
table/column/index AND running the seeds. Verified: a fresh upgrade head seeds
both provider rows. Existing DBs still get migrations + create_all gap-fill.
Updates the init_db fresh-DB test accordingly.

* feat(task): project_id optional when a product_id is set (board fan-out tasks)

A board task that fans out across cells via a Product has no single repo of its
own — backend/frontend/ux_ui are each wrong, because the root coordinates and
delegates. Forcing one arbitrary Project was broken design (flagged at design
time). project_id is now nullable; a task must have project_id OR product_id:
  - TaskCreate model validator + a TaskService.create() invariant (covers every
    create path).
  - ORM/DTO/schema: project_id nullable; task_to_response uses to_python_uuid.
  - Gateway: a parent with only a product can delegate (guard now needs BOTH
    project and product to be None to reject); _resolve_subtask_project resolves
    each subtask from the product map and raises a clear error if a cell has no
    mapping and no parent project.
  - Migration 018 (tasks.project_id nullable), round-trip verified; fresh
    upgrade head still seeds providers.
  - Panel: Project no longer required once a Product is selected.
  - Removed the dead, never-called a2a create_task_from_message (it could only
    ever create a repo-less task) + its two coverage-only tests.

make quality green; panel tsc/lint/build green.

* Upgrade to Minimax M3

* fix(db): seed providers on existing DBs + correct enum casing

Migration 004 created the modelprovider/assignmentscope enums and seeded
provider rows in UPPERCASE, but the ORM (_str_enum) reads/writes the
lowercase StrEnum .value — so a fresh migrate-from-base DB built an enum
the ORM cannot read. Lowercase the enum labels and seed values in 004.

Add idempotent migration 019 to (re)seed the Anthropic + Ollama Cloud
providers with ON CONFLICT (name) DO NOTHING, so an existing DB whose
provider_configs table was created by create_all (and never ran 004's
seed) gets the rows on the next `alembic upgrade head` — fixing the
/api/providers/ollama-key 404 without a volume wipe.

* fix(tasks): let board/fan-out coordination tasks flow without a repo

A coordination task (project_id NULL, product_id set) targets no repo of
its own — it fans out to cell subtasks that each resolve a real project
from the product's cell->project map. Several paths still assumed every
task does git work and blocked it:

- orchestrator: add _is_coordination_task() and exempt these tasks from
  the project/branch/git-token gates in _readiness_check_task,
  _readiness_gate, _check_stuck_conditions, _validate_task_for_spawn.
- services/task.py: _ensure_branch_for_task returns "" (no branch) for a
  coordination task instead of raising; activate requires project OR
  product. This unblocks Main PM's i_will_plan claim, which otherwise
  raised before it could delegate the fan-out.
- gateway: _pending_assignment_guard exempts advisory roles
  (product_owner/head_marketing/auditor) from the "assigned but never
  claimed" idle gate — they review without claiming, so they could not
  satisfy a claim-or-unclaim remediation.

Adds focused unit tests for each.

* fix(tasks): coordination tasks reach in_progress + team reflects Main PM

The board->cells fan-out deadlocked: a coordination/fan-out task (product set,
no project of its own) could be created and claimed, but start()'s
claimed->in_progress transition hit validate_git_requirements, which still
demanded a branch_name and raised GitRequirementError. So Main PM's i_will_plan
never completed — it looped and never delegated. c961282 exempted
_ensure_branch_for_task (branch creation) but missed this parallel git gate in
the enforcement layer.

- task_lifecycle.py: add GitContext.is_coordination; skip the
  claimed->in_progress branch_name gate when it is set.
- task.py: populate is_coordination=(project_id is None and product_id is not
  None) in _validate_and_set_status; a branchless code task is still gated.
- approve_and_start: set team=Team.MAIN_PM on hand-off so the task isn't left
  labelled team=board after it leaves the board (now assigned to main-pm).

Adds a lifecycle-gate unit test and an end-to-end integration test that
claims, plans, and starts a project-less coordination task.

* fix(hooks): remove dead traceability hook + stale deleted-verb references

The v1-removal cleanup (2cfbf39) deleted the /traceability/remind SDK endpoint
but left the PostToolUse hook that curls it, so every gateway tool call 404'd
and agents silently lost their traceability reminders. Remove the dangling hook
(registration + TRACEABILITY_TRIGGER_TOOLS + Dockerfile COPY + the script); v2
carries per-verb guidance on the Envelope. Also correct two stale pre-gateway
tool names in hook text: the budget loop-detector nudged agents toward the
deleted roboco_task_escalate() (now unclaim()/i_am_idle(), which every looping
role has), and an sdk-startup comment referenced roboco_task_scan/get.

Extends the deleted-tool-name guard to scan docker/scripts/*.sh and to assert
every $SDK_URL/<path> a hook curls is a route still served by the SDK — the
check that would have caught this class (it lives in shell, invisible to mypy
and the Python import graph).

* fix(db): backfill ORM enum values the migration chain never added

Several StrEnum values were added to the ORM over time without a matching
`ALTER TYPE ... ADD VALUE` migration; 017 was autogenerate-derived and
autogenerate does not detect added enum labels, so the drift survived. On a DB
whose enum type predates the value, binding it raises at runtime — e.g.
`invalid input value for enum notificationtype: "a2a_request"` on
GET /api/notifications (list_system_notifications), and the same class for
blockerresolvertype/handoffstatus/team.

Migration 020 adds every drifted value idempotently (ADD VALUE IF NOT EXISTS —
no-op when 009 already reconciled it). Runs on the next `alembic upgrade head`.

Detected by comparing each ORM enum's values to the labels the migration chain
produces; adds tests/unit/test_enum_migration_parity.py which renders the chain
offline and fails on any future drift — the check that would have caught both
this and the provider-enum bug.

* fix(orchestrator): stop branch auto-block, board reassign, unblock livelock, agentless claims

Cluster C1 — four coupled orchestrator/task-invariant defects:

#18: a branch is created only at claim, so a pending, never-claimed code task
legitimately has no branch_name. The stuck-detection sweep (pending-only) and
readiness gate flagged that as "Task missing branch_name" and auto-blocked the
task every tick, so it never dispatched. Centralize the gate in
_branch_is_expected (status in claimed/in_progress/verifying, never a
coordination task) and apply it in both _check_stuck_conditions and
_readiness_check_task.

#14: the main_pm -> product_owner escalation rung handed an in_progress
descendant code task to the Product Owner (a board role) and marked it BLOCKED;
the board has no verb to own code work, so the dev's finished work deadlocked.
TaskService.apply_escalation (the single write primitive — covers both the
gateway escalate verb and the HTTP escalate route) now diverts a descendant code
task targeting a board/advisory role: it releases the task to PENDING for a
role-matched cell claim instead of stranding it.

#17: a blocked task reassigned to Main PM kept respawning the ex-assignee cell
PM to unblock it, but the assignee-only pre-unblock note returned not_authorized
— a livelock. _dispatch_blocker_work now dispatches the task's CURRENT PM/board
assignee (the unblock authority), falling back to the cell PM only when no
PM/board holds it. Also: a branchless coordination parent yields no valid merge
target — resolve_parent_branch now falls back to the child's own project default
branch (e.g. master) via TaskService.project_default_branch_for_task, and
_check_parent_branch_ready no longer blocks a child on a coordination parent's
non-existent branch.

#19: a task left claimed/in_progress with an assignee but no running container
was invisibly stuck (only PENDING tasks get fresh dispatch; the heartbeat reaper
can't see a freshly-seeded claim). New _dispatch_claimed_without_agent net:
after a short grace window it respawns the assignee, or releases the claim to
pending (lifecycle-safe via unclaim_for_reaper) when the assignee is unknown.
New config ROBOCO_CLAIMED_NO_AGENT_GRACE_SECONDS (default 120).

* fix(gateway): tolerant note verb + lock evidence do-tool invariant

#15: the note verb no longer hard-rejects thin decision/reflect payloads.
List-typed fields (options, consequences, next_steps) coerce a lone scalar
into a one-element list at both the NoteRequest schema (mode=before
validator) and the service layer; missing narrative fields default to a
visible placeholder instead of returning incomplete_input. The note is
always recorded, preserving audit value, and a well-intentioned note can no
longer trip the do-server 3-strikes circuit breaker. Widen the agent-facing
do_server.note hints to accept list-or-scalar and refresh the docstrings.

#8: add regression coverage locking the invariant that every role's do_tools
carries evidence (role_config + developer spawn manifest). The current source
already registers mcp__roboco-do__evidence for developers end-to-end; the
report stemmed from a stale deployed build, and the tests prevent silent
regression.

* fix(gateway): allow UX devs to receive design tasks; surface delegation rules to cell PM

The UX/UI cell's developers (ux-dev-1/ux-dev-2, Role.DEVELOPER on
Team.UX_UI) ARE its designers, but _validate_assignee_task_type rejected
task_type='design' for every DEVELOPER, blocking the UX cell's normal
design delegation. Allow 'design' for UX-team devs only; backend/frontend
devs stay rejected (design routing belongs to the UX cell). The
orchestrator already dispatches a developer for a design task
(_dev_dispatch_role_matches returns True), so this creates no orphan like
the documentation case.

Replace the static Cell-PM 'pass planning' remediate with a per-assignee
hint so a dev/design mis-type gets a developer-class next-step instead of
an off-topic planning hint.

Surface the three delegation guardrails in the cell-PM prompt so PMs stop
probing them by trial and error: valid task_type per assignee (incl.
design for UX devs), documentation auto-creation (non-delegatable), and
the sequential single-active code-spine. Fix the delegate-row task_type
list (documentation is NOT delegatable) and update the lifecycle spec
description; regenerate the lifecycle artifacts.

* fix(orchestrator): improve agent briefings for handoff consumption, product/project model, and workspace/secret hygiene

Main PM (roles/main_pm.md):
- Require reading the upstream Product Owner / Head of Marketing handoff
  (their decision/reflect journal entries + task description) BEFORE doing
  any own research or calling i_will_plan, so the Main PM builds on the
  Board's analysis instead of duplicating it. Added a dedicated section,
  hardened workflow step 1, and added an anti-pattern.
- Add a 'Products vs Projects' section: a Product fans out to one Project
  per cell; those Projects may be the SAME repo (monorepo subtrees) or
  DIFFERENT repos (multi-repo). The Main PM coordinates across them and
  must not assume one repo or call a monorepo subtree 'a separate repo'.
  Names the Prompter monorepo case (github.com/rennf93/roboco).

Developer (roles/developer.md):
- State the exact workspace path convention
  /data/workspaces/<project-slug>/<team>/<agent-slug>/, that the cwd is
  already set there, to stay inside the own cell workspace, and to not
  probe/guess the path (ls /, find /).
- Sanctioned secret handling: env/printenv is bash-guard denied and
  reveals nothing; needed secrets arrive via the task description, else
  i_am_blocked so the PM supplies them. Added matching anti-patterns.

Tests: add tests/unit/agents/test_briefing_cluster_c4.py asserting the
composed system prompt (the text mounted into agent containers) carries
each of the above.

* fix(orchestrator): board review involves PO+HoM and notifies CEO

Cluster C5 (#2, #4): a board/coordination task was reviewed by the Product
Owner alone, and the CEO got no formal signal when the review finished —
only buried channel chatter — so the Approve & Start handoff was invisible.

#4 — Board review is now a two-reviewer gate. _handle_board_assigned_task
dispatches BOTH the Product Owner and the Head of Marketing (one-shot each),
regardless of which one holds assigned_to, and the unassigned board-routing
path delegates here instead of claiming + spawning the PO alone. Board tasks
stay pending/unassigned for the CEO's Approve & Start. The board prompt now
makes the PO+HoM pair-review model explicit (HoM owns the UX/positioning
dimension).

#2 — Once BOTH reviewers have finished (dispatched and no longer active),
the orchestrator emits exactly one formal CEO notification via
NotificationService.send_board_review_complete_notification (APPROVAL type,
ack-required, carrying related_task_id) so the handoff is an actionable
signal. One-shot per task; a notification failure clears the guard so a
later tick can retry.

To let the non-assignee board member record its review note on a task held
by the other board member, content-action ownership now exempts a board role
posting to a board/coordination task (project_id is None, product_id set).
The exemption is narrow: it does not widen ownership for any other role or
any project-backed task.

Unit tests cover both reviewers dispatched, one-shot dispatch, the CEO
notification fired exactly once when both are done (and not before), the
retry-on-failure path, the notification builder, and the board co-review
ownership exemption (allowed for board+coordination, blocked otherwise).

* fix(workspace): install dev deps post-clone + raise git commit timeout for large changesets

Cluster C6 (#10, #13, #12-investigate).

#10: per-agent workspace clones never had the project's dev dependencies
installed, so the make-quality gate (ruff/mypy/pytest for Python, the TS
toolchain for the panel) was missing and devs re-downloaded tooling per
task. WorkspaceService now runs the project's install after cloning
(`uv sync` for Python, `pnpm install`/`npm ci`/`npm install` for Node/TS,
detected by manifest/lockfile). Idempotent via a lockfile-digest marker
under .git/ so a re-entry with unchanged lockfiles is a no-op; also runs on
the healthy short-circuit so pre-existing clones get backfilled. Gated by
workspace_install_dev_deps (default on) with workspace_dep_install_timeout_seconds.

#13: the gateway commit verb timed out on the large panel changeset because
every git op used the hardcoded 30s _GIT_TIMEOUT and each call also re-walks
the tree to chown. _run_git now takes a per-call timeout override sourced
from settings (git_command_timeout_seconds default); the staging + commit
ops in commit() and create_commit() use the longer git_commit_timeout_seconds
(default 180s). httpx REST timeouts unchanged in value.

#12 (investigate only — no push, no history change): the clone base ref is
NOT hardcoded; it already comes from project.default_branch threaded through
git.get_workspace -> ensure_workspace -> _clone_repo (git clone --branch).
The stale-base problem is a deploy/process issue (GitHub master is behind the
deployed migration chain), resolvable only by pushing the chain to master.
The default_branch column is the existing configurable lever.

* fix(panel): gate Approve & Start to board coordination tasks; stop 404 storm on closed sessions

CEO gate #1 button only renders for a PENDING board coordination/fan-out
task (no project_id, has product_id) — the board-reviewed handoff that
approve_and_start accepts — instead of every PENDING board-team task.
approve_and_start requires PENDING (it re-targets to Main PM without a
status change), so the gate stays on PENDING rather than the unrelated
end-of-work awaiting_ceo_approval state.

Session/message reads now treat a 404 as terminal and never retry it: a
reaped session is gone for good, and retrying every dead session-id is
what produced the growing 404 storm on GET /api/messages. The transcript
loads once (staleTime Infinity, no focus/reconnect refetch) so closed
sessions stay viewable without re-polling.

* fix(orchestrator): role-correct respawn prompt, throttle agentless dispatch, broaden #14 guard

#19 wrong-role prompt on respawn: _get_prompt_for_agent fell through to the
developer prompt for every non-dev/doc/qa role, so a respawned PM or board
agent was told to write code and call verbs it does not own. Route by the
agent's actual role through the existing per-role prompt builders
(developer/qa/documenter/cell_pm/main_pm/product_owner/head_marketing/auditor).
Both callers benefit; _spawn_pending_dev only ever passes developer/documenter/
unknown, so its behavior is unchanged.

#19 spawn-burst: _dispatch_claimed_without_agent looped over every agentless
claimed/in_progress task and could spawn many containers in one tick. Break
after the first respawn so a restart can't trigger a burst, matching every
sibling dispatcher. The release-to-pending path spawns nothing and keeps
draining stale unknown claims.

#14 guard scope: _is_descendant_code_task only matched CODE, so a descendant
DOCUMENTATION or DESIGN task escalated to a board/advisory role was still
stranded on a role with no verb to own it. Rename to
_is_descendant_executable_task and broaden to CODE/DOCUMENTATION/DESIGN — the
cell-executed types a board role cannot own. PLANNING/RESEARCH/ADMINISTRATIVE
route to a PM, not a cell agent, and are left unchanged; root tasks are still
reviewed up the chain.

* fix(docker): add node+pnpm to orchestrator so it pre-installs frontend cell deps

* Added .github workflows

* refactor(services): extract helpers to keep install_dev_deps + developer task-type check under the xenon complexity gate

* chore(github): add launch kit — CI, GHCR release, labels, templates, funding, dependabot npm, community docs

* chore(github): bump_version — drop unused noqa, fix datetime UTC import

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-03 06:35:03 +02:00

2911 lines
98 KiB
Python

"""Tasks API route coverage — list/get/lifecycle endpoints."""
from __future__ import annotations
from http import HTTPStatus
from types import SimpleNamespace
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch
from uuid import uuid4
import pytest
import pytest_asyncio
from fastapi import FastAPI, HTTPException
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.tasks import (
_translate_error,
get_awaiting_ceo_approval_tasks,
get_awaiting_pm_review_tasks,
)
from roboco.api.routes.tasks import (
router as tasks_router,
)
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.exceptions import TaskLifecycleError
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import (
TaskNature,
TaskStatus,
TaskType,
)
from roboco.models.permissions import AgentContext
from roboco.services.base import (
NotFoundError,
ServiceError,
UnauthorizedError,
ValidationError,
)
from roboco.services.notification_delivery import EscalationError
from roboco.services.permissions import PermissionService
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
@pytest_asyncio.fixture
async def task_client(
db_session: AsyncSession,
) -> AsyncIterator[dict]:
main_pm = AgentTable(
id=uuid4(),
name="MainPM",
slug=f"main-pm-{uuid4().hex[:8]}",
role=AgentRole.MAIN_PM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="pm",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(main_pm)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="TR-Proj",
slug=f"tr-proj-{uuid4().hex[:6]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=main_pm.id,
)
db_session.add(project)
await db_session.flush()
app = FastAPI()
app.include_router(tasks_router, prefix="/api/tasks")
async def _override_db():
yield db_session
async def _override_agent() -> AgentContext:
return AgentContext(agent_id=main_pm.id, role=AgentRole.MAIN_PM, team=None)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = _override_agent
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield {
"client": client,
"agent": main_pm,
"project": project,
"db": db_session,
}
app.dependency_overrides.clear()
_HDR = {"X-Agent-ID": str(uuid4()), "X-Agent-Role": "main_pm"}
def _seed_task(
setup: dict, *, status: TaskStatus = TaskStatus.PENDING, **kw
) -> TaskTable:
task = TaskTable(
id=uuid4(),
title=kw.pop("title", "t"),
description=kw.pop("description", "d"),
acceptance_criteria=["ac"],
status=status,
priority=kw.pop("priority", 2),
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
project_id=setup["project"].id,
created_by=kw.pop("created_by", setup["agent"].id),
team=kw.pop("team", Team.BACKEND),
**kw,
)
setup["db"].add(task)
return task
async def _seed_agent(
setup: dict, *, role: AgentRole = AgentRole.DEVELOPER
) -> AgentTable:
"""Seed a real agent so FK constraints don't break."""
other = AgentTable(
id=uuid4(),
name="Other",
slug=f"other-{uuid4().hex[:8]}",
role=role,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
setup["db"].add(other)
await setup["db"].flush()
return other
@pytest.mark.asyncio
async def test_create_task(task_client: dict) -> None:
client = task_client["client"]
response = await client.post(
"/api/tasks",
json={
"title": "Test Task",
"description": "Some description that is long enough for the schema",
"acceptance_criteria": ["criteria"],
"team": "backend",
"project_id": str(task_client["project"].id),
"task_type": "code",
"nature": "technical",
"estimated_complexity": "medium",
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.CREATED
@pytest.mark.asyncio
async def test_create_task_missing_project_id(task_client: dict) -> None:
"""Create with no project_id should fail validation."""
client = task_client["client"]
response = await client.post(
"/api/tasks",
json={
"title": "Test",
"description": "x",
"acceptance_criteria": ["a"],
"team": "backend",
},
headers=_HDR,
)
assert response.status_code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.UNPROCESSABLE_ENTITY,
)
@pytest.mark.asyncio
async def test_list_tasks(task_client: dict) -> None:
client = task_client["client"]
_seed_task(task_client)
await task_client["db"].flush()
response = await client.get("/api/tasks", headers=_HDR)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_list_tasks_filter_by_team(task_client: dict) -> None:
client = task_client["client"]
response = await client.get("/api/tasks?team=backend", headers=_HDR)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_list_tasks_filter_by_status(task_client: dict) -> None:
client = task_client["client"]
response = await client.get("/api/tasks?status=pending", headers=_HDR)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_get_my_tasks(task_client: dict) -> None:
client = task_client["client"]
response = await client.get("/api/tasks/my", headers=_HDR)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_get_pending_tasks(task_client: dict) -> None:
client = task_client["client"]
response = await client.get("/api/tasks/pending", headers=_HDR)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_get_blocked_tasks(task_client: dict) -> None:
client = task_client["client"]
response = await client.get("/api/tasks/blocked", headers=_HDR)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_get_awaiting_qa(task_client: dict) -> None:
client = task_client["client"]
response = await client.get("/api/tasks/awaiting-qa", headers=_HDR)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_get_task_not_found(task_client: dict) -> None:
client = task_client["client"]
response = await client.get(f"/api/tasks/{uuid4()}", headers=_HDR)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_get_task_by_id(task_client: dict) -> None:
client = task_client["client"]
task = _seed_task(task_client)
await task_client["db"].flush()
response = await client.get(f"/api/tasks/{task.id}", headers=_HDR)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_update_task(task_client: dict) -> None:
client = task_client["client"]
task = _seed_task(task_client)
await task_client["db"].flush()
response = await client.patch(
f"/api/tasks/{task.id}",
json={"title": "Renamed"},
headers=_HDR,
)
assert response.status_code in (HTTPStatus.OK, HTTPStatus.UNPROCESSABLE_ENTITY)
@pytest.mark.asyncio
async def test_delete_task(task_client: dict) -> None:
client = task_client["client"]
task = _seed_task(task_client)
await task_client["db"].flush()
response = await client.delete(f"/api/tasks/{task.id}", headers=_HDR)
assert response.status_code in (
HTTPStatus.OK,
HTTPStatus.NO_CONTENT,
HTTPStatus.UNPROCESSABLE_ENTITY,
)
@pytest.mark.asyncio
async def test_delete_task_not_found(task_client: dict) -> None:
client = task_client["client"]
response = await client.delete(f"/api/tasks/{uuid4()}", headers=_HDR)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_get_subtasks_of_unknown_task(task_client: dict) -> None:
client = task_client["client"]
response = await client.get(f"/api/tasks/{uuid4()}/subtasks", headers=_HDR)
# Either 404 or empty list depending on implementation.
assert response.status_code in (HTTPStatus.OK, HTTPStatus.NOT_FOUND)
@pytest.mark.asyncio
async def test_count_endpoint_returns_response(task_client: dict) -> None:
"""Count route may take query params we don't supply; just ensure it's reached."""
client = task_client["client"]
response = await client.get("/api/tasks/count", headers=_HDR)
assert response.status_code in (HTTPStatus.OK, HTTPStatus.UNPROCESSABLE_ENTITY)
# ---------------------------------------------------------------------------
# Additional list endpoints
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_awaiting_docs(task_client: dict) -> None:
client = task_client["client"]
response = await client.get("/api/tasks/awaiting-docs", headers=_HDR)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_get_team_tasks(task_client: dict) -> None:
client = task_client["client"]
response = await client.get("/api/tasks/team/backend", headers=_HDR)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_get_task_stats(task_client: dict) -> None:
client = task_client["client"]
response = await client.get("/api/tasks/stats", headers=_HDR)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_get_task_stats_by_team(task_client: dict) -> None:
client = task_client["client"]
response = await client.get("/api/tasks/stats/by-team", headers=_HDR)
assert response.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# Lifecycle: claim/unclaim (404 paths)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_claim_unknown_task_returns_404(task_client: dict) -> None:
client = task_client["client"]
response = await client.post(
f"/api/tasks/{uuid4()}/claim",
json={"role": "developer"},
headers=_HDR,
)
assert response.status_code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.FORBIDDEN,
HTTPStatus.NOT_FOUND,
HTTPStatus.UNPROCESSABLE_ENTITY,
)
@pytest.mark.asyncio
async def test_unclaim_unknown_returns_404(task_client: dict) -> None:
client = task_client["client"]
response = await client.post(f"/api/tasks/{uuid4()}/unclaim", headers=_HDR)
assert response.status_code in (HTTPStatus.BAD_REQUEST, HTTPStatus.NOT_FOUND)
@pytest.mark.asyncio
async def test_submit_for_qa_unknown_returns_404(task_client: dict) -> None:
client = task_client["client"]
response = await client.post(
f"/api/tasks/{uuid4()}/submit-qa",
json={},
headers=_HDR,
)
assert response.status_code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.NOT_FOUND,
HTTPStatus.UNPROCESSABLE_ENTITY,
)
@pytest.mark.asyncio
async def test_pass_qa_unknown_returns_404(task_client: dict) -> None:
client = task_client["client"]
response = await client.post(
f"/api/tasks/{uuid4()}/pass-qa",
json={"notes": "looks good and is sufficiently detailed"},
headers=_HDR,
)
assert response.status_code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.FORBIDDEN,
HTTPStatus.NOT_FOUND,
HTTPStatus.UNPROCESSABLE_ENTITY,
)
@pytest.mark.asyncio
async def test_fail_qa_unknown_returns_404(task_client: dict) -> None:
client = task_client["client"]
response = await client.post(
f"/api/tasks/{uuid4()}/fail-qa",
json={"notes": "broken in many ways"},
headers=_HDR,
)
assert response.status_code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.FORBIDDEN,
HTTPStatus.NOT_FOUND,
HTTPStatus.UNPROCESSABLE_ENTITY,
)
@pytest.mark.asyncio
async def test_complete_unknown_returns_404(task_client: dict) -> None:
client = task_client["client"]
response = await client.post(
f"/api/tasks/{uuid4()}/complete",
json={},
headers=_HDR,
)
assert response.status_code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.FORBIDDEN,
HTTPStatus.NOT_FOUND,
HTTPStatus.UNPROCESSABLE_ENTITY,
)
@pytest.mark.asyncio
async def test_block_unknown_returns_404(task_client: dict) -> None:
client = task_client["client"]
response = await client.post(
f"/api/tasks/{uuid4()}/block",
json={"reason": "blocker", "blocker_type": "external", "what_needed": "x"},
headers=_HDR,
)
assert response.status_code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.FORBIDDEN,
HTTPStatus.NOT_FOUND,
HTTPStatus.UNPROCESSABLE_ENTITY,
)
@pytest.mark.asyncio
async def test_unblock_unknown_returns_404(task_client: dict) -> None:
client = task_client["client"]
response = await client.post(f"/api/tasks/{uuid4()}/unblock", headers=_HDR)
assert response.status_code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.FORBIDDEN,
HTTPStatus.NOT_FOUND,
HTTPStatus.UNPROCESSABLE_ENTITY,
)
@pytest.mark.asyncio
async def test_pause_unknown_returns_404(task_client: dict) -> None:
client = task_client["client"]
response = await client.post(f"/api/tasks/{uuid4()}/pause", headers=_HDR)
assert response.status_code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.FORBIDDEN,
HTTPStatus.NOT_FOUND,
HTTPStatus.UNPROCESSABLE_ENTITY,
)
@pytest.mark.asyncio
async def test_resume_unknown_returns_404(task_client: dict) -> None:
client = task_client["client"]
response = await client.post(f"/api/tasks/{uuid4()}/resume", headers=_HDR)
assert response.status_code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.FORBIDDEN,
HTTPStatus.NOT_FOUND,
HTTPStatus.UNPROCESSABLE_ENTITY,
)
@pytest.mark.asyncio
async def test_cancel_unknown_returns_404(task_client: dict) -> None:
client = task_client["client"]
response = await client.post(
f"/api/tasks/{uuid4()}/cancel",
json={"reason": "no longer needed"},
headers=_HDR,
)
assert response.status_code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.FORBIDDEN,
HTTPStatus.NOT_FOUND,
HTTPStatus.UNPROCESSABLE_ENTITY,
)
@pytest.mark.asyncio
async def test_add_progress_unknown_returns_404(task_client: dict) -> None:
client = task_client["client"]
response = await client.post(
f"/api/tasks/{uuid4()}/progress",
json={"message": "doing things", "percentage": 25},
headers=_HDR,
)
assert response.status_code in (HTTPStatus.BAD_REQUEST, HTTPStatus.NOT_FOUND)
@pytest.mark.asyncio
async def test_add_checkpoint_unknown_returns_404(task_client: dict) -> None:
client = task_client["client"]
response = await client.post(
f"/api/tasks/{uuid4()}/checkpoints",
json={
"state_summary": "halfway",
"remaining_work": ["finish API"],
},
headers=_HDR,
)
assert response.status_code in (HTTPStatus.BAD_REQUEST, HTTPStatus.NOT_FOUND)
@pytest.mark.asyncio
async def test_add_commit_unknown_returns_404(task_client: dict) -> None:
client = task_client["client"]
response = await client.post(
f"/api/tasks/{uuid4()}/commits",
json={"hash": "abc123", "message": "fix"},
headers=_HDR,
)
assert response.status_code in (HTTPStatus.BAD_REQUEST, HTTPStatus.NOT_FOUND)
@pytest.mark.asyncio
async def test_escalate_unknown_returns_404(task_client: dict) -> None:
client = task_client["client"]
response = await client.post(
f"/api/tasks/{uuid4()}/escalate",
json={"reason": "needs PM input"},
headers=_HDR,
)
assert response.status_code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.FORBIDDEN,
HTTPStatus.NOT_FOUND,
HTTPStatus.UNPROCESSABLE_ENTITY,
)
@pytest.mark.asyncio
async def test_get_sessions_for_task(task_client: dict) -> None:
client = task_client["client"]
task = _seed_task(task_client)
await task_client["db"].flush()
response = await client.get(f"/api/tasks/{task.id}/sessions", headers=_HDR)
assert response.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# Additional create_task validation paths
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_task_no_acceptance_criteria(task_client: dict) -> None:
"""Task without acceptance criteria — 4xx."""
client = task_client["client"]
response = await client.post(
"/api/tasks",
json={
"title": "T",
"description": "d",
"acceptance_criteria": [],
"team": "backend",
"project_id": str(task_client["project"].id),
},
headers=_HDR,
)
assert response.status_code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.UNPROCESSABLE_ENTITY,
)
@pytest.mark.asyncio
async def test_create_task_blank_acceptance_criteria(task_client: dict) -> None:
"""Task with blank acceptance criteria — 400."""
client = task_client["client"]
response = await client.post(
"/api/tasks",
json={
"title": "T",
"description": "d",
"acceptance_criteria": [" ", ""],
"team": "backend",
"project_id": str(task_client["project"].id),
},
headers=_HDR,
)
assert response.status_code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.UNPROCESSABLE_ENTITY,
)
@pytest.mark.asyncio
async def test_create_task_assigned_to_uuid(task_client: dict) -> None:
"""Task with assigned_to as UUID string — should work."""
client = task_client["client"]
response = await client.post(
"/api/tasks",
json={
"title": "T",
"description": "Twenty character description here ok",
"acceptance_criteria": ["a"],
"team": "backend",
"project_id": str(task_client["project"].id),
"assigned_to": str(task_client["agent"].id),
"task_type": "code",
"nature": "technical",
"estimated_complexity": "medium",
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.CREATED
@pytest.mark.asyncio
async def test_create_task_assigned_to_slug(task_client: dict) -> None:
"""Task with assigned_to as slug — should resolve."""
client = task_client["client"]
response = await client.post(
"/api/tasks",
json={
"title": "T",
"description": "Twenty character description here ok",
"acceptance_criteria": ["a"],
"team": "backend",
"project_id": str(task_client["project"].id),
"assigned_to": task_client["agent"].slug,
"task_type": "code",
"nature": "technical",
"estimated_complexity": "medium",
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.CREATED
@pytest.mark.asyncio
async def test_create_task_assigned_to_unknown_slug(task_client: dict) -> None:
"""Task with unknown assigned_to slug — 422."""
client = task_client["client"]
response = await client.post(
"/api/tasks",
json={
"title": "T",
"description": "d",
"acceptance_criteria": ["a"],
"team": "backend",
"project_id": str(task_client["project"].id),
"assigned_to": "ghost-agent-1",
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
# ---------------------------------------------------------------------------
# Get descendants
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_descendants(task_client: dict) -> None:
client = task_client["client"]
response = await client.get(f"/api/tasks/{uuid4()}/descendants", headers=_HDR)
assert response.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# Update task — privileges
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_update_task_not_found(task_client: dict) -> None:
client = task_client["client"]
response = await client.patch(
f"/api/tasks/{uuid4()}",
json={"title": "Renamed"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_update_task_via_put(task_client: dict) -> None:
"""PUT alias works the same as PATCH."""
client = task_client["client"]
task = _seed_task(task_client)
await task_client["db"].flush()
response = await client.put(
f"/api/tasks/{task.id}",
json={"title": "PutRenamed"},
headers=_HDR,
)
assert response.status_code in (HTTPStatus.OK, HTTPStatus.UNPROCESSABLE_ENTITY)
# ---------------------------------------------------------------------------
# Lifecycle: start
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_start_unknown_returns_404(task_client: dict) -> None:
client = task_client["client"]
response = await client.post(f"/api/tasks/{uuid4()}/start", headers=_HDR)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_start_task_not_assigned_returns_403(task_client: dict) -> None:
"""Main PM is not the assignee, so start should fail with 403."""
client = task_client["client"]
other = await _seed_agent(task_client)
task = _seed_task(task_client, status=TaskStatus.CLAIMED, assigned_to=other.id)
await task_client["db"].flush()
response = await client.post(f"/api/tasks/{task.id}/start", headers=_HDR)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_start_task_no_branch_returns_400(task_client: dict) -> None:
"""Task without branch — 400."""
client = task_client["client"]
task = _seed_task(
task_client,
status=TaskStatus.CLAIMED,
assigned_to=task_client["agent"].id,
)
await task_client["db"].flush()
response = await client.post(f"/api/tasks/{task.id}/start", headers=_HDR)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert "NO_BRANCH" in response.json()["detail"]
@pytest.mark.asyncio
async def test_start_claimed_task_no_plan_returns_400(task_client: dict) -> None:
"""Claimed task without plan — 400."""
client = task_client["client"]
task = _seed_task(
task_client,
status=TaskStatus.CLAIMED,
assigned_to=task_client["agent"].id,
branch_name="feature/backend/X",
)
await task_client["db"].flush()
response = await client.post(f"/api/tasks/{task.id}/start", headers=_HDR)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert "NO_PLAN" in response.json()["detail"]
# ---------------------------------------------------------------------------
# Block
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_block_task_not_found(task_client: dict) -> None:
client = task_client["client"]
blocker = _seed_task(task_client)
await task_client["db"].flush()
response = await client.post(
f"/api/tasks/{uuid4()}/block?blocker_id={blocker.id}",
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_block_task_forbidden(task_client: dict) -> None:
"""Non-assignee, non-PM cannot block."""
other = await _seed_agent(task_client)
task = _seed_task(task_client, assigned_to=other.id)
blocker = _seed_task(task_client)
await task_client["db"].flush()
# Override agent to a developer not assigned
app = task_client["client"]._transport.app
async def _override_agent() -> AgentContext:
return AgentContext(
agent_id=uuid4(), role=AgentRole.DEVELOPER, team=Team.BACKEND
)
app.dependency_overrides[get_agent_context] = _override_agent
response = await task_client["client"].post(
f"/api/tasks/{task.id}/block?blocker_id={blocker.id}",
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
# ---------------------------------------------------------------------------
# Soft-block
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_soft_block_unknown_returns_404(task_client: dict) -> None:
client = task_client["client"]
response = await client.post(
f"/api/tasks/{uuid4()}/soft-block",
json={
"reason": "stuck",
"blocker_type": "external",
"what_needed": "some external system",
},
headers=_HDR,
)
assert response.status_code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.NOT_FOUND,
HTTPStatus.FORBIDDEN,
)
# ---------------------------------------------------------------------------
# Unblock — happy path with progress notification
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unblock_task_not_blocked_returns_400(task_client: dict) -> None:
"""Unblock a task that is not blocked - 400."""
client = task_client["client"]
task = _seed_task(
task_client,
status=TaskStatus.PENDING,
assigned_to=task_client["agent"].id,
)
await task_client["db"].flush()
response = await client.post(f"/api/tasks/{task.id}/unblock", headers=_HDR)
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_unblock_task_forbidden(task_client: dict) -> None:
other = await _seed_agent(task_client)
task = _seed_task(task_client, status=TaskStatus.BLOCKED, assigned_to=other.id)
await task_client["db"].flush()
# Override agent role to be non-PM, non-assignee
app = task_client["client"]._transport.app
async def _override_agent() -> AgentContext:
return AgentContext(
agent_id=uuid4(), role=AgentRole.DEVELOPER, team=Team.BACKEND
)
app.dependency_overrides[get_agent_context] = _override_agent
response = await task_client["client"].post(
f"/api/tasks/{task.id}/unblock", headers=_HDR
)
assert response.status_code == HTTPStatus.FORBIDDEN
# ---------------------------------------------------------------------------
# Pause/resume forbidden + invalid-status branches
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pause_task_forbidden(task_client: dict) -> None:
other = await _seed_agent(task_client)
task = _seed_task(task_client, status=TaskStatus.IN_PROGRESS, assigned_to=other.id)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/pause", headers=_HDR
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_pause_task_invalid_status_returns_400(task_client: dict) -> None:
"""Pause when not in_progress — 400."""
task = _seed_task(
task_client, status=TaskStatus.PENDING, assigned_to=task_client["agent"].id
)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/pause", headers=_HDR
)
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_resume_task_forbidden(task_client: dict) -> None:
other = await _seed_agent(task_client)
task = _seed_task(task_client, status=TaskStatus.PAUSED, assigned_to=other.id)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/resume", headers=_HDR
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_resume_task_invalid_status(task_client: dict) -> None:
task = _seed_task(
task_client, status=TaskStatus.PENDING, assigned_to=task_client["agent"].id
)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/resume", headers=_HDR
)
assert response.status_code == HTTPStatus.BAD_REQUEST
# ---------------------------------------------------------------------------
# verify
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_verify_task_not_found(task_client: dict) -> None:
response = await task_client["client"].post(
f"/api/tasks/{uuid4()}/verify", headers=_HDR
)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_verify_task_forbidden(task_client: dict) -> None:
other = await _seed_agent(task_client)
task = _seed_task(task_client, status=TaskStatus.IN_PROGRESS, assigned_to=other.id)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/verify", headers=_HDR
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_verify_task_invalid_status(task_client: dict) -> None:
task = _seed_task(
task_client, status=TaskStatus.PENDING, assigned_to=task_client["agent"].id
)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/verify", headers=_HDR
)
assert response.status_code == HTTPStatus.BAD_REQUEST
# ---------------------------------------------------------------------------
# submit-qa gates
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_submit_qa_not_self_verified_returns_400(task_client: dict) -> None:
task = _seed_task(
task_client,
status=TaskStatus.VERIFYING,
assigned_to=task_client["agent"].id,
self_verified=False,
)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/submit-qa", headers=_HDR
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert "NOT_SELF_VERIFIED" in response.json()["detail"]
@pytest.mark.asyncio
async def test_submit_qa_no_commits_returns_400(task_client: dict) -> None:
task = _seed_task(
task_client,
status=TaskStatus.VERIFYING,
assigned_to=task_client["agent"].id,
self_verified=True,
commits=[],
)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/submit-qa", headers=_HDR
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert "NO_COMMITS" in response.json()["detail"]
@pytest.mark.asyncio
async def test_submit_qa_no_pr_returns_400(task_client: dict) -> None:
task = _seed_task(
task_client,
status=TaskStatus.VERIFYING,
assigned_to=task_client["agent"].id,
self_verified=True,
commits=[{"hash": "abc", "message": "fix"}],
pr_number=None,
)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/submit-qa", headers=_HDR
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert "NO_PR" in response.json()["detail"]
@pytest.mark.asyncio
async def test_submit_qa_no_progress_updates_returns_400(task_client: dict) -> None:
task = _seed_task(
task_client,
status=TaskStatus.VERIFYING,
assigned_to=task_client["agent"].id,
self_verified=True,
commits=[{"hash": "abc", "message": "fix"}],
pr_number=42,
progress_updates=[],
)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/submit-qa", headers=_HDR
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert "NO_PROGRESS" in response.json()["detail"]
@pytest.mark.asyncio
async def test_submit_qa_forbidden_non_assignee(task_client: dict) -> None:
other = await _seed_agent(task_client)
task = _seed_task(
task_client,
status=TaskStatus.VERIFYING,
assigned_to=other.id,
)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/submit-qa", headers=_HDR
)
assert response.status_code == HTTPStatus.FORBIDDEN
# ---------------------------------------------------------------------------
# pass-qa gates
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pass_qa_non_qa_role_forbidden(task_client: dict) -> None:
task = _seed_task(task_client, status=TaskStatus.AWAITING_QA, pr_number=42)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/pass-qa",
json={"notes": "looks good and is sufficiently substantive notes"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_fail_qa_non_qa_role_forbidden(task_client: dict) -> None:
task = _seed_task(task_client, status=TaskStatus.AWAITING_QA, pr_number=42)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/fail-qa",
json={"notes": "broken in some ways"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
# ---------------------------------------------------------------------------
# docs-complete
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_docs_complete_unknown_returns_4xx(task_client: dict) -> None:
response = await task_client["client"].post(
f"/api/tasks/{uuid4()}/docs-complete",
json={"notes": "completed docs"},
headers=_HDR,
)
assert response.status_code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.NOT_FOUND,
HTTPStatus.FORBIDDEN,
)
# ---------------------------------------------------------------------------
# submit-pm-review
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_submit_pm_review_not_found(task_client: dict) -> None:
response = await task_client["client"].post(
f"/api/tasks/{uuid4()}/submit-pm-review",
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_submit_pm_review_forbidden(task_client: dict) -> None:
other = await _seed_agent(task_client)
task = _seed_task(task_client, status=TaskStatus.IN_PROGRESS, assigned_to=other.id)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/submit-pm-review",
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
# ---------------------------------------------------------------------------
# CEO endpoints
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_awaiting_pm_review(task_client: dict) -> None:
"""Path collides with /{task_id} — invalid UUID gives 422."""
response = await task_client["client"].get(
"/api/tasks/awaiting-pm-review", headers=_HDR
)
# Route ordering quirk: /{task_id} matches first.
assert response.status_code in (HTTPStatus.OK, HTTPStatus.UNPROCESSABLE_ENTITY)
@pytest.mark.asyncio
async def test_get_awaiting_ceo_approval(task_client: dict) -> None:
response = await task_client["client"].get(
"/api/tasks/awaiting-ceo-approval", headers=_HDR
)
# Same /{task_id} ordering quirk.
assert response.status_code in (HTTPStatus.OK, HTTPStatus.UNPROCESSABLE_ENTITY)
@pytest.mark.asyncio
async def test_ceo_approve_unknown_returns_4xx(task_client: dict) -> None:
response = await task_client["client"].post(
f"/api/tasks/{uuid4()}/ceo-approve",
json={"notes": "approved"},
headers=_HDR,
)
# Main PM is not CEO — 403
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_ceo_reject_non_ceo_forbidden(task_client: dict) -> None:
response = await task_client["client"].post(
f"/api/tasks/{uuid4()}/ceo-reject",
json={"notes": "rejected"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_escalate_to_ceo_unknown_returns_4xx(task_client: dict) -> None:
response = await task_client["client"].post(
f"/api/tasks/{uuid4()}/escalate-to-ceo",
json={"notes": "needs CEO"},
headers=_HDR,
)
assert response.status_code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.NOT_FOUND,
HTTPStatus.FORBIDDEN,
)
# ---------------------------------------------------------------------------
# Substitute
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_substitute_unknown_returns_4xx(task_client: dict) -> None:
response = await task_client["client"].post(
f"/api/tasks/{uuid4()}/substitute",
json={"reason": "low_context", "details": "Need more context"},
headers=_HDR,
)
assert response.status_code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.NOT_FOUND,
HTTPStatus.FORBIDDEN,
)
# ---------------------------------------------------------------------------
# progress / checkpoint / commit forbidden + not-found
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_progress_forbidden(task_client: dict) -> None:
other = await _seed_agent(task_client)
task = _seed_task(task_client, assigned_to=other.id)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/progress",
json={"message": "doing things now", "percentage": 25},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_checkpoint_forbidden(task_client: dict) -> None:
other = await _seed_agent(task_client)
task = _seed_task(task_client, assigned_to=other.id)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/checkpoint",
json={"state_summary": "halfway", "remaining_work": ["finish"]},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_checkpoint_unknown_returns_404(task_client: dict) -> None:
response = await task_client["client"].post(
f"/api/tasks/{uuid4()}/checkpoint",
json={"state_summary": "halfway", "remaining_work": ["finish"]},
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_commit_unknown_returns_404(task_client: dict) -> None:
response = await task_client["client"].post(
f"/api/tasks/{uuid4()}/commit",
json={"hash": "abc1234", "message": "fix"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_commit_forbidden(task_client: dict) -> None:
other = await _seed_agent(task_client)
task = _seed_task(task_client, assigned_to=other.id)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/commit",
json={"hash": "abc1234", "message": "fix"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
# ---------------------------------------------------------------------------
# Activate (PM)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_activate_unknown_returns_4xx(task_client: dict) -> None:
response = await task_client["client"].post(
f"/api/tasks/{uuid4()}/activate", headers=_HDR
)
assert response.status_code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.NOT_FOUND,
HTTPStatus.FORBIDDEN,
)
@pytest.mark.asyncio
async def test_activate_developer_forbidden(task_client: dict) -> None:
app = task_client["client"]._transport.app
async def _override_agent() -> AgentContext:
return AgentContext(
agent_id=task_client["agent"].id,
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
)
app.dependency_overrides[get_agent_context] = _override_agent
response = await task_client["client"].post(
f"/api/tasks/{uuid4()}/activate", headers=_HDR
)
assert response.status_code == HTTPStatus.FORBIDDEN
# ---------------------------------------------------------------------------
# Listing variants — different team filters
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_tasks_by_team_filter(task_client: dict) -> None:
"""Both team and status filters set."""
response = await task_client["client"].get(
"/api/tasks?team=backend&status=pending", headers=_HDR
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_get_team_tasks_unauthorized(task_client: dict) -> None:
"""Developer trying to view a team's tasks they aren't on."""
app = task_client["client"]._transport.app
async def _override_agent() -> AgentContext:
return AgentContext(
agent_id=task_client["agent"].id,
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
)
app.dependency_overrides[get_agent_context] = _override_agent
response = await task_client["client"].get("/api/tasks/team/frontend", headers=_HDR)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_get_task_stats_by_team_developer_forbidden(
task_client: dict,
) -> None:
app = task_client["client"]._transport.app
async def _override_agent() -> AgentContext:
return AgentContext(
agent_id=task_client["agent"].id,
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
)
app.dependency_overrides[get_agent_context] = _override_agent
response = await task_client["client"].get("/api/tasks/stats/by-team", headers=_HDR)
assert response.status_code == HTTPStatus.FORBIDDEN
# ---------------------------------------------------------------------------
# List as developer with no team — empty list
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_tasks_no_team_no_view_all(task_client: dict) -> None:
"""Agent with no team and no VIEW_ALL — empty list."""
app = task_client["client"]._transport.app
async def _override_agent() -> AgentContext:
return AgentContext(
agent_id=task_client["agent"].id,
role=AgentRole.DEVELOPER,
team=None,
)
app.dependency_overrides[get_agent_context] = _override_agent
response = await task_client["client"].get("/api/tasks", headers=_HDR)
assert response.status_code == HTTPStatus.OK
assert response.json() == []
@pytest.mark.asyncio
async def test_list_tasks_developer_with_team_filters_to_own(
task_client: dict,
) -> None:
"""Developer (no VIEW_ALL) with a team — effective_team = agent.team."""
app = task_client["client"]._transport.app
async def _override_agent() -> AgentContext:
return AgentContext(
agent_id=task_client["agent"].id,
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
)
app.dependency_overrides[get_agent_context] = _override_agent
response = await task_client["client"].get("/api/tasks", headers=_HDR)
assert response.status_code == HTTPStatus.OK
assert isinstance(response.json(), list)
@pytest.mark.asyncio
async def test_create_task_missing_project_id_returns_422(task_client: dict) -> None:
"""`TaskCreate.project_id` is `UUID` (required); pydantic rejects missing
value with 422 before the route runs. (The previously-dead inline runtime
`if not data.project_id` branch was removed.)"""
response = await task_client["client"].post(
"/api/tasks",
json={
"title": "T",
"description": "d",
"acceptance_criteria": ["a"],
"team": "backend",
# project_id intentionally missing
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
# ---------------------------------------------------------------------------
# Delete forbidden
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_task_forbidden_non_creator(task_client: dict) -> None:
"""Developer not the creator — 403."""
other = await _seed_agent(task_client)
task = _seed_task(task_client, created_by=other.id)
await task_client["db"].flush()
# Override to be a developer different from creator
app = task_client["client"]._transport.app
async def _override_agent() -> AgentContext:
return AgentContext(
agent_id=uuid4(), role=AgentRole.DEVELOPER, team=Team.BACKEND
)
app.dependency_overrides[get_agent_context] = _override_agent
response = await task_client["client"].delete(f"/api/tasks/{task.id}", headers=_HDR)
assert response.status_code == HTTPStatus.FORBIDDEN
# ---------------------------------------------------------------------------
# Cancel forbidden + happy path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cancel_developer_forbidden(task_client: dict) -> None:
task = _seed_task(task_client)
await task_client["db"].flush()
app = task_client["client"]._transport.app
async def _override_agent() -> AgentContext:
return AgentContext(
agent_id=task_client["agent"].id,
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
)
app.dependency_overrides[get_agent_context] = _override_agent
response = await task_client["client"].post(
f"/api/tasks/{task.id}/cancel",
json={"reason": "no longer needed at all"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_cancel_task_pm_succeeds(task_client: dict) -> None:
task = _seed_task(task_client)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/cancel",
json={"reason": "no longer needed at all"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# _translate_error: direct unit coverage for service-error → HTTP mapping
# ---------------------------------------------------------------------------
def test_translate_error_not_found() -> None:
"""NotFoundError → 404."""
err = NotFoundError(resource_type="task", resource_id="123")
http_exc = _translate_error(err)
assert isinstance(http_exc, HTTPException)
assert http_exc.status_code == HTTPStatus.NOT_FOUND
assert "task not found" in http_exc.detail.lower()
def test_translate_error_unauthorized() -> None:
"""UnauthorizedError → 403."""
err = UnauthorizedError(action="delete", reason="not your task")
http_exc = _translate_error(err)
assert http_exc.status_code == HTTPStatus.FORBIDDEN
assert "delete" in http_exc.detail
def test_translate_error_validation() -> None:
"""ValidationError → 400."""
err = ValidationError("bad field value")
http_exc = _translate_error(err)
assert http_exc.status_code == HTTPStatus.BAD_REQUEST
assert http_exc.detail == "bad field value"
def test_translate_error_generic_service_error() -> None:
"""Plain ServiceError → 500."""
err = ServiceError("service exploded")
http_exc = _translate_error(err)
assert http_exc.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
assert http_exc.detail == "service exploded"
# ---------------------------------------------------------------------------
# create_task: role denial + audit logging
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_task_role_not_authorized(task_client: dict) -> None:
"""Override agent to a role that cannot CREATE; audit denial path runs."""
app = task_client["client"]._transport.app
async def _override_agent() -> AgentContext:
return AgentContext(
agent_id=task_client["agent"].id,
role=AgentRole.QA,
team=Team.BACKEND,
)
app.dependency_overrides[get_agent_context] = _override_agent
response = await task_client["client"].post(
"/api/tasks",
json={
"title": "T",
"description": "Twenty character description here ok",
"acceptance_criteria": ["a"],
"team": "backend",
"project_id": str(task_client["project"].id),
"task_type": "code",
"nature": "technical",
"estimated_complexity": "medium",
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
assert "Not authorized" in response.json()["detail"]
# ---------------------------------------------------------------------------
# update_task: forbidden (non-owner non-PM) + 500 fallback
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_update_task_forbidden_non_owner(task_client: dict) -> None:
"""Developer who is neither owner nor creator gets 403."""
other = await _seed_agent(task_client)
task = _seed_task(task_client, assigned_to=other.id, created_by=other.id)
await task_client["db"].flush()
app = task_client["client"]._transport.app
async def _override_agent() -> AgentContext:
return AgentContext(
agent_id=uuid4(), role=AgentRole.DEVELOPER, team=Team.BACKEND
)
app.dependency_overrides[get_agent_context] = _override_agent
response = await task_client["client"].patch(
f"/api/tasks/{task.id}",
json={"title": "X"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
assert "Not authorized" in response.json()["detail"]
@pytest.mark.asyncio
async def test_update_task_service_returns_none_yields_500(
task_client: dict,
) -> None:
"""Force service.update() to return None → route raises 500."""
task = _seed_task(task_client)
await task_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.get = AsyncMock(return_value=task)
instance.update = AsyncMock(return_value=None)
mock_factory.return_value = instance
response = await task_client["client"].patch(
f"/api/tasks/{task.id}",
json={"title": "Renamed"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
assert "update failed" in response.json()["detail"].lower()
# ---------------------------------------------------------------------------
# claim_task: ServiceError -> _translate_error
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_claim_task_service_error_translated(task_client: dict) -> None:
"""A ServiceError raised by claim_task_for_agent surfaces via _translate_error."""
task = _seed_task(task_client)
await task_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.claim_task_for_agent = AsyncMock(
side_effect=ValidationError("Cannot claim — already claimed")
)
mock_factory.return_value = instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/claim",
json={"agent_id": "main-pm"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_claim_task_success(task_client: dict) -> None:
"""Happy path: claim returns task, route serializes it."""
task = _seed_task(task_client)
await task_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.claim_task_for_agent = AsyncMock(return_value=task)
mock_factory.return_value = instance
# No body — claim with the caller's own context.
response = await task_client["client"].post(
f"/api/tasks/{task.id}/claim",
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["id"] == str(task.id)
# ---------------------------------------------------------------------------
# start_task: success path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_start_task_success(task_client: dict) -> None:
"""Claimed task with branch + plan starts cleanly → in_progress."""
task = _seed_task(
task_client,
status=TaskStatus.CLAIMED,
assigned_to=task_client["agent"].id,
branch_name="feature/backend/X",
plan={"steps": ["a"]},
)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/start", headers=_HDR
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "in_progress"
@pytest.mark.asyncio
async def test_start_task_service_returns_none_returns_400(
task_client: dict,
) -> None:
"""If service.start returns None on a non-claimed/paused task, route 400s."""
task = _seed_task(
task_client,
status=TaskStatus.IN_PROGRESS,
assigned_to=task_client["agent"].id,
branch_name="feature/backend/X",
)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/start", headers=_HDR
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert "invalid status" in response.json()["detail"].lower()
# ---------------------------------------------------------------------------
# block_task: success + 500 fallback
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_block_task_success(task_client: dict) -> None:
"""PM blocks a task with a real blocker_id → 200."""
blocker = _seed_task(task_client)
target = _seed_task(
task_client,
status=TaskStatus.IN_PROGRESS,
assigned_to=task_client["agent"].id,
)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{target.id}/block?blocker_id={blocker.id}",
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "blocked"
@pytest.mark.asyncio
async def test_block_task_service_returns_none_500(task_client: dict) -> None:
"""If service.block returns None → 500."""
task = _seed_task(
task_client,
status=TaskStatus.IN_PROGRESS,
assigned_to=task_client["agent"].id,
)
blocker = _seed_task(task_client)
await task_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.get = AsyncMock(return_value=task)
instance.block = AsyncMock(return_value=None)
mock_factory.return_value = instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/block?blocker_id={blocker.id}",
headers=_HDR,
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
assert "block failed" in response.json()["detail"].lower()
# ---------------------------------------------------------------------------
# soft_block: success
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_soft_block_task_success(task_client: dict) -> None:
task = _seed_task(
task_client,
status=TaskStatus.IN_PROGRESS,
assigned_to=task_client["agent"].id,
)
await task_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.soft_block_task_for_agent = AsyncMock(return_value=task)
mock_factory.return_value = instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/soft-block",
json={
"reason": "external system unavailable",
"blocker_type": "external",
"what_needed": "Stripe API",
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# unblock: success notifies assignee + commits
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unblock_task_success_notifies_assignee(
task_client: dict,
) -> None:
"""Unblock a blocked task assigned to a different agent → notification path."""
other = await _seed_agent(task_client)
task = _seed_task(
task_client,
status=TaskStatus.BLOCKED,
assigned_to=other.id,
)
await task_client["db"].flush()
with patch(
"roboco.api.routes.tasks.get_notification_delivery_service"
) as mock_delivery:
delivery_instance = AsyncMock()
delivery_instance.notify_assignee_of_unblock = AsyncMock(return_value=None)
mock_delivery.return_value = delivery_instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/unblock", headers=_HDR
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] != "blocked"
delivery_instance.notify_assignee_of_unblock.assert_awaited_once()
# ---------------------------------------------------------------------------
# pause / resume / verify success
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pause_task_success(task_client: dict) -> None:
task = _seed_task(
task_client,
status=TaskStatus.IN_PROGRESS,
assigned_to=task_client["agent"].id,
)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/pause", headers=_HDR
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "paused"
@pytest.mark.asyncio
async def test_resume_task_success(task_client: dict) -> None:
task = _seed_task(
task_client,
status=TaskStatus.PAUSED,
assigned_to=task_client["agent"].id,
)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/resume", headers=_HDR
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_verify_task_success(task_client: dict) -> None:
task = _seed_task(
task_client,
status=TaskStatus.IN_PROGRESS,
assigned_to=task_client["agent"].id,
)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/verify", headers=_HDR
)
assert response.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# submit_for_qa: success
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_submit_qa_success(task_client: dict) -> None:
"""All gates satisfied + status=verifying → submit succeeds."""
task = _seed_task(
task_client,
status=TaskStatus.VERIFYING,
assigned_to=task_client["agent"].id,
self_verified=True,
commits=[
{
"hash": "abc1234",
"message": "wip",
"timestamp": "2026-01-01T00:00:00+00:00",
}
],
pr_number=42,
progress_updates=[
{
"timestamp": "2026-01-01T00:00:00+00:00",
"agent_id": str(task_client["agent"].id),
"message": "started",
}
],
)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/submit-qa", headers=_HDR
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "awaiting_qa"
@pytest.mark.asyncio
async def test_submit_qa_service_returns_none_returns_400(
task_client: dict,
) -> None:
"""Force service.submit_for_qa to return None → route 400s with cannot submit."""
task = _seed_task(
task_client,
status=TaskStatus.VERIFYING,
assigned_to=task_client["agent"].id,
self_verified=True,
commits=[{"hash": "abc", "message": "fix"}],
pr_number=42,
progress_updates=[
{
"timestamp": "2026-01-01T00:00:00+00:00",
"agent_id": str(task_client["agent"].id),
"message": "started",
}
],
)
await task_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.get = AsyncMock(return_value=task)
instance.submit_for_qa = AsyncMock(return_value=None)
mock_factory.return_value = instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/submit-qa", headers=_HDR
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert "not verifying" in response.json()["detail"].lower()
# ---------------------------------------------------------------------------
# pass_qa: full body coverage
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def qa_client(db_session: AsyncSession) -> AsyncIterator[dict]:
"""Client where the agent context role is QA."""
qa = AgentTable(
id=uuid4(),
name="QA",
slug=f"be-qa-{uuid4().hex[:8]}",
role=AgentRole.QA,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="qa",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(qa)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="QA-Proj",
slug=f"qa-proj-{uuid4().hex[:6]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=qa.id,
)
db_session.add(project)
await db_session.flush()
app = FastAPI()
app.include_router(tasks_router, prefix="/api/tasks")
async def _override_db():
yield db_session
async def _override_agent() -> AgentContext:
return AgentContext(agent_id=qa.id, role=AgentRole.QA, team=Team.BACKEND)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = _override_agent
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield {
"client": client,
"agent": qa,
"project": project,
"db": db_session,
}
app.dependency_overrides.clear()
def _seed_task_qa(setup: dict, **kw) -> TaskTable:
task = TaskTable(
id=uuid4(),
title="t",
description="d",
acceptance_criteria=["ac"],
status=kw.pop("status", TaskStatus.AWAITING_QA),
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
project_id=setup["project"].id,
created_by=setup["agent"].id,
team=Team.BACKEND,
**kw,
)
setup["db"].add(task)
return task
@pytest.mark.asyncio
async def test_pass_qa_self_review_forbidden(qa_client: dict) -> None:
"""QA agent cannot pass-QA on a task where they were the original developer."""
task = _seed_task_qa(
qa_client,
pr_number=42,
quick_context=f"original_developer:{qa_client['agent'].id}",
)
await qa_client["db"].flush()
response = await qa_client["client"].post(
f"/api/tasks/{task.id}/pass-qa",
json={"notes": "looks good and covers all criteria"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
assert "your own task" in response.json()["detail"]
@pytest.mark.asyncio
async def test_pass_qa_no_pr_attached(qa_client: dict) -> None:
"""pass-qa without a PR returns NO_PR_ATTACHED 400."""
task = _seed_task_qa(qa_client, pr_number=None)
await qa_client["db"].flush()
response = await qa_client["client"].post(
f"/api/tasks/{task.id}/pass-qa",
json={"notes": "ok and was thorough enough for review"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert "NO_PR_ATTACHED" in response.json()["detail"]
@pytest.mark.asyncio
async def test_pass_qa_notes_too_short(qa_client: dict) -> None:
"""pass-qa with notes < 20 chars → QA_NOTES_REQUIRED 400."""
task = _seed_task_qa(qa_client, pr_number=42)
await qa_client["db"].flush()
response = await qa_client["client"].post(
f"/api/tasks/{task.id}/pass-qa",
json={"notes": "ok"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert "QA_NOTES_REQUIRED" in response.json()["detail"]
@pytest.mark.asyncio
async def test_pass_qa_no_notes_at_all(qa_client: dict) -> None:
"""pass-qa with NO body at all → QA_NOTES_REQUIRED 400."""
task = _seed_task_qa(qa_client, pr_number=42)
await qa_client["db"].flush()
response = await qa_client["client"].post(
f"/api/tasks/{task.id}/pass-qa",
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert "QA_NOTES_REQUIRED" in response.json()["detail"]
@pytest.mark.asyncio
async def test_pass_qa_success(qa_client: dict) -> None:
"""Happy path — QA passes a task, transitions to awaiting_documentation."""
task = _seed_task_qa(qa_client, pr_number=42)
await qa_client["db"].flush()
response = await qa_client["client"].post(
f"/api/tasks/{task.id}/pass-qa",
json={
"notes": (
"Verified all acceptance criteria match the PR diff. "
"No security issues."
)
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "awaiting_documentation"
@pytest.mark.asyncio
async def test_pass_qa_service_returns_none(qa_client: dict) -> None:
"""If service.pass_qa returns None → route 400s."""
task = _seed_task_qa(qa_client, pr_number=42)
await qa_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.get = AsyncMock(return_value=task)
instance.pass_qa = AsyncMock(return_value=None)
mock_factory.return_value = instance
response = await qa_client["client"].post(
f"/api/tasks/{task.id}/pass-qa",
json={"notes": "verified all acceptance criteria are met."},
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert "invalid status" in response.json()["detail"].lower()
# ---------------------------------------------------------------------------
# fail_qa: full body coverage
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_fail_qa_self_review_forbidden(qa_client: dict) -> None:
"""QA cannot fail-QA on a task where they were the dev."""
task = _seed_task_qa(
qa_client,
quick_context=f"original_developer:{qa_client['agent'].id}",
)
await qa_client["db"].flush()
response = await qa_client["client"].post(
f"/api/tasks/{task.id}/fail-qa",
json={"notes": "broken in many ways"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
assert "your own task" in response.json()["detail"]
@pytest.mark.asyncio
async def test_fail_qa_success(qa_client: dict) -> None:
"""fail-qa happy path → needs_revision."""
task = _seed_task_qa(qa_client)
await qa_client["db"].flush()
response = await qa_client["client"].post(
f"/api/tasks/{task.id}/fail-qa",
json={"notes": "broken in some ways"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "needs_revision"
@pytest.mark.asyncio
async def test_fail_qa_service_returns_none(qa_client: dict) -> None:
"""If service.fail_qa returns None → 400."""
task = _seed_task_qa(qa_client)
await qa_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.get = AsyncMock(return_value=task)
instance.fail_qa = AsyncMock(return_value=None)
mock_factory.return_value = instance
response = await qa_client["client"].post(
f"/api/tasks/{task.id}/fail-qa",
json={"notes": "broken"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
# ---------------------------------------------------------------------------
# docs_complete: success
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_docs_complete_success(task_client: dict) -> None:
"""Mock service.docs_complete_for_task to return a task, route serializes it."""
task = _seed_task(task_client)
await task_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.docs_complete_for_task = AsyncMock(return_value=task)
mock_factory.return_value = instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/docs-complete",
json={"notes": "documented thoroughly enough"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# submit_pm_review: success + None branch
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_submit_pm_review_success(task_client: dict) -> None:
"""Assigned in_progress task — assignee submits for PM review."""
task = _seed_task(
task_client,
status=TaskStatus.IN_PROGRESS,
assigned_to=task_client["agent"].id,
branch_name="feature/backend/X",
pr_created=True,
pr_number=42,
)
await task_client["db"].flush()
with patch(
"roboco.api.routes.tasks.get_notification_delivery_service"
) as mock_delivery:
delivery_instance = AsyncMock()
delivery_instance.notify_pm_of_review_submission = AsyncMock(return_value=None)
mock_delivery.return_value = delivery_instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/submit-pm-review",
json={"notes": "Submitted for PM review please."},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
delivery_instance.notify_pm_of_review_submission.assert_awaited_once()
@pytest.mark.asyncio
async def test_submit_pm_review_service_returns_none(task_client: dict) -> None:
"""If service.submit_for_pm_review returns None → 400."""
task = _seed_task(
task_client,
status=TaskStatus.IN_PROGRESS,
assigned_to=task_client["agent"].id,
)
await task_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.get = AsyncMock(return_value=task)
instance.submit_for_pm_review = AsyncMock(return_value=None)
mock_factory.return_value = instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/submit-pm-review",
json={"notes": "Ready for PM review — all criteria met."},
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert "not in progress" in response.json()["detail"].lower()
# ---------------------------------------------------------------------------
# complete_task: success path through service mock
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_complete_task_success(task_client: dict) -> None:
task = _seed_task(task_client)
await task_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.complete_task_for_agent = AsyncMock(return_value=task)
mock_factory.return_value = instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/complete",
json={
"force_with_cancelled": False,
"justification": "All acceptance criteria met; merging.",
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_complete_without_justification_rejected(task_client: dict) -> None:
"""Audit: completing a task must carry its rationale (>= 20 chars)."""
task = _seed_task(task_client)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/complete",
json={"force_with_cancelled": False},
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert "JUSTIFICATION_REQUIRED" in response.json()["detail"]
# ---------------------------------------------------------------------------
# cancel: service returns None branch (1162-1167)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cancel_task_service_returns_none(task_client: dict) -> None:
task = _seed_task(task_client)
await task_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.get = AsyncMock(return_value=task)
instance.cancel = AsyncMock(return_value=None)
mock_factory.return_value = instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/cancel",
json={"reason": "no longer needed at all"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
# ---------------------------------------------------------------------------
# CEO endpoints with separate ceo_client fixture
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def ceo_client(db_session: AsyncSession) -> AsyncIterator[dict]:
"""Client where agent role is CEO."""
ceo = AgentTable(
id=uuid4(),
name="CEO",
slug=f"ceo-{uuid4().hex[:8]}",
role=AgentRole.CEO,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="ceo",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(ceo)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="CEO-Proj",
slug=f"ceo-proj-{uuid4().hex[:6]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=ceo.id,
)
db_session.add(project)
await db_session.flush()
app = FastAPI()
app.include_router(tasks_router, prefix="/api/tasks")
async def _override_db():
yield db_session
async def _override_agent() -> AgentContext:
return AgentContext(agent_id=ceo.id, role=AgentRole.CEO, team=None)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = _override_agent
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield {
"client": client,
"agent": ceo,
"project": project,
"db": db_session,
}
app.dependency_overrides.clear()
def _seed_task_ceo(setup: dict, **kw) -> TaskTable:
task = TaskTable(
id=uuid4(),
title="t",
description="d",
acceptance_criteria=["ac"],
status=kw.pop("status", TaskStatus.AWAITING_CEO_APPROVAL),
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
project_id=setup["project"].id,
created_by=setup["agent"].id,
team=Team.BACKEND,
**kw,
)
setup["db"].add(task)
return task
@pytest.mark.asyncio
async def test_get_awaiting_pm_review_via_query(task_client: dict) -> None:
"""Hit get_awaiting_pm_review_tasks helper directly (route ordering quirk)."""
db = task_client["db"]
agent_ctx = AgentContext(
agent_id=task_client["agent"].id, role=AgentRole.MAIN_PM, team=None
)
permissions = PermissionService()
result = await get_awaiting_pm_review_tasks(
db=db,
agent=agent_ctx,
permissions=permissions,
team=Team.BACKEND,
)
assert isinstance(result, list)
@pytest.mark.asyncio
async def test_get_awaiting_pm_review_no_view_all(task_client: dict) -> None:
"""Developer (no VIEW_ALL) — falls back to agent.team."""
db = task_client["db"]
agent_ctx = AgentContext(
agent_id=task_client["agent"].id,
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
)
permissions = PermissionService()
result = await get_awaiting_pm_review_tasks(
db=db, agent=agent_ctx, permissions=permissions, team=None
)
assert isinstance(result, list)
@pytest.mark.asyncio
async def test_get_awaiting_ceo_approval_pm_role(task_client: dict) -> None:
"""Main PM can view CEO approval queue — direct invocation."""
db = task_client["db"]
agent_ctx = AgentContext(
agent_id=task_client["agent"].id, role=AgentRole.MAIN_PM, team=None
)
permissions = PermissionService()
result = await get_awaiting_ceo_approval_tasks(
db=db, agent=agent_ctx, permissions=permissions
)
assert isinstance(result, list)
@pytest.mark.asyncio
async def test_get_awaiting_ceo_approval_developer_forbidden(
task_client: dict,
) -> None:
"""Developer (no VIEW_ALL, not PM/CEO) → 403 from helper."""
db = task_client["db"]
agent_ctx = AgentContext(
agent_id=task_client["agent"].id,
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
)
permissions = PermissionService()
with pytest.raises(HTTPException) as exc_info:
await get_awaiting_ceo_approval_tasks(
db=db, agent=agent_ctx, permissions=permissions
)
assert exc_info.value.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_escalate_to_ceo_returns_task(task_client: dict) -> None:
"""Mock service.escalate_to_ceo_for_agent → route returns serialized task."""
task = _seed_task(task_client)
await task_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.escalate_to_ceo_for_agent = AsyncMock(return_value=task)
mock_factory.return_value = instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/escalate-to-ceo",
json={"notes": "Need CEO sign-off please"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_ceo_approve_task_not_found(ceo_client: dict) -> None:
response = await ceo_client["client"].post(
f"/api/tasks/{uuid4()}/ceo-approve",
json={"notes": "approved"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_ceo_approve_service_returns_none(ceo_client: dict) -> None:
"""If service.ceo_approve returns None — 400."""
task = _seed_task_ceo(ceo_client, status=TaskStatus.PENDING)
await ceo_client["db"].flush()
response = await ceo_client["client"].post(
f"/api/tasks/{task.id}/ceo-approve",
json={"notes": "Reviewed and approved for production release."},
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert "not awaiting CEO" in response.json()["detail"]
@pytest.mark.asyncio
async def test_ceo_approve_success(ceo_client: dict) -> None:
task = _seed_task_ceo(ceo_client)
await ceo_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.get = AsyncMock(return_value=task)
instance.ceo_approve = AsyncMock(return_value=task)
mock_factory.return_value = instance
response = await ceo_client["client"].post(
f"/api/tasks/{task.id}/ceo-approve",
json={"notes": "Verified against all acceptance criteria; approved."},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_ceo_approve_without_notes_rejected(ceo_client: dict) -> None:
"""Audit: a CEO approval with no/thin notes leaves no record of WHY the
work shipped, so the endpoint must reject it (>= 20 chars required). The
panel collects the note before POSTing."""
task = _seed_task_ceo(ceo_client)
await ceo_client["db"].flush()
for body in ({}, {"notes": ""}, {"notes": "lgtm"}):
response = await ceo_client["client"].post(
f"/api/tasks/{task.id}/ceo-approve",
json=body,
headers=_HDR,
)
assert response.status_code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.UNPROCESSABLE_ENTITY,
), (body, response.status_code)
@pytest.mark.asyncio
async def test_approve_and_start_success(ceo_client: dict) -> None:
task = _seed_task_ceo(ceo_client, status=TaskStatus.PENDING)
await ceo_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.get = AsyncMock(return_value=task)
instance.approve_and_start = AsyncMock(return_value=task)
mock_factory.return_value = instance
resp = await ceo_client["client"].post(
f"/api/tasks/{task.id}/approve-and-start",
json={"notes": "Board review complete; clear requirements; build it now."},
headers=_HDR,
)
assert resp.status_code == HTTPStatus.OK
instance.approve_and_start.assert_awaited_once()
@pytest.mark.asyncio
async def test_approve_and_start_requires_ceo(task_client: dict) -> None:
# task_client is MAIN_PM-role; the inline CEO guard must 403.
resp = await task_client["client"].post(
f"/api/tasks/{uuid4()}/approve-and-start",
json={"notes": "x" * 30},
headers=_HDR,
)
assert resp.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_approve_and_start_short_notes(ceo_client: dict) -> None:
task = _seed_task_ceo(ceo_client, status=TaskStatus.PENDING)
await ceo_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.get = AsyncMock(return_value=task)
mock_factory.return_value = instance
resp = await ceo_client["client"].post(
f"/api/tasks/{task.id}/approve-and-start",
json={"notes": "too short"},
headers=_HDR,
)
assert resp.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_approve_and_start_missing_task_404_before_notes_gate(
ceo_client: dict,
) -> None:
# Missing task -> 404 even with valid notes: the not-found guard runs
# before the notes gate, so service.approve_and_start is never reached.
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.get = AsyncMock(return_value=None)
mock_factory.return_value = instance
resp = await ceo_client["client"].post(
f"/api/tasks/{uuid4()}/approve-and-start",
json={"notes": "Board review complete; clear requirements; build it now."},
headers=_HDR,
)
assert resp.status_code == HTTPStatus.NOT_FOUND
instance.approve_and_start.assert_not_awaited()
@pytest.mark.asyncio
async def test_ceo_reject_task_not_found(ceo_client: dict) -> None:
response = await ceo_client["client"].post(
f"/api/tasks/{uuid4()}/ceo-reject",
json={"notes": "rejected"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_ceo_reject_service_returns_none(ceo_client: dict) -> None:
"""ceo_reject on a task not in awaiting_ceo_approval — service None → 400."""
task = _seed_task_ceo(ceo_client, status=TaskStatus.PENDING)
await ceo_client["db"].flush()
response = await ceo_client["client"].post(
f"/api/tasks/{task.id}/ceo-reject",
json={"notes": "rejected"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_ceo_reject_success_notifies_assignee(ceo_client: dict) -> None:
"""ceo_reject success path with assignee triggers notification."""
other = AgentTable(
id=uuid4(),
name="Dev",
slug=f"dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
ceo_client["db"].add(other)
await ceo_client["db"].flush()
task = _seed_task_ceo(ceo_client, assigned_to=other.id)
await ceo_client["db"].flush()
with (
patch("roboco.api.routes.tasks.get_task_service") as mock_factory,
patch(
"roboco.api.routes.tasks.get_notification_delivery_service"
) as mock_delivery,
):
instance = AsyncMock()
instance.get = AsyncMock(return_value=task)
instance.ceo_reject = AsyncMock(return_value=task)
mock_factory.return_value = instance
delivery_instance = AsyncMock()
delivery_instance.notify_assignee_of_ceo_rejection = AsyncMock(
return_value=None
)
mock_delivery.return_value = delivery_instance
response = await ceo_client["client"].post(
f"/api/tasks/{task.id}/ceo-reject",
json={"notes": "rejected with detailed notes"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
delivery_instance.notify_assignee_of_ceo_rejection.assert_awaited_once()
# ---------------------------------------------------------------------------
# escalate (general): success + EscalationError 403/400
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_escalate_task_success(task_client: dict) -> None:
"""escalate_and_notify returns outcome → service.apply_escalation runs."""
task = _seed_task(task_client)
await task_client["db"].flush()
outcome = SimpleNamespace(
target_agent_id=uuid4(),
escalator_slug="be-dev-1",
target_slug="be-pm",
)
with (
patch(
"roboco.api.routes.tasks.get_notification_delivery_service"
) as mock_delivery,
patch("roboco.api.routes.tasks.get_task_service") as mock_factory,
):
instance = AsyncMock()
instance.get = AsyncMock(return_value=task)
instance.apply_escalation = AsyncMock(return_value=None)
mock_factory.return_value = instance
delivery_instance = AsyncMock()
delivery_instance.escalate_and_notify = AsyncMock(return_value=outcome)
mock_delivery.return_value = delivery_instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/escalate",
json={"reason": "Need help — out of scope"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
body = response.json()
assert body["status"] == "escalated"
assert body["escalated_to"] == "be-pm"
@pytest.mark.asyncio
async def test_escalate_task_escalation_error_404(task_client: dict) -> None:
"""EscalationError starting with 'escalator agent' → 404."""
task = _seed_task(task_client)
await task_client["db"].flush()
with patch(
"roboco.api.routes.tasks.get_notification_delivery_service"
) as mock_delivery:
delivery_instance = AsyncMock()
delivery_instance.escalate_and_notify = AsyncMock(
side_effect=EscalationError("escalator agent missing")
)
mock_delivery.return_value = delivery_instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/escalate",
json={"reason": "stuck"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_escalate_task_escalation_error_403(task_client: dict) -> None:
"""EscalationError 'Cannot escalate to ...' → 403."""
task = _seed_task(task_client)
await task_client["db"].flush()
with patch(
"roboco.api.routes.tasks.get_notification_delivery_service"
) as mock_delivery:
delivery_instance = AsyncMock()
delivery_instance.escalate_and_notify = AsyncMock(
side_effect=EscalationError("Cannot escalate to qa")
)
mock_delivery.return_value = delivery_instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/escalate",
json={"reason": "stuck"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_escalate_task_escalation_error_400(task_client: dict) -> None:
"""Other EscalationError → 400."""
task = _seed_task(task_client)
await task_client["db"].flush()
with patch(
"roboco.api.routes.tasks.get_notification_delivery_service"
) as mock_delivery:
delivery_instance = AsyncMock()
delivery_instance.escalate_and_notify = AsyncMock(
side_effect=EscalationError("no chain configured")
)
mock_delivery.return_value = delivery_instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/escalate",
json={"reason": "stuck"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
# ---------------------------------------------------------------------------
# substitute: success
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_substitute_task_success(task_client: dict) -> None:
task = _seed_task(task_client, assigned_to=task_client["agent"].id)
await task_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.substitute_task_for_agent = AsyncMock(return_value=task)
mock_factory.return_value = instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/substitute",
json={"reason": "low_context", "details": "Need more context"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# progress / checkpoint / commit: success and 500-fallback
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_add_progress_success(task_client: dict) -> None:
task = _seed_task(task_client, assigned_to=task_client["agent"].id)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/progress",
json={"message": "Halfway done now", "percentage": 50},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_add_progress_service_returns_none_500(task_client: dict) -> None:
task = _seed_task(task_client, assigned_to=task_client["agent"].id)
await task_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.get = AsyncMock(return_value=task)
instance.add_progress = AsyncMock(return_value=None)
mock_factory.return_value = instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/progress",
json={"message": "halfway done now", "percentage": 50},
headers=_HDR,
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
@pytest.mark.asyncio
async def test_add_checkpoint_success(task_client: dict) -> None:
task = _seed_task(task_client, assigned_to=task_client["agent"].id)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/checkpoint",
json={"state_summary": "halfway", "remaining_work": ["finish API"]},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_add_checkpoint_service_returns_none_500(task_client: dict) -> None:
task = _seed_task(task_client, assigned_to=task_client["agent"].id)
await task_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.get = AsyncMock(return_value=task)
instance.add_checkpoint = AsyncMock(return_value=None)
mock_factory.return_value = instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/checkpoint",
json={"state_summary": "halfway", "remaining_work": ["finish"]},
headers=_HDR,
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
@pytest.mark.asyncio
async def test_add_commit_success(task_client: dict) -> None:
task = _seed_task(task_client, assigned_to=task_client["agent"].id)
await task_client["db"].flush()
response = await task_client["client"].post(
f"/api/tasks/{task.id}/commit",
json={"hash": "abc1234", "message": "fix"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_add_commit_service_returns_none_500(task_client: dict) -> None:
task = _seed_task(task_client, assigned_to=task_client["agent"].id)
await task_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.get = AsyncMock(return_value=task)
instance.add_commit = AsyncMock(return_value=None)
mock_factory.return_value = instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/commit",
json={"hash": "abc1234", "message": "fix"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
# ---------------------------------------------------------------------------
# activate: success + ValueError + TaskLifecycleError
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_activate_success(task_client: dict) -> None:
task = _seed_task(task_client, status=TaskStatus.BACKLOG)
await task_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.activate = AsyncMock(return_value=task)
mock_factory.return_value = instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/activate", headers=_HDR
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_activate_value_error_returns_400(task_client: dict) -> None:
task = _seed_task(task_client, status=TaskStatus.BACKLOG)
await task_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.activate = AsyncMock(side_effect=ValueError("no session linked"))
mock_factory.return_value = instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/activate", headers=_HDR
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert "no session" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_activate_task_lifecycle_error_returns_403(task_client: dict) -> None:
task = _seed_task(task_client, status=TaskStatus.BACKLOG)
await task_client["db"].flush()
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.activate = AsyncMock(
side_effect=TaskLifecycleError(
current_status="backlog",
target_status="pending",
message="Wrong role",
)
)
mock_factory.return_value = instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/activate", headers=_HDR
)
assert response.status_code == HTTPStatus.FORBIDDEN
# ---------------------------------------------------------------------------
# get_sessions_for_task: 404 path for unknown task
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_sessions_for_task_not_found(task_client: dict) -> None:
response = await task_client["client"].get(
f"/api/tasks/{uuid4()}/sessions", headers=_HDR
)
assert response.status_code == HTTPStatus.NOT_FOUND