mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* release-manager: fencing-token mutex + executor/readiness hardening
Closes the release-mutex TTL race (#17, HIGH) and the remaining
release-manager gaps (#88, #89, #201, #202):
- #17: the release mutex is now acquired with a uuid4 fencing token and
released via Lua compare-and-del; a background asyncio heartbeat
compare-and-expires the TTL ~every 60s while the execute owns the lock,
so a live execute no longer expires and a crashed one auto-releases
<=3000s. A second approve after TTL expiry cannot usurp and rm -rf the
in-flight clone — the fenced first-finally keeps its lock.
- #89: a Redis outage during acquire stays fail-closed (the execute never
runs) but now returns a distinct redis_unavailable result + log so the
CEO sees the cause instead of a false already_in_progress.
- #88: commit_and_push RuntimeError is wrapped into a structured
ReleaseResult(commit_failed) instead of a 500.
- #201: first-release fallback still emits untracked version-ref files as
gaps (no longer silenced by the first-release branch).
- #202: _await_proc awaits proc.wait() after kill() so a timeout cannot
leak a zombie.
TDD: tests/unit/services/test_release_proposal_concurrency.py extends
_FakeRedis with eval/get/expire and pins the fencing/heartbeat/usurper
invariants + the redis_unavailable result.
* PM/code-task creation guard + main_pm coverage + issue carve-out
Closes the creation-time role x task_type gap (the user's explicit example)
and the main_pm delegate hole:
- New pure helper `pm_cannot_own_code(role, task_type, is_issue_resolution)`
in foundation/policy/batch.py — single source of truth. Both PM roles
(cell_pm + main_pm) coordinate; a `code` task assigned/claimed by a PM is
a structural mismatch, EXCEPT a PM taking a code task in needs_revision to
resolve review/QA issues directly (the carve-out).
- Creation-time guard: TaskService.create calls the helper (closes the
create-with-cell-PM-assignee hole the team-based check misses).
- Delegate path + spec claim gate consult the same helper.
`_validate_assignee_task_type` / `_task_type_hint_for` now key on the
Role (CELL_PM OR MAIN_PM), not the cell-PM slug set — closes the
delegate-to-main-pm-as-code hole.
- identity.role_for_uuid_or_none is None-tolerant (treats None as "not a
PM" and proceeds) so a malformed/missing assignee cannot crash the guard.
- prompter.create_task_from_draft reuses the guard at draft-create.
TDD: test_batch.py (helper matrix + carve-out), test_main_pm_code_guard.py
(main_pm coverage), test_delegate_assignee_task_type.py (delegate parity),
test_lifecycle_spec.py (claim gate: rejects PM claiming code from pending,
allows from needs_revision + PM claiming planning + dev claiming code).
* task-service: completion hooks + escalation/cancel/audit hardening
Closes the task-service cluster (#21/#98, #99, #100, #101, #103, #216; #102
verified already-covered, #217 verified already-guarded):
- #21/#98: ceo_approve now closes the work session + triggers completion
hooks before worktree removal (no-op when work_session_id is None), so a
CEO-approved task lands the same close-path as PM-completed.
- #99: apply_escalation routes through the transition validator with an
enumerated escalation exemption (_ESCALATABLE_TO_BLOCKED) instead of an
arbitrary source->BLOCKED write; BACKLOG is refused.
- #100: branchless ceo_reject awaiting_ceo_approval->pending gets a real
spec edge (ceo_reject_to_pool ActionSpec + _STATUS_TRANSITIONS entry) so
future admin-override tightening can't wedge the path.
- #101: revision_count bump is documented as the single chokepoint, with
the pre-block RESTORE path undoing it when restoring a snapshotted
needs_revision (same cycle resuming, not a new rejection).
- #103: cancel cascade surfaces non-terminal orphans instead of swallowing
the role violation.
- #216: _remove_task_worktree_on_terminal escalates recurring FS/permission
failure (audit/notify after N) instead of silent-failing forever.
- #102: pinned in test_verb_runner_midverb_invalid_state.py (committed with
the choreographer cluster) — verb-runner savepoints already surface a
concurrent mid-verb state change as INVALID_STATE.
- #217: submit_for_qa claimed_by guard verified intact.
TDD: test_task.py, test_worktree_cleanup_on_complete.py,
test_escalation_board_guard.py (#99), test_task_service_* integration,
test_lifecycle_spec.py.
* choreographer: gate-claim guards + pr-gate hardening + fail-open logging
Closes the choreographer cluster (#5/#222, #29, #30, #82, #188, #189,
#192; #157/#187 verified already-fixed/pinned; #102 pin lives here):
- #5/#222: the unchanged-PR guard's fail-open head_sha lookup now logs
(warning) on a slug-resolver/git-helper error so a regression cannot
silently turn the pr_fail re-submit loop-stopper into a no-op. Stays
fail-open (never wedges the PM).
- #29: pinned (REFUTED-with-pin) — _lane_claim_guard already returns the
error envelope without releasing the claim on a transient lookup error.
- #30: pinned (REFUTED-with-negative-pin) — a non-batch branchless main_pm
root cannot bypass the complete spec gate (is_batch_umbrella requires
batch_id set).
- #82: _post_gate_review_to_pr wraps the slug-resolution call in try/except
(mirrors _capture_pr_head_sha) so a malformed cell_map AttributeError no
longer 500s the reviewer after a committed gate transition.
- #188: _is_hand_formatted_verdict anchors the header regex to line-start,
so a quoted (> ## Summary) or inline (mid-prose) header mention no longer
false-refuses a hand-formatted verdict.
- #189: pr_fail re-captures the PR head SHA after the transition commits and
re-stamps the verdict note only when it advanced (closes the stale-SHA
false-allow loop-hole); no-advance stays a single note write.
- #192: claim_gate_review skips the dev claim guards (already_active/paused/
lane) via a new skip_dev_guards param — a pr_reviewer inspecting an
assembled PR does not start work, so the single-active-task / code-lane
invariants do not apply; the dependency guard is kept, and QA's
claim_review parity is preserved.
- #157/#187: verified in tree — pr_review-only handoff is intentionally
prior-work-worth-resuming; self_review_block wiring (reviewer != dev)
holds on assembled tasks with 4 existing pin tests.
TDD: test_choreographer_*, test_pr_gate_posts_review (#82),
test_pr_review_hand_format_guard (#188), test_submit_root_unchanged_pr_guard
(#189), test_claim_gate_review_guards (#192), test_verb_runner_midverb
_invalid_state (#102 pin).
* playbook curate: guard the gating commit against a poisoned session (#55)
The explicit `session.commit()` that gates the RAG index (commit-before-index
so an uncommitted playbook cannot land in the corpus) raised PendingRollbackError
when a prior mid-verb failure had rolled the caller's session back — 500ing the
whole curation verb instead of returning a clean envelope, and (worse) risking a
fall-through to index an uncommitted playbook. Wrap the commit: on
PendingRollbackError, log + return invalid_state with a re-fetch/retry remediate
and skip the index. The happy path still commits exactly once then indexes.
TDD: test_playbook_verbs.py — poisoned-session returns a clean invalid_state and
does NOT index; clean-session still commits once + indexes (pins no fail-closed
inversion / no double-commit).
* [chore] gateway: atomic activate merge — preserve probe_failures across re-park (#156)
activate() was a blind SET that reset probe_failures to 0, so a probe-failure
increment that just landed (or was in flight) could be wiped by a concurrent
re-park — resetting the give-up / CEO-notify count mid-episode. Route activate
through a server-side Lua merge (roboco:activate_rate_limit) that refreshes the
episode metadata (kind / activated_at / retry_after / affected_agents) while
carrying over the previous probe_failures count. Indivisible w.r.t. the
increment/reset scripts (Redis single-threads an EVAL).
#56 (notify to prompter/secretary refused) verified SAFE — the pin tests
(test_notify_rejects_prompter_recipient / _secretary_recipient /
_allows_ceo_recipient) already cover the only human notify target invariant;
no legitimate send is dropped, no code change.
* [chore] foundation/policy: spec gates + QA retry-key pin (Cluster F)
#50 sync_branch composes=() so the spec gate accepted a terminal/paused/
blocked task and the handler rebased a dead/parked branch — add a
PRECONDITION_SYNC_BRANCH_STATE (claimed/in_progress/verifying/needs_revision
only), rejection_kind=invalid_state. TDD: 28 spec tests.
#148 submit_root's prose asserts 'a Main-PM root is planning-typed, never
code' but only the creation path (main_pm_cannot_own_code) backed it — add
PRECONDITION_ROOT_NOT_CODE on the submit_root IntentSpec (defense in depth),
scoped to submit_root only so the shared submit_for_review action keeps
cell_pm+code submit_up parity. Graceful on Mock/None task_type so the
choreographer Mock-task tests don't crash. TDD: 2 spec tests.
#150 VERB_RETRY_LIMITS is keyed by the MCP-exposed names (pass/fail), not
the IntentSpec-internal pass_review/fail_review — already correct; add a
pin test so a one-sided rename can't silently drop the QA-handoff cap.
#142 main_pm_cannot_own_code/pm_cannot_own_code already normalize casing
(.lower()) — no-op, pin test test_main_pm_cannot_own_code_is_case_insensitive
already in tree.
* [chore] worksession-git: 405 merge-method fallback + non-destructive close (Cluster W)
#108 _merge_with_retry hardcoded 'squash' and raised MergeConflictError on a
405 with no method fallback — wedging the PM on an open, mergeable PR whose
repo merely had the squash button off. Add a 405 fallback to a permitted
method (via _first_allowed_merge_method, exclude='squash'), mirroring the CEO
merge_pull_request path. A 405 with no permitted fallback (or a second 405)
still falls through to the already-merged disambiguation / MergeConflictError.
TDD: 2 new tests (fallback-success, no-permitted-method-raises).
#109 close_pull_request defaulted delete_branch=True, so the choreographer
supersede path deleted a superseded PR's branch while the orchestrator
supersede path explicitly preserved it — the two disagreed, and the
destructive default ran on the 'close the dead PR' path where the branch may
still be referenced / useful for audit. Flip the default to False (opt-in
deletion) and make the choreographer caller explicit (parity with the
orchestrator). TDD: 1 new test (default preserves branch); existing
deletion-when-requested test now passes delete_branch=True explicitly.
Dispositions verified against current code (no silent drops):
- #27 REFUTED/FIXED-UNDEPLOYED: work_session.merge_pr resolves by session_id
(no global pr_number lookup); the real cross-repo collision fix
(project_id scoping on pr_merge/close_pull_request/rebase_pr_for_task/
pr_target) is already in tree + tested (test_pr_merge_scopes_task_lookup_
by_project_id, test_close_pull_request_scopes_task_lookup_by_project_id,
test_git_pr_target_scoping). Verify-only.
- #106 REFUTED: a guard exists (rev-list --count {base_ref}..{branch} == 0)
before reset --hard + base_ref falls back to default_branch; tests lock
the safety (test_create_branch_never_repoints_branch_with_real_work,
test_create_branch_does_not_reset_or_checkout_shared_clone).
- #218 BY-DESIGN: the merge_pr idempotent guard intentionally preserves the
audit trail (docstring + test_merge_pr_idempotent_on_already_completed_
preserves_audit_trail); a COMPLETED session always carries attribution
(COMPLETED only via merge_pr), so the NULL-COMPLETED case is unreachable.
- #104 BY-DESIGN: agents never merge to the repo default branch in RoboCo's
model (root→master is CEO-only); the guard is a correct CEO-only rail,
locked by test_pr_merge_into_default_branch_is_ceo_only.
* [chore] llm: surface disabled-provider downgrade + scrub probe log (#20/#3/#211)
#20/#3 resolve_for_agent silently fell through to the legacy Anthropic path
when a configured provider was disabled — indistinguishable from 'no
assignment', so the operator got no signal that spawns bypassed the
provider. Surface the bypass with a warning (graceful degradation stays the
default — a stalled spawn is worse than a routing miss) and add an opt-in
ROBOCO_ROUTING_STRICT (default-off) that fail-closes instead. Wired into the
panel Feature Flags card. TDD: 3 unit tests (warn-on-disabled, strict-raises,
no-assignment-stays-silent).
#211 probe_ollama_tags logged str(exc) raw on the generic-exception branch —
structured log could carry connection internals / stack traces. Log the
exception class name only. TDD: existing generic-branch test strengthened to
assert the log kwargs don't leak the raw text.
* [chore] support/stream/optimal/playbook/comms hardening (Cluster S)
Logical-gaps sweep, Cluster S (TDD, red→green per item):
#64 notification_delivery.acknowledge published the NOTIFICATION_ACKED bus
event directly (bypassing the outbox) — a rollback left a phantom ACK. Route
it through defer_bus_publish (after_commit), mirroring deliver.
#76 playbook.archive()/reject() stamped the archiver into approved_by/
approved_at, overwriting approval provenance (and fabricating approval for a
rejected draft). Add archived_by/archived_at (migration 053 + table + model)
and write those on archive/reject, leaving approval attribution intact.
#181 vector_store.replace_chunks wiped existing index rows even when every
chunk lacked an embedding (embedder failure). Skip the wipe when chunks is
non-empty but records is empty — preserve good rows for nothing.
#182/#183 optimal.record_learning recomputed a learn-{md5(full_content)}
tracking source that never matched the URI the plugin embedded chunks under
(roboco://learnings/{doc_id}, doc_id=lrn-{hash100}). Use the plugin's
returned doc_id so de-index/lookup-by-source finds the chunk rows.
#96/#97 transcription periodic flush only peeked ready buffers (unbounded
map growth) and ran sync callbacks on the event loop (a slow callback
blocked the flush task). Flush (remove) each ready buffer after notifying,
and offload each callback to a thread.
#212 _TEAM_SCOPED_ROLES was duplicated across communications/agents_config/
seeds. Single-source it in foundation.policy.communications; consumers
reference that object (identity-tested).
#19 stream_bus._dispatch_event re-ran already-succeeded handlers on a
recover_pending replay (duplicate side effects). Add a per-(event.id,
handler) SET-NX idempotency guard: skip on a hit, clear the key on handler
failure so a replay re-runs it, fail-open when redis is unavailable.
Dispositions (no code change): #77 approve() index-write pair asserted
BY-DESIGN; #62/#63 notification DB-dedup verified pinned; #184/#185 REFUTED;
#214 REFUTED; #215 BY-DESIGN.
* [chore] db/migrations: graph-integrity guard + conftest unreachable-DB warning (Cluster D)
Logical-gaps sweep, Cluster D (TDD + real alembic upgrade head verification):
#16/#37 add tests/unit/test_migration_graph_integrity.py — a static guard that
the alembic migration graph has exactly one head, every down_revision resolves,
every revision is reachable from a root, and no revision id is duplicated. The
suite builds its DB via Base.metadata.create_all (not alembic upgrade head), so
a forked head / dangling down_revision / duplicate id would otherwise ship
silently and break a real deploy mid-stream.
Caught a real bug in the process: migration 053's revision id
"053_playbook_archived_attribution" (33 chars) exceeded alembic's
alembic_version.version_num VARCHAR(32) — a fresh `alembic upgrade head` raised
"value too long for type character varying(32)" at the 053 stamp. Renamed to
"053_playbook_archived_attr" (26 chars). Verified end-to-end on a scratch PG:
upgrade head stamps 053, downgrade -1 returns to 052. (The pre-existing
test_every_migration_revision_id_fits_the_alembic_version_column guard is now
green too; it had been red on the 33-char id.)
#90 conftest silently pytest.skip'd every DB test when Postgres was unreachable
— a non-Docker box reported a green run of all-skips. Extract the warning into
_warn_if_pg_unavailable and fire it at import so the operator sees the DB is
down (the per-test skip path is unchanged). Test: warns when unavailable, silent
when reachable (verified under -W error::UserWarning).
Dispositions (verified against real code + a fresh alembic upgrade head, no code
change): #6 REFUTED — sa.Enum(create_type=False) at 001:119/304 does NOT break a
fresh upgrade head (001→052 applied cleanly on a scratch DB); #8 REFUTED — the
upgrade passed 030/031 (RAG chunk tables) without pgvector installed; pgvector is
a runtime concern handled by roboco/db/base.py, not a migration prerequisite;
#40 REFUTED — the `|| echo` mask was already removed and partial-schema drift
reports exit 1 (only by-design unreachable/unmigrated skips remain); #204 REFUTED
— the property walk seed IS pinned (random.Random(20260504), line 97); #205/#206
REFUTED — the smoke-trace fixture IS wired via
test_lifecycle_smoke_replay.py (8 passed); no shell smoke scripts exist in the
tree to wire; #137 BY-DESIGN — pyproject version 0.14.0 is an operational note,
no code gate.
* [chore] panel: admin-override force flag + kanban subtask_count + ws cleanup + ui-store dedupe (Cluster P)
#13: kanban admin-override into a hatch state (completed / awaiting_qa /
awaiting_pm_review) now requires an explicit force=true from the panel and
emits a dedicated task.admin_override audit row server-side; non-hatch
overrides need no force. Backend gate in tasks route + admin_set_status;
panel kanban-board sends force for hatch targets; TaskUpdate carries force.
#198: kanban service threads the real subtask_count (one grouped query) into
dev + priority-swimlane + main-pm-flat boards instead of a hardcoded 0.
#79: useWebSocket cleanup clears messages/lastMessage/state on unmount or
endpoint change so a dep-change (navigating to another stream) can't leak the
prior subscription's stale snapshot as live.
#186: disambiguate the duplicate ui-store modules -- the session/scroll store
in lib/stores renamed to useScrollRestorationStore / scroll-restoration-store
(barrel + 2 consumers updated); the sidebar/theme useUIStore in @/store is now
the sole useUIStore.
#12: verified already in-tree (release-proposal-card surfaces non-404 errors
with retry; getProposal maps only 404->null). #80 by-design (handleTransportError
already resets isSending on a no-payload SSE drop). #81 docs (streamUrl docstring
records that live-intake SSE auth is session-id-based bearer-style).
Backend: ruff+mypy clean, 278 tests green. Panel: lint+typecheck clean, 159 tests.
* orchestrator: park/reaper/readopt/a2a hardening + self-heal/ci-watch dedupe (Cluster O)
Closes the orchestrator-side logical gaps from the sweep:
- #75 a2a human-only drop surfaced: _dispatch_a2a_work logs the skip
("a2a request targets a human-only role; left as a notification for the
human (not spawned)") instead of silently dropping the target — the
CEO/secretary/prompter still see the notification; only the spawn is
suppressed. (orchestrator.py)
- #72 readopt liveness: _readopt_running_agents requires a non-stale live
claim (via _agent_holds_live_claim) and skips a zombie container so a
reaped-but-restart-readopted agent isn't double-counted as active.
- #74 shutdown drain: stop() calls _flush_respawn_tracker so the durable
respawn counter write-throughs aren't lost on a clean stop.
- #71 resolve_wait active-guard + deferred liveness: a rate_limit_lifted
WaitingRecord is only confirmed-live after a _confirm_resume_liveness
probe (deferred deletion _resume_confirm_delay=30.0), and an
already-active agent short-circuits the repark. Scoped to
rate_limit_lifted records (the only ones at risk of a false lift).
- #73 stuck-Claude kill: _maybe_kill_stuck_claude + _claude_stuck_kill_ttl
(config.claude_stuck_kill_seconds) — a live container whose heartbeat is
stale past the grace AND whose gateway probe is broken is killed+evicted,
not protected forever by the reaper's live-skip.
- #230 verified FIXED-UNDEPLOYED: _gateway_broken_past_grace already
requires N consecutive false-broken probes (not one flaky streak); no
change, test added to pin the N-consecutive invariant.
- #43 self-heal per-observation dedupe: a fingerprint collapses repeat
CEO notifications for the same CI regression.
- #44 ci_watch dedupe by (git_url, workflow): a monorepo's multiple
workflows each get their own fix task (was collapsed by git_url alone).
- #49 identity.role_for_slug_or_none None-hardening: a stale/malformed
slug resolves to None and the human-only skip falls through to the safe
"not spawnable" path instead of crashing.
- #193 strategy engine: notify the CEO on a persistent assess failure
instead of failing silently in the background loop.
TDD: test_no_spawn_human_roles (a2a skip surfaced), test_orchestrator_
shutdown_drain (#74), test_provider_overload_break (#71), test_readopt_
running_agents (#72), test_resolve_wait_repark (#71), test_stale_claim_
reaper (#73/#230), test_strategy_engine_loop (#193, new),
test_self_heal_engine (#43), test_ci_watch_engine (#44), test_identity
(#49). All red->green.
* chore: make-quality green — xenon complexity refactors + mypy test fixes + lifecycle regen
No behavior changes. Brings the tree to a fully green `make quality` (the
base branch never passed the xenon B-rank gate on several blocks; the
lifecycle artifacts had drifted from the committed ceo_reject_to_pool edge).
Xenon B-rank refactors (extract a helper; preserve semantics exactly):
- api/routes/tasks.py: _apply_forced_status_override + _StatusOverride
dataclass bundle (update_task override block).
- services/task.py: _enforce_no_pm_code_on_create (create guards) +
_escalation_diverts_to_pool (collapses the two board/advisory +
main_pm+code divert branches into one predicate).
- services/prompter.py: _coerce_pm_code_to_planning (create_task_from_draft).
- services/notification.py: _duplicate_unacked_exists (_create_notification
purpose-based dedup query + ACK_REQUIRED_BY_TYPE gate).
- services/sequencing.py: _same_assignee_lane_edges (the undeclared-surface
same-assignee lane fallback at the tail of dev_task_collision_edges).
- gateway/choreographer/_impl.py: _pm_task_type_error static helper
(_validate_assignee_task_type compound PM guard).
- gateway/choreographer/pr_gate.py: _gate_review_event_verdict +
_gate_review_body static helpers (_post_gate_review_to_pr).
mypy test fixes (no type:ignore — banned; use typing.cast with quoted
strings per TC006):
- test_task_update_completeness: TaskUpdate(acceptance_criteria=None).
- test_bus: cast("Redis", _FakeRedis()); Redis import under TYPE_CHECKING.
- test_pr_merge_concurrency: capture AsyncMocks into locals before asserting.
- test_notification_delivery_phantom: cast("UUID", to_agents[0]).
Lifecycle artifact regen (owed from Cluster T #100 — the
awaiting_ceo_approval -> pending `ceo_reject_to_pool` edge was added to the
spec in 3d633084 without regenerating the derived artifacts the
foundation-check gate diffs against): docs/rag/lifecycle/intent-verbs.md,
docs/rag/lifecycle/status-transitions.md, panel/lib/lifecycle.json.
services/kanban.py: ruff format only (collapses the _load_subtask_counts
signature that drifted unformatted from Cluster P).
* [chore] logical-gaps sweep — Cluster I (intake/product/pitch)
#57/#58 prompter: preserve a top-level product_id with a 1-cell map
(prompter.py create_task_from_draft — top-level target wins over a
redundant 1-cell map instead of dropping product_id); reject — not
silently skip — a malformed project_id in the_work cell entries
(prompter.py _draft_cell_map raises ValidationError).
#59/#159 prompter: create_task_from_draft now operates on a copy
(_copy_draft) so _validate_and_coerce_draft / _clean_list never mutate
the caller's draft dict.
#160 prompter: _resolve_owning_team consults product/board routing
before forcing MAIN_PM on a multi-cell map (product root stays Board,
product+assignee-is-board stays Board).
#83/#84 github_provisioning: create_repo is idempotent by GitHub name
— a 422 "name already exists" (orphaned repo from a rolled-back prior
approval) is fetched and reused instead of erroring; pitch re-approval
now reuses the orphaned repo end-to-end.
#196 kanban: flat main-PM board has a "coordination" column for
non-cell teams (MAIN_PM/Board) instead of dropping their cards.
#197 project update: an explicit null in the PATCH body now clears the
stored field, distinct from an absent field (leave unchanged).
ProjectService.update drops exclude_none so explicit-None applies; the
PATCH route uses ProjectUpdate.model_validate(data.model_dump(
exclude_unset=True)) to preserve the request's unset-tracking (the old
field-by-field construction marked every field set and defeated the
distinction — nulling NOT-NULL git_url).
TDD: prompter 47, github_provisioning+pitch 16, kanban+project 67,
project routes 37 — all green; ruff + mypy clean.
* [chore] logical-gaps sweep — Cluster M (mcp-servers)
#60 flow_server/do_server: the circuit-breaker substitution no longer
erases the fixable rejection — the original envelope (kind/message/
remediate) is nested as inner on a copy of the SDK's circuit_open
envelope (the SDK dict is not mutated in place). The agent still sees
WHY the verb failed, not just that the breaker tripped.
#61 flow_server/do_server: a 404 carrying a *descriptive* detail
(not FastAPI's bare default {"detail":"Not Found"}) is now a
real resource not_found, surfaced as not_found so the agent
re-fetches state — instead of a misleading "server-side wiring gap"
invalid_state. The bare default and unparseable 404s still synthesize
the wiring-gap envelope; a 404 with a real Envelope (error field)
is still surfaced as-is.
#161 flow_server/do_server: dict error.code classification now uses
an exact-code map (authoritative for the codes the handlers emit) with
a substring fallback for unknown codes. Fixes the real regression:
AUTHENTICATION_REQUIRED carries no AUTHORIZED/DENIED/PERMISSION
substring, so the old substring-only rule dropped it to invalid_state
instead of not_authorized — an auth storm attributed as a state storm.
The fallback also adds AUTH so future AUTH-prefixed codes classify.
#162 flow_server/do_server: _register_tools gains a
ROBOCO_ALLOW_FULL_TOOLSET env override (default-off) so a missing
manifest falls back to the full tool set instead of raising — a
dev/test escape hatch. Production fail-loud behaviour is unchanged.
#163 intake_server: propose_batch accepts name as well as
title (intake drafts in the wild have used both), normalizing a
name-only draft onto a copy as title (caller's dict never mutated),
and reports the dropped count + reason in the return instead of
silently vanishing malformed drafts. The empty-batch hint now names
name as an alternative.
TDD: 123 mcp_servers tests green (14 new + 2 updated); ruff + mypy clean.
* [chore] Cluster N — conventions/docs logical-gaps sweep
#33: _create_new_doc/_update_existing_doc now resolve via
_resolve_contained_path (the RAG-returned update path was not containment-
checked — an escaping source could write/overwrite outside the docs dir).
#34: _commit_doc_to_repo returns committed/skipped/failed instead of
swallowing all exceptions; surfaced on DocRef.commit_status, the write
response, and the docs MCP guidance so a failed repo commit is fail-loud.
#35: write_doc only updates the similar doc when its filename matches — a
different filename creates a new file instead of collapsing onto the
similar doc's path (the dedup-overwrite defect codified by the old tests).
#129: a custom rule scoped to a language the validator never reports (a
typo) is surfaced as a warn finding on .roboco/conventions.yml via the
runner's once-per-run validation; #32 (tsx->typescript dialect) stays
BY-DESIGN.
#130: _cache_put only swallows a UNIQUE violation (23505) as a concurrent
duplicate; a non-unique IntegrityError (FK/NOT NULL/check) is log-errored
and re-raised instead of being silently misattributed.
#132: health re-reads the live file status (a cached degraded row hid an
in-place repair at a stale head key); get_map skips cached degraded rows
and stops caching degraded so a repaired file re-derives. #134 BY-DESIGN.
#133: _DB_METHODS gains stream/stream_scalars (SQLAlchemy 2.0 streaming
constructs are data access too — a route calling them is not thin).
#199: regenerate_verb_tables._annot_str strips Annotated[...] metadata
(BeforeValidator) before rendering; regenerated verbs.md + per-role
prompts so the BeforeValidator(func=...) repr (with a memory address) no
longer leaks into agent-facing prompt text.
TDD: 206 conventions/docs tests green (incl. 5 new files / appended
cases); ruff + mypy clean.
* [chore] Cluster 16 — cross-cutting hygiene logical-gaps sweep
Disposition + fix the 10 cross-cutting-hygiene gaps, TDD. make quality green
(ruff, mypy 944 files, pytest, xenon, vulture, foundation-check, enum-parity).
FIX:
- #24 /ws/system now gated by _require_panel_token (matches every sibling
/ws/* stream); rejects a missing token in strict mode and a forged token
even in dev. (roboco/api/websocket.py)
- #25 two drifted _require_ceo implementations (orchestrator router vs release
handler) unified on a single require_ceo_role helper in deps — same 403,
same role set, accepts Role/AgentRole/"ceo". (roboco/api/deps.py,
routes/orchestrator.py, routes/release.py)
- #11 a spawn session for a delivery role (developer/qa/documenter) with no
task_id now logs an unattributed-usage warning via is_unattributed_delivery_spawn.
(roboco/runtime/orchestrator.py)
- #65 pricing returns a structured CostResult(cost_usd, unpriced, is_anthropic)
so an unpriced Anthropic model (real spend we'd undercount) is flagged
instead of silently $0; calculate_cost stays a thin float wrapper.
(roboco/billing/pricing.py, billing/__init__.py)
- #67 blocker-metrics "blocked since" reads the task.blocked audit transition
(indexed on target_id/event_type/timestamp), not updated_at — which
over-counted when a blocked task was touched for a non-blocking reason.
Falls back to updated_at/created_at only with no audit row.
(roboco/services/metrics.py)
- #94 grok refresh_if_stale uses double-checked locking (_refresh_lock +
_recheck_or_refresh) so two concurrent callers don't both POST the
single-use refresh grant and burn the credential. (grok_auth.py)
DOCS (fix the doc, behavior already correct/pinned by tests):
- #66 get_summary docstring corrected — it sums raw agent_spawn_sessions rows
(sub-day precise); daily_usage_rollups/get_today_summary can diverge for
"today" until the sweeper catches up. (roboco/services/usage.py)
- #68 DashboardStorage is a documented in-memory stub; added a test pinning
that auditor flags are lost on storage reset (persisting = a migration +
service refactor, out of scope as a half-implementation).
(tests/integration/test_dashboard_service.py)
BY-DESIGN (no code change, with file:line evidence):
- #28 dashboard reads are open to the authenticated operator (dashboard.py:36
documents this); mutating auditor routes already gate via
_require_auditor_or_ceo. Role-gating reads would break the panel (no
X-Agent-ID on dashboard reads) and CEO-token-gating the router would block
the Auditor (auditor token != CEO token). nginx is the prod boundary.
REFUTED (narrowing would reintroduce a documented hang):
- #93 the ~/.grok directory mount (vs a single auth.json file) is load-bearing
— a single-file bind mount pins the inode so the atomic tmp.replace refresh
doesn't propagate to running containers (they hang at grok's login prompt).
Already documented in grok.py:161 and locked by
test_intake_grok_mounts_subscription_auth_when_present.
Incidental gate-greening (mypy errors a stale .mypy_cache had hidden in
earlier-cluster test files; xenon refactors for the new B-threshold):
- tests/unit/test_regenerate_verb_tables.py: type the dynamic-module loader.
- tests/unit/services/test_prompter.py: annotate the draft dict as dict[str,Any].
- tests/unit/services/test_conventions_cache_put.py: _FakeOrig is a real Exception
(IntegrityError's orig arg requires BaseException).
- metrics._blocked_since_map extracted from get_blocker_metrics (complexity).
- intake_server._normalize_batch_drafts extracted from propose_batch (complexity).
* Docs update
* [bug] spawn: self-heal vanished clone + branch ref before worktree ensure (be-dev-1 fatal loop)
A vanished clone_root (disk loss / /data/workspaces wipe / manual cleanup)
fatal-looped the resume path: _ensure_worktree_before_spawn ran
`git -C <missing>` and released the claim, but the reaper-style release
preserves assigned_to + branch_name so the next dispatch is a RESUME
(create_branch never re-runs to re-clone) and the same missing clone failed
every ~30s.
- workspace.py: ensure_worktree_self_heal re-attaches a present worktree +
symlinks the shared .venv; on a missing local branch ref it fetches from
origin (create_branch pushes at claim time, so pushed work survives) and
re-creates the ref, falling back to -b origin/HEAD only when the branch
was never pushed. _fetch_branch_ref is the token-aware fetch helper.
- orchestrator.py: _ensure_worktree_before_spawn health-checks the clone
and re-clones via ensure_workspace BEFORE the worktree self-heal. Fatal
git-state (WorkspaceError) still releases the claim + aborts; transient
failures abort without releasing (a fresh claim wouldn't help and
re-cloning is destructive).
TDD: 21 new + 61 related worktree/git/cancel/cleanup tests green; ruff +
mypy clean.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
3497 lines
120 KiB
Python
3497 lines
120 KiB
Python
"""Tasks API route coverage — list/get/lifecycle endpoints."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from http import HTTPStatus
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import TYPE_CHECKING, Any, cast
|
|
from unittest.mock import AsyncMock, patch
|
|
from uuid import UUID, 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 GitError, TaskLifecycleError
|
|
from roboco.foundation.policy.lifecycle import STATUS_GRAPH
|
|
from roboco.foundation.policy.lifecycle import Status as LifecycleStatus
|
|
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.base import ServiceError as SvcError
|
|
from roboco.services.git import GitService
|
|
from roboco.services.notification_delivery import EscalationError
|
|
from roboco.services.permissions import PermissionService
|
|
from roboco.services.task import TaskService
|
|
|
|
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() -> AsyncIterator[AsyncSession]:
|
|
yield db_session
|
|
|
|
async def _override_agent() -> AgentContext:
|
|
return AgentContext(
|
|
agent_id=cast("UUID", 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: Any
|
|
) -> 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=kw.pop("task_type", TaskType.CODE),
|
|
nature=kw.pop("nature", TaskNature.TECHNICAL),
|
|
project_id=kw.pop("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_update_task_status_override_recovers_blocked(task_client: dict) -> None:
|
|
"""A privileged PATCH with ``status`` + ``force`` is applied as an audited
|
|
override, so an operator can recover a task wedged in ``blocked`` (which
|
|
``/complete`` refuses) instead of the status being silently dropped. The
|
|
``force`` flag acknowledges the bypass past the lifecycle gate (#13)."""
|
|
client = task_client["client"]
|
|
task = _seed_task(task_client, status=TaskStatus.BLOCKED)
|
|
await task_client["db"].flush()
|
|
response = await client.patch(
|
|
f"/api/tasks/{task.id}",
|
|
json={"status": "completed", "force": True},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
assert response.json()["status"] == "completed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_task_status_override_refused_without_force(
|
|
task_client: dict,
|
|
) -> None:
|
|
"""#13: pasting over the lifecycle gate into a terminal/final hatch state
|
|
(completed / awaiting_qa / awaiting_pm_review) without ``force`` is refused
|
|
with 400 — the bypass must be an explicit, acknowledged forced override."""
|
|
client = task_client["client"]
|
|
task = _seed_task(task_client, status=TaskStatus.IN_PROGRESS)
|
|
await task_client["db"].flush()
|
|
for hatch in ("completed", "awaiting_qa", "awaiting_pm_review"):
|
|
response = await client.patch(
|
|
f"/api/tasks/{task.id}",
|
|
json={"status": hatch},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.BAD_REQUEST, hatch
|
|
assert "force" in response.json()["detail"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_task_status_override_non_hatch_needs_no_force(
|
|
task_client: dict,
|
|
) -> None:
|
|
"""#13: a non-terminal recovery override (blocked -> pending) does NOT require
|
|
``force`` — only the terminal/final hatch states do."""
|
|
client = task_client["client"]
|
|
task = _seed_task(task_client, status=TaskStatus.BLOCKED)
|
|
await task_client["db"].flush()
|
|
response = await client.patch(
|
|
f"/api/tasks/{task.id}",
|
|
json={"status": "pending"},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
assert response.json()["status"] == "pending"
|
|
|
|
|
|
@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
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lifecycle_transitions_parity(task_client: dict) -> None:
|
|
"""GET /lifecycle-transitions returns the exact STATUS_GRAPH as strings.
|
|
|
|
Parity check: response keys and values must match
|
|
roboco.foundation.policy.lifecycle.STATUS_GRAPH.
|
|
"""
|
|
client = task_client["client"]
|
|
response = await client.get("/api/tasks/lifecycle-transitions", headers=_HDR)
|
|
assert response.status_code == HTTPStatus.OK
|
|
body = response.json()
|
|
|
|
# Keys must be exactly the set of status string values
|
|
expected_keys = {s.value for s in STATUS_GRAPH}
|
|
assert set(body.keys()) == expected_keys, (
|
|
f"Key mismatch: extra={set(body.keys()) - expected_keys}, "
|
|
f"missing={expected_keys - set(body.keys())}"
|
|
)
|
|
|
|
# Values must match STATUS_GRAPH (as sorted lists of strings)
|
|
for status_str, next_statuses in body.items():
|
|
src = LifecycleStatus(status_str)
|
|
expected_targets = sorted(t.value for t in STATUS_GRAPH[src])
|
|
assert sorted(next_statuses) == expected_targets, (
|
|
f"Targets for {status_str!r} mismatch: "
|
|
f"got {sorted(next_statuses)!r}, want {expected_targets!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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() -> AsyncIterator[AsyncSession]:
|
|
yield db_session
|
|
|
|
async def _override_agent() -> AgentContext:
|
|
return AgentContext(
|
|
agent_id=cast("UUID", 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: Any) -> 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,
|
|
orchestration_markers={"original_developer": str(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,
|
|
orchestration_markers={"original_developer": str(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() -> AsyncIterator[AsyncSession]:
|
|
yield db_session
|
|
|
|
async def _override_agent() -> AgentContext:
|
|
return AgentContext(
|
|
agent_id=cast("UUID", 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: Any) -> 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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TaskUpdate schema: nature / task_type / project_id (AC: schema fix)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_patch_nature_persists(task_client: dict) -> None:
|
|
"""PATCH with nature=non_technical persists; GET returns updated value."""
|
|
task = _seed_task(task_client, nature=TaskNature.TECHNICAL)
|
|
await task_client["db"].flush()
|
|
response = await task_client["client"].patch(
|
|
f"/api/tasks/{task.id}",
|
|
json={"nature": "non_technical"},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
body = response.json()
|
|
assert body["nature"] == "non_technical"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_patch_task_type_persists(task_client: dict) -> None:
|
|
"""PATCH with task_type=research persists; GET returns updated value."""
|
|
task = _seed_task(task_client, task_type=TaskType.CODE)
|
|
await task_client["db"].flush()
|
|
response = await task_client["client"].patch(
|
|
f"/api/tasks/{task.id}",
|
|
json={"task_type": "research"},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
body = response.json()
|
|
assert body["task_type"] == "research"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_patch_project_id_persists(task_client: dict) -> None:
|
|
"""PATCH with project_id=<valid-uuid> persists; GET returns updated value."""
|
|
task = _seed_task(task_client)
|
|
# Create a second project to switch to
|
|
second_project = ProjectTable(
|
|
id=uuid4(),
|
|
name="Proj2",
|
|
slug=f"proj2-{uuid4().hex[:6]}",
|
|
git_url="https://example.com/proj2.git",
|
|
assigned_cell=Team.BACKEND,
|
|
created_by=task_client["agent"].id,
|
|
)
|
|
task_client["db"].add(second_project)
|
|
await task_client["db"].flush()
|
|
|
|
response = await task_client["client"].patch(
|
|
f"/api/tasks/{task.id}",
|
|
json={"project_id": str(second_project.id)},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
body = response.json()
|
|
assert body["project_id"] == str(second_project.id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_patch_title_only_changes_title(task_client: dict) -> None:
|
|
"""PATCH with only title does not mutate nature/task_type/status/team."""
|
|
task = _seed_task(
|
|
task_client,
|
|
title="original title",
|
|
nature=TaskNature.TECHNICAL,
|
|
task_type=TaskType.CODE,
|
|
team=Team.BACKEND,
|
|
)
|
|
await task_client["db"].flush()
|
|
|
|
response = await task_client["client"].patch(
|
|
f"/api/tasks/{task.id}",
|
|
json={"title": "updated title"},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
body = response.json()
|
|
assert body["title"] == "updated title"
|
|
# Other fields unchanged
|
|
assert body["nature"] == "technical"
|
|
assert body["task_type"] == "code"
|
|
assert body["team"] == "backend"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# assigned_to: slug resolution and null guard (AC: slug-resolution fix)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_patch_assigned_to_slug_resolves_to_uuid(task_client: dict) -> None:
|
|
"""PATCH assigned_to with agent slug resolves to agent UUID."""
|
|
dev = await _seed_agent(task_client)
|
|
task = _seed_task(task_client)
|
|
await task_client["db"].flush()
|
|
|
|
response = await task_client["client"].patch(
|
|
f"/api/tasks/{task.id}",
|
|
json={"assigned_to": dev.slug},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
body = response.json()
|
|
assert body["assigned_to"] == str(dev.id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_patch_assigned_to_null_unassigns(task_client: dict) -> None:
|
|
"""PATCH assigned_to: null sets assigned_to to null (unassign)."""
|
|
dev = await _seed_agent(task_client)
|
|
task = _seed_task(task_client, assigned_to=dev.id)
|
|
await task_client["db"].flush()
|
|
|
|
response = await task_client["client"].patch(
|
|
f"/api/tasks/{task.id}",
|
|
json={"assigned_to": None},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
body = response.json()
|
|
assert body["assigned_to"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_patch_assigned_to_unknown_slug_returns_422(task_client: dict) -> None:
|
|
"""PATCH assigned_to with unknown slug returns 422 ASSIGNEE_NOT_FOUND."""
|
|
task = _seed_task(task_client)
|
|
await task_client["db"].flush()
|
|
|
|
response = await task_client["client"].patch(
|
|
f"/api/tasks/{task.id}",
|
|
json={"assigned_to": "totally-nonexistent-slug"},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
|
detail = response.json()["detail"]
|
|
assert isinstance(detail, dict)
|
|
assert detail["error"]["code"] == "ASSIGNEE_NOT_FOUND"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /tasks/{id}/ceo-approve — eligibility check (AC: ceo-approve fix)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ceo_approve_get_no_pr_returns_400(ceo_client: dict) -> None:
|
|
"""GET /ceo-approve with no pr_number on the task → 400 NO_PR."""
|
|
task = _seed_task_ceo(ceo_client, pr_number=None)
|
|
await ceo_client["db"].flush()
|
|
response = await ceo_client["client"].get(
|
|
f"/api/tasks/{task.id}/ceo-approve",
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.BAD_REQUEST
|
|
assert "NO_PR" in response.json()["detail"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ceo_approve_get_with_pr_returns_200(ceo_client: dict) -> None:
|
|
"""GET /ceo-approve with pr_number set → 200 with task."""
|
|
task = _seed_task_ceo(ceo_client, pr_number=42)
|
|
await ceo_client["db"].flush()
|
|
response = await ceo_client["client"].get(
|
|
f"/api/tasks/{task.id}/ceo-approve",
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
body = response.json()
|
|
_expected_pr = 42
|
|
assert body["pr_number"] == _expected_pr
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ceo_approve_get_not_ceo_returns_403(task_client: dict) -> None:
|
|
"""GET /ceo-approve by non-CEO → 403 Forbidden."""
|
|
response = await task_client["client"].get(
|
|
f"/api/tasks/{uuid4()}/ceo-approve",
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.FORBIDDEN
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ceo_approve_get_task_not_found(ceo_client: dict) -> None:
|
|
"""GET /ceo-approve for unknown task → 404."""
|
|
response = await ceo_client["client"].get(
|
|
f"/api/tasks/{uuid4()}/ceo-approve",
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.NOT_FOUND
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tasks/{id}/approve-and-merge (AC: approve-and-merge fix)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_and_merge_no_pr_returns_400(ceo_client: dict) -> None:
|
|
"""POST /approve-and-merge with no pr_number → 400 NO_PR."""
|
|
task = _seed_task_ceo(ceo_client, pr_number=None)
|
|
await ceo_client["db"].flush()
|
|
response = await ceo_client["client"].post(
|
|
f"/api/tasks/{task.id}/approve-and-merge",
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.BAD_REQUEST
|
|
assert "NO_PR" in response.json()["detail"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_and_merge_not_ceo_returns_403(task_client: dict) -> None:
|
|
"""POST /approve-and-merge by non-CEO → 403 Forbidden."""
|
|
response = await task_client["client"].post(
|
|
f"/api/tasks/{uuid4()}/approve-and-merge",
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.FORBIDDEN
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_and_merge_task_not_found(ceo_client: dict) -> None:
|
|
"""POST /approve-and-merge for unknown task → 404."""
|
|
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
|
|
response = await ceo_client["client"].post(
|
|
f"/api/tasks/{uuid4()}/approve-and-merge",
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.NOT_FOUND
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_and_merge_success(ceo_client: dict) -> None:
|
|
"""POST /approve-and-merge with PR + fully mocked services → 200 task."""
|
|
task = _seed_task_ceo(ceo_client, pr_number=99)
|
|
await ceo_client["db"].flush()
|
|
|
|
# The handler does lazy imports of get_project_service and get_git_service.
|
|
# Patch them at their source modules so the lazy import picks up the mock.
|
|
with (
|
|
patch("roboco.api.routes.tasks.get_task_service") as mock_task_factory,
|
|
patch("roboco.services.project.get_project_service") as mock_proj_factory,
|
|
patch("roboco.services.git.get_git_service") as mock_git_factory,
|
|
):
|
|
task_instance = AsyncMock()
|
|
# service.get is called twice: once for the initial check, once to re-fetch.
|
|
task_instance.get = AsyncMock(side_effect=[task, task])
|
|
mock_task_factory.return_value = task_instance
|
|
|
|
proj_instance = AsyncMock()
|
|
proj_instance.get = AsyncMock(return_value=ceo_client["project"])
|
|
mock_proj_factory.return_value = proj_instance
|
|
|
|
git_instance = AsyncMock()
|
|
git_instance.merge_pr_for_task = AsyncMock(return_value=("main", "abc1234"))
|
|
mock_git_factory.return_value = git_instance
|
|
|
|
response = await ceo_client["client"].post(
|
|
f"/api/tasks/{task.id}/approve-and-merge",
|
|
headers=_HDR,
|
|
)
|
|
|
|
assert response.status_code == HTTPStatus.OK
|
|
body = response.json()
|
|
_expected_pr = 99
|
|
assert body["pr_number"] == _expected_pr
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_and_merge_merge_failure_returns_structured_error(
|
|
ceo_client: dict,
|
|
) -> None:
|
|
"""POST /approve-and-merge where git merge fails → 400 with descriptive message.
|
|
|
|
The error must NOT be an unhandled exception (500 with traceback); it must
|
|
be a structured HTTP error (400 or 500) with a human-readable message.
|
|
"""
|
|
task = _seed_task_ceo(ceo_client, pr_number=55)
|
|
await ceo_client["db"].flush()
|
|
|
|
with (
|
|
patch("roboco.api.routes.tasks.get_task_service") as mock_task_factory,
|
|
patch("roboco.services.project.get_project_service") as mock_proj_factory,
|
|
patch("roboco.services.git.get_git_service") as mock_git_factory,
|
|
):
|
|
task_instance = AsyncMock()
|
|
task_instance.get = AsyncMock(return_value=task)
|
|
mock_task_factory.return_value = task_instance
|
|
|
|
proj_instance = AsyncMock()
|
|
proj_instance.get = AsyncMock(return_value=ceo_client["project"])
|
|
mock_proj_factory.return_value = proj_instance
|
|
|
|
git_instance = AsyncMock()
|
|
git_instance.merge_pr_for_task = AsyncMock(
|
|
side_effect=SvcError("GitHub refused the merge: 409 Conflict")
|
|
)
|
|
mock_git_factory.return_value = git_instance
|
|
|
|
response = await ceo_client["client"].post(
|
|
f"/api/tasks/{task.id}/approve-and-merge",
|
|
headers=_HDR,
|
|
)
|
|
|
|
# Must be a structured error, not an unhandled 500
|
|
_ok_statuses = (HTTPStatus.BAD_REQUEST, HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
assert response.status_code in _ok_statuses
|
|
body = response.json()
|
|
# The detail must be a string (not a raw traceback or empty)
|
|
assert isinstance(body.get("detail"), str)
|
|
assert len(body["detail"]) > 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_and_merge_git_error_returns_structured_error(
|
|
ceo_client: dict,
|
|
) -> None:
|
|
"""POST /approve-and-merge: GitError → 400 with descriptive message."""
|
|
task = _seed_task_ceo(ceo_client, pr_number=66)
|
|
await ceo_client["db"].flush()
|
|
|
|
with (
|
|
patch("roboco.api.routes.tasks.get_task_service") as mock_task_factory,
|
|
patch("roboco.services.project.get_project_service") as mock_proj_factory,
|
|
patch("roboco.services.git.get_git_service") as mock_git_factory,
|
|
):
|
|
task_instance = AsyncMock()
|
|
task_instance.get = AsyncMock(return_value=task)
|
|
mock_task_factory.return_value = task_instance
|
|
|
|
proj_instance = AsyncMock()
|
|
proj_instance.get = AsyncMock(return_value=ceo_client["project"])
|
|
mock_proj_factory.return_value = proj_instance
|
|
|
|
git_instance = AsyncMock()
|
|
git_instance.merge_pr_for_task = AsyncMock(
|
|
side_effect=GitError(
|
|
"GitHub API refused PR merge (422): branch protected",
|
|
{"pr": 66},
|
|
)
|
|
)
|
|
mock_git_factory.return_value = git_instance
|
|
|
|
response = await ceo_client["client"].post(
|
|
f"/api/tasks/{task.id}/approve-and-merge",
|
|
headers=_HDR,
|
|
)
|
|
|
|
# Must be a structured error — NOT an unhandled traceback
|
|
_ok_statuses = (HTTPStatus.BAD_REQUEST, HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
assert response.status_code in _ok_statuses
|
|
body = response.json()
|
|
assert isinstance(body.get("detail"), str)
|
|
assert len(body["detail"]) > 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tasks/{id}/complete — PM merge path (AC: pm-merge-path fix)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cell_pm_complete_merges_then_completes(task_client: dict) -> None:
|
|
"""POST /complete on an awaiting_pm_review task with a pr_number triggers
|
|
merge_pr_for_task before the task is marked completed.
|
|
|
|
The git service and project service are mocked so no real GitHub call is
|
|
made; the test verifies the call ordering and the final 200 response.
|
|
"""
|
|
task = _seed_task(task_client, status=TaskStatus.AWAITING_PM_REVIEW, pr_number=77)
|
|
await task_client["db"].flush()
|
|
|
|
with (
|
|
patch("roboco.api.routes.tasks.get_task_service") as mock_task_factory,
|
|
patch("roboco.services.project.get_project_service") as mock_proj_factory,
|
|
patch("roboco.services.git.get_git_service") as mock_git_factory,
|
|
):
|
|
# Task service: get() returns the seeded task; complete_task_for_agent
|
|
# simulates the service marking it completed and returning it.
|
|
task_instance = AsyncMock()
|
|
task_instance.get = AsyncMock(return_value=task)
|
|
completed_task = task # same object; status already set on the mock
|
|
task_instance.complete_task_for_agent = AsyncMock(return_value=completed_task)
|
|
mock_task_factory.return_value = task_instance
|
|
|
|
proj_instance = AsyncMock()
|
|
proj_instance.get = AsyncMock(return_value=task_client["project"])
|
|
mock_proj_factory.return_value = proj_instance
|
|
|
|
git_instance = AsyncMock()
|
|
git_instance.merge_pr_for_task = AsyncMock(return_value=("main", "abc1234"))
|
|
mock_git_factory.return_value = git_instance
|
|
|
|
response = await task_client["client"].post(
|
|
f"/api/tasks/{task.id}/complete",
|
|
json={"justification": "All criteria met; QA and docs signed off."},
|
|
headers=_HDR,
|
|
)
|
|
|
|
assert response.status_code == HTTPStatus.OK
|
|
# merge_pr_for_task must have been called exactly once
|
|
git_instance.merge_pr_for_task.assert_called_once()
|
|
call_args = git_instance.merge_pr_for_task.call_args
|
|
# The GitMergePRRequest passed to merge_pr_for_task must carry the right pr_number
|
|
merge_request = call_args.args[2] # positional: agent_id, agent_role, request
|
|
_expected_pr = 77
|
|
assert merge_request.pr_number == _expected_pr
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tasks/{id}/complete — PM merge path end-to-end (AC: double-completion fix)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pm_merge_auto_completes_without_double_completion(
|
|
task_client: dict,
|
|
) -> None:
|
|
"""POST /complete on awaiting_pm_review calls real merge_pr_for_task and
|
|
real complete_task_for_agent is NOT called — the task is auto-completed
|
|
by _auto_complete_on_merge inside the git service, and the route detects
|
|
the completed state from the re-fetch and returns 200 directly.
|
|
|
|
Only the git-workspace / GitHub-API layer is mocked (GitService.get_workspace
|
|
and GitService.merge_pull_request). merge_pr_for_task and complete_task_for_agent
|
|
run for real so this exercises the fix for the double-completion 500.
|
|
"""
|
|
# Seed a task in awaiting_pm_review with a PR. No work_session_id so that
|
|
# _assert_pr_merged_for_complete returns True without querying WorkSession.
|
|
task = _seed_task(
|
|
task_client,
|
|
status=TaskStatus.AWAITING_PM_REVIEW,
|
|
pr_number=99,
|
|
# work_session_id intentionally omitted (defaults to None)
|
|
)
|
|
await task_client["db"].flush()
|
|
|
|
# Spy: we want to assert complete_task_for_agent is never reached.
|
|
# If it were called on an already-completed task it would raise ValidationError
|
|
# and the route would return a non-200 — but we assert explicitly to be clear.
|
|
complete_for_agent_spy = AsyncMock(
|
|
wraps=TaskService.complete_task_for_agent,
|
|
name="complete_task_for_agent_spy",
|
|
)
|
|
|
|
_mock_workspace = Path("/tmp/mock_workspace")
|
|
|
|
with (
|
|
patch.object(
|
|
GitService,
|
|
"get_workspace",
|
|
new=AsyncMock(return_value=_mock_workspace),
|
|
),
|
|
patch.object(
|
|
GitService,
|
|
"merge_pull_request",
|
|
new=AsyncMock(return_value=("main", "dead1234")),
|
|
),
|
|
patch.object(
|
|
TaskService,
|
|
"complete_task_for_agent",
|
|
new=complete_for_agent_spy,
|
|
),
|
|
):
|
|
response = await task_client["client"].post(
|
|
f"/api/tasks/{task.id}/complete",
|
|
json={"justification": "All criteria met and QA signed off."},
|
|
headers=_HDR,
|
|
)
|
|
|
|
# The route must return 200; if the double-completion bug were present the
|
|
# second call to complete() would fail (task already completed) → 422/400.
|
|
assert response.status_code == HTTPStatus.OK, response.text
|
|
|
|
body = response.json()
|
|
assert body["status"] == "completed", f"expected completed, got {body['status']}"
|
|
|
|
# complete_task_for_agent must NOT have been called: the task was already
|
|
# auto-completed by _auto_complete_on_merge inside merge_pr_for_task.
|
|
complete_for_agent_spy.assert_not_called()
|