Files
roboco/tests/unit/services/test_x_post_service.py
T
401f8a2cc9 feat(board): Board Programs — the complete twelve-program catalog (Phases 1-3) (#699)
* feat(board): Pest Control — the first project-scoped Board Program

The Product Owner hunts latent defects (what the org records but nobody
reads): a weekly cycle — accelerated off-schedule when the trailing-7-day
rework rate crosses pest_rework_threshold, with the cheap dedup/scope gates
evaluated before the metrics queries — opens one held exploration task
against the least-recently-explored opted-in project (deterministic
round-robin; opted_in_projects gains a stable ORDER BY), with server-
assembled evidence in the spawn prompt (rework hotspots, recurring-findings
and waived-minor ledger aggregates, all capped) plus prior-cycle LEARN
context. The PO calls the new PO-only propose_bug_hunt verb once: ≤5 items,
evidence required per item, targets validated against pest_control
participation. CEO decides per item — approve materializes a BACKLOG task
(source pest_control, never auto-starts), reject records the reason; both
feed the LEARN ledger by exploration task id; all-terminal completes the
cycle. Telegram queue pushes carry working Approve/Reject handlers
mirroring the roadmap kind. Doctrine: board.md Pest Control section +
product-owner verb entry + regenerated verb tables.

* feat(panel): Pest Control review queue

Command Center gains the pest review queue (per-item approve/reject with
reason, mirroring the roadmap queue); the Programs card and the project
settings participates-in checkboxes pick the new program up registry-driven
— the settings section renders for the first time now that a project-scoped
program exists.

* feat(board): Periscope — HoM market-research brief program

Weekly org-scoped cycle: a solo HoM spawn researches the market (web
research with mandatory source URLs — uncited findings are rejected) and
files one structured brief via the new HoM-only propose_market_brief verb:
headline, cited findings, threats/opportunities, positioning note, all
soup-checked and screened through the injection guard at persist time
(web-derived text later reaches prompts; flags recorded, content never
dropped). A brief is a report, not a proposal: the verb completes the
exploration in the same call (the x_feature asymmetry), the cycle ledger
auto-closes, and the CEO gets a best-effort notification with no
approve/reject surface (periscope deliberately never joins Telegram's
action kinds). The latest brief is injected into the roadmap exploration
prompt — Periscope feeds Printer, the first cross-role program input.

* feat(panel): Market Briefs tab (read-only)

Business page gains a Market Briefs tab listing Periscope briefs —
headline, cited findings, threats/opportunities — read-only by design; a
report has nothing to approve.

* feat(board): Coroner — event-triggered Auditor postmortems

The first EVENT program: no cron — three best-effort hooks open an autopsy
when a task bounces to its 3rd revision (the audit chokepoint), is
cancelled after work started, or is budget-blocked; all gated on arming +
one-open-autopsy dedup, none can fail the underlying transition. A solo
Auditor spawn reads the incident (server-assembled findings + transition
context) and files one propose_postmortem: incident summary, root cause,
failed stage (validated against the real status vocabulary), and ONE
process change — a playbook-kind change drafts via PlaybookService
directly into the normal pending-curation queue; the briefed draft_playbook
manifest grant was deliberately NOT added, preserving the existing
'auditor curates but never drafts' invariant test. Complete-at-propose
(report asymmetry), cycle ledger auto-closes, CEO notified link-only.
Integrated as a union with Periscope across the shared program surfaces.

* feat(panel): Coroner postmortems card

Read-only postmortems list under Business → Programs — incident, root
cause, failed stage, process change; nothing to approve, the process-change
artifact (a draft playbook) rides the existing curation queue.

* feat(board): Sentinel — Auditor drift-watch quality reports

Weekly org-scoped cycle: a solo Auditor spawn receives a server-assembled
drift context (waived-findings trend, open findings by severity,
conventions-violation hotspots, top spend — all capped, pure ORM) and files
one propose_quality_report: headline, 1-7 area-validated items with
evidence and suggested actions, overall assessment. Report semantics —
complete-at-propose, cycle auto-closes, CEO notified display-only (never on
Telegram's approve/reject surface); items are structured so a later
convert-to-task control is cheap. Integration adopts Sentinel's module-
level dict-dispatch for board-program routing (xenon-driven), folding all
prior programs in; app router mounting extracted to a helper for the same
budget.

* feat(panel): Quality Reports tab (read-only)

Business page gains the Sentinel quality-reports tab — headline, per-area
observations with evidence and suggested actions; read-only, a report has
nothing to approve.

* feat(board): Spackle — gap-fill audit program

Biweekly project-scoped PO cycle over the half-shipped surface area: API
routes without panel surfaces (and vice versa), armed flags without docs,
docs promises the code doesn't keep, dead-end tabs — the inventory diffing
is the PO's own read-tool work, ordered by the spawn prompt with file:line
citations required; the server injects only prior-cycle LEARN and the
rotation target. Rotation is now a shared module-level helper
(pick_rotation_target, parameterized by source) both project-scoped
engines use — pest_control delegates to it, behavior-identical, with a
cross-pollution test proving the two programs' rotations stay independent.
propose_gap_fill mirrors the bug-hunt verb (≤5 items, two-sided evidence
required, participation gate); per-item CEO decide materializes BACKLOG
source=spackle tasks; full Telegram kind incl. approve/reject handlers.
All seven program routers now mount from one helper.

* feat(panel): Spackle gap-fill review queue

Command Center gains the gap-fill queue mirroring the pest-control one —
per-item approve/reject with the two-sided gap evidence rendered.

* feat(board): Scales — monthly portfolio rebalance

Org-scoped PO cycle over the stale backlog: the spawn receives a capped
stale-task snapshot (BACKLOG/PENDING unclaimed >30 days) plus the charter
and prior-cycle LEARN, and files one propose_rebalance — 1-7 items, each a
resolvable task_ref with action reprioritize (validated new priority) or
cancel, rationale required. Per-item CEO decide: approve EXECUTES the
action (audited priority update, or the normal cancel path) — the first
program whose materializer mutates existing tasks instead of creating
them; reject records the reason; LEARN by exploration task id;
all-terminal completes the cycle. Full Telegram decide-kind wiring.
Integrated as the eight-program union (registry, dict dispatch, routers
helper, teardown enumerations).

* feat(panel): Scales rebalance review queue

Command Center gains the rebalance queue — per-item approve/reject with
the action, target task, and rationale rendered.

* feat(board): Mirror — quarterly positioning audit

Project-scoped HoM cycle over messaging surfaces: README claims vs shipped
reality, docs-site promises vs code, charter alignment — the audit is the
HoM's own read-tool work with citations required; the server injects the
charter, prior-cycle LEARN, and the shared rotation target. propose_
messaging_fixes mirrors the gap-fill verb (≤5 items, drift evidence naming
claim + contradicting reality, participation gate); per-item CEO decide
materializes BACKLOG source=mirror documentation tasks; full Telegram
decide-kind wiring. Nine-program union across the shared surfaces.

* feat(panel): Mirror messaging-fixes review queue

* feat(board): Megaphone — HoM standing editorial calendar

Cron cycle (3 days, org-scoped, gated on X credentials — drafting content
nobody can post is pointless): the HoM receives a shipped-this-week digest
plus Unreleased changelog bullets and files one propose_editorial_post
(angle-validated, ≤280, brand voice) that materializes a held x_editorial
draft through the SAME X-queue origination chokepoint release posts use —
zero new approval surface, notifications and CEO decide for free.
Complete-at-propose; cycle auto-closes. Ten-program union.

* feat(panel): x_editorial source labels in the X queue surfaces

* feat(board): Librarian — proactive playbook mining

Biweekly org-scoped Auditor cycle: mines recurring non-private learning
journals (≥2-count grouping with a recency fallback) against the existing
playbook-title inventory and files one propose_playbook_drafts — 1-3
drafts, each with the repeated-pattern evidence that justifies it,
duplicate titles rejected in-batch and against the live store. Drafts are
created via PlaybookService directly (the Coroner precedent — the
'auditor curates but never drafts' do-verb invariant stays intact and
tested) and land in the normal pending-curation queue the Auditor's own
triage already surfaces; no new panel surface. Complete-at-propose;
display-only CEO notification. Eleven-program union.

* feat(board): War Room — release campaign planning

EVENT program with a REAL originator (unlike coroner's stub): a release
publish hooks a campaign brief beside the release-post seam, and the CEO's
run-now originates on demand — the cron loop never fires it. The HoM
designs a 2-6 post arc (teaser → launch → follow-up → spotlight; 280-cap,
future strictly-ascending publish_after, stage vocabulary) and one
propose_campaign call materializes each post as a held x_campaign draft
through the X-queue chokepoint. V1 is manual-cadence by design: publish_
after renders as queue guidance and the CEO approves each post at its
moment — nothing auto-posts, ever; the auto-schedule upgrade is a
documented ceiling. Twelve-program union: full registry complete.

* feat(panel): x_campaign labels + publish-after guidance in the X queue

* feat(board): Barfly — adjacent-conversation replies

Cron cycle (2 days, org-scoped, X-credentials gated): the engine searches
X for conversations where RoboCo is relevant but unmentioned (new OAuth-
signed search_recent on the client; queries + candidate cap configurable),
screens every fetched tweet through the injection guard (stored unclamped
— a clamp was truncating the candidate under the envelope, caught by the
dev's own tests), dedupes via the existing x_seen_mentions ledger (no
migration; also prevents double-drafting against the mentions poll), and
opens one held HoM exploration carrying the screened candidates. propose_
conversation_replies enforces candidate-id-only replies (≤5, 280-cap);
each materializes a held x_barfly draft through the X-queue chokepoint,
threaded via a new in_reply_to seam on post_tweet that only x_barfly
drafts use. The X redraft machinery is now dict-dispatch over per-source
extractors with reply-ref carry for x_barfly. Thirteen-program registry.
War Room's test fakes gained the new abstract search_recent stub.

* feat(board): Dogfood — the PO walks the product

The fourteenth and final registry entry, completing the catalog. EVENT
program (release-publish hook beside the war-room hook + CEO run-now, both
through the same real originator; the cron loop never fires it), project-
scoped with shared rotation. The permission surface is the careful part:
the PO's dogfood spawn — and ONLY that spawn — gets the Playwright MCP
mounted, via a task-scoped fail-closed probe mirroring the video-authoring
precedent (a PO spawned for roadmap/pest/scales never sees browser tools;
tested both ways); the PM agent image bakes chromium unconditionally like
the ux image, the mount stays task-gated in code. The walk targets the
rotation target's live surfaces (panel_base_url only when the target is
the org's own project, honest degradation otherwise); propose_friction_
fixes files ≤5 walked-path-evidenced items; per-item CEO decide
materializes BACKLOG source=dogfood tasks; full Telegram decide kind.
Also: megaphone/librarian/war_room arming keys restored to the settings
validator — their panel toggles would have been rejected (dropped in
earlier unions; the same silent-arming class the drill killed once
already).

* feat(panel): Dogfood friction review queue

* chore(board): final whole-branch sweep fixes

The night's closing adversarial pass over the integrated fourteen-program
registry found ONE functional defect — the war-room test fakes' post_tweet
predated Barfly's in_reply_to_tweet_id kwarg (LSP violation, the only red
in an otherwise fully green gate) — plus doc/test drift, all fixed: the
source-parity test completes to fourteen (spackle/mirror were silently
absent while its neighboring comment claimed full coverage), the PO
identity doc gains its missing Dogfood verb, the auditor quick-list gains
propose_postmortem, three stale comments corrected (rotation docstring,
panel registry header, X source enumerations), the dogfood release-hook
gains the exception-swallow test its four sibling hooks already had, and
the CHANGELOG's Unreleased section documents the whole Board Programs
train. Full make quality: exit 0, all gates green.

* docs: full documentation sweep for the Board Programs train

CLAUDE.md's roadmap-engine entry superseded by the Board Program registry
entry (all fourteen programs, arming, scoping, LEARN, guardrails) with the
role verb tables and playwright row refreshed; docs/rag gains the agent-
facing architecture doc plus full propose_* call-shape sections in the
three board role docs, and corrects the strategy-engine section to shipped
reality (only idle→roadmap is wired); docs/map covers the registry + all
twelve engines with flags, gotchas, and drift notes. The 0.27.0 reference
inventory confirmed only the release-executor's canonical set carries the
version — left for the 0.28.0 cut.

* feat(board): human titles + descriptions on every program surface

Raw registry keys rendered as bare panel labels — an operator reading
x_feature had no idea what enabling or running it does. The registry
dataclass gains title/description (test-enforced non-empty for every
entry, unique titles), the API passes them through, and every surface
renders title-with-description-tooltip instead of the key: the Programs
card (label, toggle hint, run-now toast), and the project settings
participates-in/excluded-from checkboxes.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-25 17:13:32 +02:00

1459 lines
55 KiB
Python

"""XPostService coverage: approve posts (idempotent), reject cancels.
Mirrors the release-proposal service tests. The Redis lock helpers are
patched (no live Redis in tests, matching the project's ``_no_live_redis``
fixture) so approve exercises the real post + status-transition path.
"""
from __future__ import annotations
import asyncio
import contextlib
from contextlib import contextmanager
from datetime import UTC, datetime
from typing import TYPE_CHECKING, cast
from unittest.mock import AsyncMock, patch
from uuid import UUID, uuid4
import pytest
from roboco.config import settings as cfg
from roboco.db.tables import AgentTable, BoardProgramCycleTable, ProjectTable, TaskTable
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.models.base import (
AgentRole,
AgentStatus,
Complexity,
Team,
)
from roboco.models.base import TaskNature as TN
from roboco.models.base import TaskStatus as TS
from roboco.models.base import TaskType as TT
from roboco.services import board_programs as bp_module
from roboco.services import x_engine as x_engine_module
from roboco.services.company_goals import get_company_goals_service
from roboco.services.task import (
X_BARFLY_SOURCE,
X_FEATURE_SOURCE,
X_POST_SOURCE,
X_REPLY_SOURCE,
TaskService,
)
from roboco.services.x_client import XClient, XMention, XPostResult
from roboco.services.x_post_service import (
TaskAlreadyCompletedError,
XPostBodyTooLongError,
XPostExecuteResult,
XPostService,
get_x_post_service,
)
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
if TYPE_CHECKING:
from collections.abc import Iterator
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
SECRETARY_UUID = _foundation.AGENTS["secretary-1"].uuid
ONE = 1
TWO = 2
@contextmanager
def _lock_free() -> Iterator[None]:
"""Patch XPostService's lock helpers so approve/reject exercise the real
post/cancel path without touching the (test-blocked) Redis."""
with (
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
yield
class _StubClient(XClient):
def __init__(self, *, posted: bool = True, tweet_id: str = "999") -> None:
self._posted = posted
self._tweet_id = tweet_id
self.calls: list[str] = []
self.reply_targets: list[str | None] = []
@property
def configured(self) -> bool:
return True
async def post_tweet(
self, text: str, *, in_reply_to_tweet_id: str | None = None
) -> XPostResult:
self.calls.append(text)
self.reply_targets.append(in_reply_to_tweet_id)
if not self._posted:
return XPostResult(posted=False, tweet_id=None, detail="rejected by X")
return XPostResult(posted=True, tweet_id=self._tweet_id, detail="posted")
async def fetch_mentions(
self, since_id: str | None, max_results: int
) -> list[XMention]:
_ = (since_id, max_results)
return []
async def search_recent(self, query: str, max_results: int) -> list[XMention]:
_ = (query, max_results)
return []
async def _seed_draft(
session: AsyncSession, *, source: str = X_POST_SOURCE, body: str = "Draft body"
) -> TaskTable:
for uuid, slug, role in (
(SYSTEM_UUID, "system", AgentRole.SYSTEM),
(SECRETARY_UUID, "secretary-1", AgentRole.SECRETARY),
):
if await session.get(AgentTable, uuid) is None:
session.add(
AgentTable(
id=uuid,
name=slug,
slug=slug,
role=role,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
project = ProjectTable(
id=uuid4(),
name="RoboCo",
slug=f"roboco-{uuid4().hex[:6]}",
git_url="https://example.com/roboco.git",
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
)
session.add(project)
await session.flush()
task = TaskTable(
id=uuid4(),
title="X draft",
description=body,
acceptance_criteria=["CEO approves or rejects"],
status=TS.PENDING,
priority=2,
task_type=TT.ADMINISTRATIVE,
nature=TN.NON_TECHNICAL,
estimated_complexity=Complexity.LOW,
project_id=project.id,
created_by=SYSTEM_UUID,
assigned_to=SECRETARY_UUID,
team=Team.MAIN_PM,
source=source,
confirmed_by_human=False,
)
session.add(task)
await session.flush()
markers.set_x_draft_body(task, body)
await session.flush()
return task
_FEATURE_SLUG = "org-memory"
_FEATURE_TITLE = "Organizational Memory Loop"
async def _seed_feature_draft(
session: AsyncSession,
*,
wants_video: bool = True,
video_script: str = "",
body: str = "Draft body",
) -> TaskTable:
"""An X_FEATURE_SOURCE draft carrying the x_feature_ref marker
``propose_feature_spotlight`` stamps (Task 4, 2026-07-09 pipeline fixes):
slug/title always, plus wants_video/video_script when a companion video
was requested at authoring time."""
task = await _seed_draft(session, source=X_FEATURE_SOURCE, body=body)
markers.set_x_feature_ref(
task,
{
"slug": _FEATURE_SLUG,
"title": _FEATURE_TITLE,
"wants_video": wants_video,
"video_script": video_script,
},
)
await session.flush()
return task
def _enable_video(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", True)
monkeypatch.setattr(cfg, "video_on_spotlight", True)
def _svc(session: AsyncSession) -> XPostService:
return get_x_post_service(session)
def _id(task: TaskTable) -> UUID:
"""The ORM id typed as stdlib ``uuid.UUID`` for service-call sites."""
return cast("UUID", task.id)
@pytest.mark.asyncio
async def test_approve_posts_and_completes(db_session: AsyncSession) -> None:
task = await _seed_draft(db_session)
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
assert result.tweet_id == "999"
assert client.calls == ["Draft body"]
await db_session.refresh(task)
assert task.status == TS.COMPLETED
assert markers.get_x_posted_tweet_id(task) == "999"
@pytest.mark.asyncio
async def test_approve_is_idempotent_second_call_is_noop(
db_session: AsyncSession,
) -> None:
task = await _seed_draft(db_session)
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
svc = _svc(db_session)
first = await svc.approve(_id(task))
second = await svc.approve(_id(task))
assert first is not None
assert first.status == "posted"
assert second is not None
assert second.status == "already_posted"
assert second.tweet_id == "999"
# The X client was called exactly once — the second approve never re-posts.
assert client.calls == ["Draft body"]
@pytest.mark.asyncio
async def test_approve_with_edited_body_posts_the_edit(
db_session: AsyncSession,
) -> None:
task = await _seed_draft(db_session, body="Original")
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
result = await _svc(db_session).approve(_id(task), "Edited body")
assert result is not None
assert result.status == "posted"
assert client.calls == ["Edited body"]
@pytest.mark.asyncio
async def test_approve_rejects_edited_body_over_280_chars(
db_session: AsyncSession,
) -> None:
task = await _seed_draft(db_session)
with pytest.raises(XPostBodyTooLongError):
await _svc(db_session).approve(_id(task), "x" * 281)
@pytest.mark.asyncio
async def test_approve_no_credentials_result(db_session: AsyncSession) -> None:
task = await _seed_draft(db_session)
no_creds_client = _StubClient()
class _Null(XClient):
@property
def configured(self) -> bool:
return False
async def post_tweet(
self, text: str, *, in_reply_to_tweet_id: str | None = None
) -> XPostResult:
_ = (text, in_reply_to_tweet_id)
return XPostResult(posted=False, tweet_id=None, detail="no creds")
async def fetch_mentions(
self, since_id: str | None, max_results: int
) -> list[XMention]:
_ = (since_id, max_results)
return []
async def search_recent(self, query: str, max_results: int) -> list[XMention]:
_ = (query, max_results)
return []
_ = no_creds_client
with (
patch("roboco.services.x_post_service.build_x_client", return_value=_Null()),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "no_credentials"
await db_session.refresh(task)
assert task.status == TS.PENDING
@pytest.mark.asyncio
async def test_approve_post_failed_keeps_task_open(db_session: AsyncSession) -> None:
task = await _seed_draft(db_session)
client = _StubClient(posted=False)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "post_failed"
await db_session.refresh(task)
assert task.status == TS.PENDING
@pytest.mark.asyncio
async def test_approve_rechecks_completed_under_lock_and_never_reposts(
db_session: AsyncSession,
) -> None:
"""The double-post guard: a concurrent approve wins the lock, posts, and
commits COMPLETED after our pre-lock read. Once we acquire the lock, the
in-lock re-read must see COMPLETED and short-circuit — never re-posting."""
task = await _seed_draft(db_session)
client = _StubClient()
async def _win_the_race(_self: XPostService, _key: str) -> str:
# Simulate the concurrent winner: the row is COMPLETED (posted) by the
# time we hold the lock, exactly as the in-lock expire()+re-fetch sees.
markers.set_x_posted_tweet_id(task, "111")
task.status = TS.COMPLETED
await db_session.flush()
return "tok"
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", _win_the_race),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "already_posted"
assert result.tweet_id == "111"
# The tweet was never posted a second time.
assert client.calls == []
@pytest.mark.asyncio
async def test_approve_refuses_already_rejected_draft(
db_session: AsyncSession,
) -> None:
"""The chokepoint guard: approving a CANCELLED (already-rejected) draft
refuses and never calls the X client — the reproduced bug (a stale
Approve after reject re-posting)."""
task = await _seed_draft(db_session)
with _lock_free():
await _svc(db_session).reject(_id(task), "not on-brand")
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "already_rejected"
assert client.calls == []
await db_session.refresh(task)
assert task.status == TS.CANCELLED
@pytest.mark.asyncio
async def test_approve_rechecks_cancelled_under_lock_and_never_posts(
db_session: AsyncSession,
) -> None:
"""TOCTOU parity with the COMPLETED re-check: a concurrent reject cancels
the draft after our pre-lock read; the in-lock re-read must see CANCELLED
and short-circuit — never posting a rejected draft."""
task = await _seed_draft(db_session)
client = _StubClient()
async def _win_the_race(_self: XPostService, _key: str) -> str:
task.status = TS.CANCELLED
await db_session.flush()
return "tok"
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", _win_the_race),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "already_rejected"
assert client.calls == []
@pytest.mark.asyncio
async def test_approve_concurrent_lock_held_returns_in_progress(
db_session: AsyncSession,
) -> None:
task = await _seed_draft(db_session)
with patch.object(XPostService, "_acquire_lock", AsyncMock(return_value=None)):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "already_in_progress"
@pytest.mark.asyncio
async def test_approve_unknown_task_returns_none(db_session: AsyncSession) -> None:
result = await _svc(db_session).approve(uuid4())
assert result is None
@pytest.mark.asyncio
async def test_reject_records_reason_and_cancels(db_session: AsyncSession) -> None:
task = await _seed_draft(db_session, source=X_REPLY_SOURCE)
with _lock_free():
updated = await _svc(db_session).reject(
_id(task), "Tone doesn't match our voice"
)
assert updated is not None
assert updated.status == TS.CANCELLED
assert markers.get_x_reject_reason(updated) == "Tone doesn't match our voice"
@pytest.mark.asyncio
async def test_reject_refused_while_lock_held_by_concurrent_approve(
db_session: AsyncSession,
) -> None:
"""A concurrent approve holds the post lock (mid-tweet-POST); reject must
fail closed instead of racing a CANCEL under it — previously reject()
never even attempted the lock, so it could commit CANCELLED to a draft a
concurrent approve was about to mark COMPLETED, or clobber the approve's
outcome depending on commit ordering."""
task = await _seed_draft(db_session)
with patch.object(XPostService, "_acquire_lock", AsyncMock(return_value=None)):
result = await _svc(db_session).reject(_id(task), "not relevant")
assert result is None
await db_session.refresh(task)
assert task.status == TS.PENDING
assert markers.get_x_reject_reason(task) is None
@pytest.mark.asyncio
async def test_list_open_posts_excludes_terminal(db_session: AsyncSession) -> None:
open_task = await _seed_draft(db_session)
rejected_task = await _seed_draft(db_session, source=X_REPLY_SOURCE)
with _lock_free():
await _svc(db_session).reject(_id(rejected_task), "not relevant")
open_posts = await _svc(db_session).list_open_posts()
ids = {t.id for t in open_posts}
assert open_task.id in ids
assert rejected_task.id not in ids
@pytest.mark.asyncio
async def test_approve_posts_feature_spotlight_draft(
db_session: AsyncSession,
) -> None:
"""The feature-spotlight source rides the same generic post path as
x_post/x_reply; it only branches for the best-effort video hook below
(a no-op here since this draft carries no x_feature_ref marker)."""
task = await _seed_draft(db_session, source=X_FEATURE_SOURCE)
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
assert result.tweet_id == "999"
assert client.calls == ["Draft body"]
await db_session.refresh(task)
assert task.status == TS.COMPLETED
assert markers.get_x_posted_tweet_id(task) == "999"
@pytest.mark.asyncio
async def test_list_open_posts_includes_feature_spotlight_source(
db_session: AsyncSession,
) -> None:
task = await _seed_draft(db_session, source=X_FEATURE_SOURCE)
open_posts = await _svc(db_session).list_open_posts()
ids = {t.id for t in open_posts}
assert task.id in ids
@pytest.mark.asyncio
async def test_approve_posts_barfly_draft_as_a_reply(db_session: AsyncSession) -> None:
"""An x_barfly draft's carried tweet_id (barfly_reply_ref) threads
through to post_tweet's in_reply_to_tweet_id — the CEO's approve posts
it as an actual reply, not a standalone tweet."""
task = await _seed_draft(db_session, source=X_BARFLY_SOURCE)
markers.set_barfly_reply_ref(
task,
{
"tweet_id": "555",
"author_handle": "someone",
"text": "we should build a multi-agent org",
"rationale": "directly relevant",
},
)
await db_session.flush()
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
assert client.reply_targets == ["555"]
@pytest.mark.asyncio
async def test_approve_plain_x_post_never_passes_a_reply_target(
db_session: AsyncSession,
) -> None:
"""A non-barfly source never threads in_reply_to_tweet_id — proves the
branch is source-gated, not accidentally always-on."""
task = await _seed_draft(db_session, source=X_POST_SOURCE)
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
await _svc(db_session).approve(_id(task))
assert client.reply_targets == [None]
@pytest.mark.asyncio
async def test_list_open_posts_includes_barfly_source(db_session: AsyncSession) -> None:
task = await _seed_draft(db_session, source=X_BARFLY_SOURCE)
open_posts = await _svc(db_session).list_open_posts()
ids = {t.id for t in open_posts}
assert task.id in ids
@pytest.mark.asyncio
async def test_reject_completed_raises(db_session: AsyncSession) -> None:
"""An already-posted (COMPLETED) draft is live on X; rejecting it would
lie "cancelled (never posted)" while the tweet is public."""
task = await _seed_draft(db_session)
markers.set_x_posted_tweet_id(task, "999")
task.status = TS.COMPLETED
await db_session.flush()
with pytest.raises(TaskAlreadyCompletedError):
await _svc(db_session).reject(_id(task), "nope")
@pytest.mark.asyncio
async def test_reject_concurrent_approve_completes_during_lock_wait(
db_session: AsyncSession, _test_database_url: str
) -> None:
"""Redis mutex pre-lock write audit regression for ``reject()``: a
genuinely concurrent approve (a real second session/connection) posts +
commits COMPLETED in the window between reject's pre-lock read and its
lock acquisition. The in-lock re-read must see that committed state and
refuse — the CANCELLED status write and reject reason must never land on
the just-posted row, proving the fix holds across sessions, not merely
within one. Mirrors
``test_approve_concurrent_edit_does_not_clobber_a_committed_post``."""
task = await _seed_draft(db_session)
task_id = _id(task)
await db_session.commit()
real_get = TaskService.get
injected = False
async def _get_then_inject_concurrent_post(
self: TaskService, tid: UUID
) -> TaskTable | None:
"""Fires once, right after reject's pre-lock read — the exact window
between that read and reject's own (would-be) pre-lock write."""
nonlocal injected
result = await real_get(self, tid)
if not injected:
injected = True
other, other_engine = await _fresh_session(_test_database_url)
try:
other_task = await other.get(TaskTable, tid)
assert other_task is not None
markers.set_x_posted_tweet_id(other_task, "concurrent-999")
other_task.status = TS.COMPLETED
await other.commit()
finally:
await _dispose(other, other_engine)
return result
with (
patch.object(TaskService, "get", _get_then_inject_concurrent_post),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
pytest.raises(TaskAlreadyCompletedError),
):
await _svc(db_session).reject(task_id, "Tone doesn't match")
fresh, fresh_engine = await _fresh_session(_test_database_url)
try:
final = await fresh.get(TaskTable, task_id)
assert final is not None
assert final.status == TS.COMPLETED
assert markers.get_x_posted_tweet_id(final) == "concurrent-999"
# The reject must never have landed on the just-posted row.
assert markers.get_x_reject_reason(final) is None
finally:
await _dispose(fresh, fresh_engine)
@pytest.mark.asyncio
async def test_list_post_history_excludes_open_drafts(
db_session: AsyncSession,
) -> None:
open_task = await _seed_draft(db_session)
rejected_task = await _seed_draft(db_session, source=X_REPLY_SOURCE)
with _lock_free():
await _svc(db_session).reject(_id(rejected_task), "not relevant")
history = await _svc(db_session).list_post_history()
ids = {t.id for t in history}
assert rejected_task.id in ids
assert open_task.id not in ids
@pytest.mark.asyncio
async def test_list_post_history_newest_acted_first(
db_session: AsyncSession,
) -> None:
rejected_task = await _seed_draft(db_session, source=X_REPLY_SOURCE)
with _lock_free():
await _svc(db_session).reject(_id(rejected_task), "not relevant")
posted_task = await _seed_draft(db_session)
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
await _svc(db_session).approve(_id(posted_task))
history = await _svc(db_session).list_post_history()
ids = [t.id for t in history]
assert ids.index(posted_task.id) < ids.index(rejected_task.id)
@pytest.mark.asyncio
async def test_list_post_history_includes_marker_fields(
db_session: AsyncSession,
) -> None:
posted_task = await _seed_draft(db_session)
client = _StubClient(tweet_id="777")
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
await _svc(db_session).approve(_id(posted_task))
rejected_task = await _seed_draft(db_session, source=X_REPLY_SOURCE)
with _lock_free():
await _svc(db_session).reject(_id(rejected_task), "off-brand tone")
history = await _svc(db_session).list_post_history()
by_id = {t.id: t for t in history}
assert markers.get_x_posted_tweet_id(by_id[posted_task.id]) == "777"
assert markers.get_x_reject_reason(by_id[rejected_task.id]) == "off-brand tone"
@pytest.mark.asyncio
async def test_list_post_history_respects_limit(db_session: AsyncSession) -> None:
tasks = []
for _ in range(3):
t = await _seed_draft(db_session, source=X_REPLY_SOURCE)
with _lock_free():
await _svc(db_session).reject(_id(t), "not relevant")
tasks.append(t)
history = await _svc(db_session).list_post_history(limit=2)
assert len(history) == TWO
ids = {t.id for t in history}
assert tasks[2].id in ids
assert tasks[1].id in ids
assert tasks[0].id not in ids
@pytest.mark.asyncio
async def test_approve_does_not_flush_edited_body_before_lock(
db_session: AsyncSession,
) -> None:
"""The CEO's edit must land on the re-read locked row INSIDE the critical
section, after the COMPLETED check — never on the pre-lock row. A concurrent
approve that already posted (locked row COMPLETED) must not have this edit
overwrite the just-posted task's stored body."""
task = await _seed_draft(db_session, body="Original")
original_body = markers.get_x_draft_body(task)
async def _already_posted_locked(
_self: XPostService, _task_id: UUID, _task: TaskTable, _trimmed: str | None
) -> XPostExecuteResult:
return XPostExecuteResult(
status="already_posted",
tweet_id="111",
detail="this draft was already posted",
)
with (
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch.object(XPostService, "_approve_locked", _already_posted_locked),
):
result = await _svc(db_session).approve(_id(task), "new body")
assert result is not None
assert result.status == "already_posted"
await db_session.refresh(task)
assert markers.get_x_draft_body(task) == original_body
# --------------------------------------------------------------------------- #
# LEARN wiring (Task 5): approve/reject of an X_FEATURE_SOURCE draft best-
# effort records onto the open board_program_cycles row for "x_feature" —
# other X sources (x_post/x_reply) are not board-program-backed and must
# never record. See test_board_program_engine.py for record_decision's own
# counter/close-on-terminal coverage.
# --------------------------------------------------------------------------- #
async def _seed_cycle_ledger_row(session: AsyncSession, task: TaskTable) -> None:
session.add(
BoardProgramCycleTable(
program_key="x_feature",
exploration_task_id=task.id,
opened_at=datetime.now(UTC),
)
)
await session.flush()
async def _cycle_row_for_task(
session: AsyncSession, task_id: UUID
) -> BoardProgramCycleTable:
"""The board_program_cycles row THIS task's approve/reject decided —
scoped by exploration_task_id rather than a bare program_key filter, since
``_post()``'s real ``session.commit()`` durably leaks rows from earlier
tests into this file's shared session-scoped test DB (documented above
`_delete_tasks`); a global program_key query would collide across tests."""
return (
await session.execute(
select(BoardProgramCycleTable).where(
BoardProgramCycleTable.exploration_task_id == task_id
)
)
).scalar_one()
@pytest.mark.asyncio
async def test_approve_feature_spotlight_records_learn_decision(
db_session: AsyncSession,
) -> None:
task = await _seed_feature_draft(db_session)
await _seed_cycle_ledger_row(db_session, task)
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
await _svc(db_session).approve(_id(task))
row = await _cycle_row_for_task(db_session, _id(task))
assert row.items_approved == ONE
assert {
"item_ref": _FEATURE_SLUG,
"verdict": "approved",
"reason": None,
} in row.decisions
@pytest.mark.asyncio
async def test_reject_feature_spotlight_records_learn_decision_with_reason(
db_session: AsyncSession,
) -> None:
task = await _seed_feature_draft(db_session)
await _seed_cycle_ledger_row(db_session, task)
with _lock_free():
await _svc(db_session).reject(_id(task), "not on-brand")
row = await _cycle_row_for_task(db_session, _id(task))
assert row.items_rejected == ONE
assert {
"item_ref": _FEATURE_SLUG,
"verdict": "rejected",
"reason": "not on-brand",
} in row.decisions
@pytest.mark.asyncio
async def test_approve_plain_x_post_does_not_record_learn(
db_session: AsyncSession,
) -> None:
"""x_post/x_reply drafts are not board-program-backed — approving one
must never touch the board_program_cycles ledger."""
task = await _seed_draft(db_session, source=X_POST_SOURCE)
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
await _svc(db_session).approve(_id(task))
rows = (
(
await db_session.execute(
select(BoardProgramCycleTable).where(
BoardProgramCycleTable.exploration_task_id == task.id
)
)
)
.scalars()
.all()
)
assert rows == []
@pytest.mark.asyncio
async def test_approve_feature_spotlight_survives_learn_recording_failure(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A record_decision blow-up must never break the already-succeeded post."""
task = await _seed_feature_draft(db_session)
await _seed_cycle_ledger_row(db_session, task)
client = _StubClient()
async def _boom(_self: object, *_args: object, **_kwargs: object) -> None:
raise RuntimeError("learn boom")
monkeypatch.setattr(bp_module.BoardProgramEngine, "record_decision", _boom)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
async def _fresh_session(url: str) -> tuple[AsyncSession, AsyncEngine]:
"""A session on a brand-new engine/connection (caller disposes)."""
engine = create_async_engine(url, future=True)
factory = async_sessionmaker(
bind=engine, class_=AsyncSession, expire_on_commit=False
)
return factory(), engine
async def _dispose(session: AsyncSession, engine: AsyncEngine) -> None:
with contextlib.suppress(Exception):
await session.rollback()
await engine.dispose()
@pytest.mark.asyncio
async def test_approve_concurrent_edit_does_not_clobber_a_committed_post(
db_session: AsyncSession, _test_database_url: str
) -> None:
"""Redis mutex pre-lock write audit regression: a genuinely concurrent
approve (a real second session/connection, not an in-process mock) posts
+ commits COMPLETED in the window between our pre-lock read and our lock
acquisition. The in-lock re-read must see that committed state and the
CEO's edited body must never land on the just-posted row — proving the
fix holds across sessions, not merely within one, mirroring
VideoPostService's identical cross-session regression test."""
task = await _seed_draft(db_session, body="Original")
task_id = _id(task)
await db_session.commit()
real_get = TaskService.get
injected = False
async def _get_then_inject_concurrent_post(
self: TaskService, tid: UUID
) -> TaskTable | None:
"""Fires once, right after the outer pre-lock read — the exact
window between our read and our own (would-be) pre-lock write."""
nonlocal injected
result = await real_get(self, tid)
if not injected:
injected = True
other, other_engine = await _fresh_session(_test_database_url)
try:
other_task = await other.get(TaskTable, tid)
assert other_task is not None
markers.set_x_posted_tweet_id(other_task, "concurrent-999")
other_task.status = TS.COMPLETED
await other.commit()
finally:
await _dispose(other, other_engine)
return result
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(TaskService, "get", _get_then_inject_concurrent_post),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
result = await _svc(db_session).approve(task_id, "Edited body")
assert result is not None
assert result.status == "already_posted"
assert result.tweet_id == "concurrent-999"
# No double-post: the concurrently-committed tweet wins, ours never fires.
assert client.calls == []
fresh, fresh_engine = await _fresh_session(_test_database_url)
try:
final = await fresh.get(TaskTable, task_id)
assert final is not None
assert final.status == TS.COMPLETED
assert markers.get_x_posted_tweet_id(final) == "concurrent-999"
# The edit must never have landed on the just-posted row.
assert markers.get_x_draft_body(final) == "Original"
finally:
await _dispose(fresh, fresh_engine)
# --------------------------------------------------------------------------- #
# Spotlight video hook (Task 4, 2026-07-09 pipeline fixes): moved from
# authoring time (propose_feature_spotlight) to this posted-success branch so
# a ux-dev never burns a cycle on a spotlight the CEO then rejects.
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_approve_feature_spotlight_with_video_opens_video_task(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session, video_script="Custom voiceover script")
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
video_engine.open_video_task.assert_awaited_once()
kwargs = video_engine.open_video_task.call_args.kwargs
assert kwargs["occasion"] == "spotlight org-memory"
assert kwargs["platforms"] == ["x", "tiktok"]
assert kwargs["script"] == "Custom voiceover script"
assert kwargs["brief"] == "Organizational Memory Loop: Draft body"
# The spotlight's own project scopes the video authoring — without it the
# video authored against the deployment-anchor project regardless.
assert kwargs["project_id"] == task.project_id
@pytest.mark.asyncio
async def test_approve_feature_spotlight_video_falls_back_to_brief_script(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No explicit video_script -> script falls back to the brief, mirroring
the fallback the authoring-time hook used to do."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
kwargs = video_engine.open_video_task.call_args.kwargs
expected_brief = "Organizational Memory Loop: Draft body"
assert kwargs["script"] == expected_brief
assert kwargs["brief"] == expected_brief
@pytest.mark.asyncio
async def test_approve_feature_spotlight_reapprove_does_not_reopen_video(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Idempotent re-approve: the second call short-circuits on the already-
COMPLETED check before ever reaching _post/_open_spotlight_video again."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
svc = _svc(db_session)
first = await svc.approve(_id(task))
second = await svc.approve(_id(task))
assert first is not None
assert first.status == "posted"
assert second is not None
assert second.status == "already_posted"
video_engine.open_video_task.assert_awaited_once()
@pytest.mark.asyncio
async def test_approve_plain_x_post_never_opens_video(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A plain x_post draft carries no x_feature_ref, so the source check
alone keeps the video hook from ever firing for it."""
_enable_video(monkeypatch)
task = await _seed_draft(db_session, source=X_POST_SOURCE)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_reject_feature_spotlight_with_wants_video_opens_none(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Rejecting a spotlight draft never posts, so the video hook (which only
fires from the posted-success branch of _post) never runs either."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session)
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
_lock_free(),
):
updated = await _svc(db_session).reject(_id(task), "not on-brand")
assert updated is not None
assert updated.status == TS.CANCELLED
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_approve_feature_spotlight_video_flags_off_skips(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", False)
monkeypatch.setattr(cfg, "video_on_spotlight", False)
task = await _seed_feature_draft(db_session)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_approve_feature_spotlight_without_wants_video_skips(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Flags on but the draft's author didn't request a video (wants_video
absent/False on the marker) -> no video task, distinct from the
flags-off case above."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session, wants_video=False)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_approve_feature_spotlight_video_failure_does_not_break_post(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Best-effort: a video-engine blow-up must not affect the already-
succeeded post."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session)
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
side_effect=RuntimeError("video-engine boom"),
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
await db_session.refresh(task)
assert task.status == TS.COMPLETED
# --------------------------------------------------------------------------- #
# Reject -> redraft (CEO feedback loop): a non-blank reject reason schedules
# XEngine.redraft_from_rejection to run only after this session's transaction
# actually commits (`defer_after_commit`), via a FRESH session opened from
# `get_session_factory()` — patched here to the same test database
# `db_session` uses, since the production singleton points elsewhere in
# tests. Mirrors `test_notification_delivery_phantom.py`'s drain helpers
# (duplicated locally rather than imported — this project's convention for
# a small pure test helper, not a service internal).
# --------------------------------------------------------------------------- #
def _drain_tasks(session: AsyncSession) -> list[asyncio.Task[object]]:
return list(session.info.get("_roboco_drain_tasks", []))
async def _await_drain(session: AsyncSession) -> None:
tasks = _drain_tasks(session)
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
async def _redraft_engine_factory(
url: str,
) -> tuple[async_sessionmaker[AsyncSession], AsyncEngine]:
"""A session factory on a brand-new engine bound to the SAME test
database `db_session` uses — what `_schedule_redraft`'s deferred closure
opens via `get_session_factory()` at drain time, patched here instead of
the (unreachable-in-tests) production singleton."""
engine = create_async_engine(url, future=True)
factory = async_sessionmaker(
bind=engine, class_=AsyncSession, expire_on_commit=False
)
return factory, engine
async def _delete_tasks(session: AsyncSession, *task_ids: UUID) -> None:
"""Delete these task rows and commit.
The tests below exercise a REAL `session.commit()` (required to fire the
after-commit redraft), and `_test_database_url` is a SESSION-scoped
database shared by every test in the whole run — an uncommitted row is
cleaned up by `db_session`'s own rollback-at-teardown, but a committed
one is durable and would leak an open draft into every later test (in
this file and any other) that counts/lists open X drafts. Explicit
cleanup restores the shared DB to a clean slate.
"""
await session.execute(delete(TaskTable).where(TaskTable.id.in_(task_ids)))
await session.commit()
@contextmanager
def _redraft_lock_free() -> Iterator[None]:
"""Patch XEngine's redraft-dedup lock helpers (class-level, since the
deferred `_redraft()` closure constructs a fresh `XEngine` each time) so
the redraft's check+originate exercises its real path without touching
the (test-blocked) Redis — mirrors `_lock_free()` above for the post
mutex."""
with (
patch.object(
x_engine_module.XEngine,
"_acquire_redraft_lock",
AsyncMock(return_value="tok"),
),
patch.object(
x_engine_module.XEngine,
"_release_redraft_lock",
AsyncMock(return_value=None),
),
):
yield
@pytest.mark.asyncio
async def test_reject_with_reason_schedules_deferred_redraft(
db_session: AsyncSession,
) -> None:
"""A non-blank reason enqueues the redraft on the after-commit outbox —
nothing runs before the transaction actually commits."""
task = await _seed_draft(db_session)
with _lock_free():
await _svc(db_session).reject(_id(task), "Too vague")
assert db_session.info.get("_roboco_pending_bus_publishes")
@pytest.mark.asyncio
async def test_reject_blank_reason_schedules_no_redraft(
db_session: AsyncSession,
) -> None:
"""Preserves current semantics: a blank/whitespace reason is a plain
cancel, nothing scheduled."""
task = await _seed_draft(db_session)
with _lock_free():
await _svc(db_session).reject(_id(task), " ")
assert not db_session.info.get("_roboco_pending_bus_publishes")
@pytest.mark.asyncio
async def test_reject_redraft_materializes_held_draft_of_same_source_with_new_body(
db_session: AsyncSession,
monkeypatch: pytest.MonkeyPatch,
_test_database_url: str,
) -> None:
"""End to end: reject with a reason -> commit -> drain -> a fresh HELD
draft of the SAME source, carrying the local model's revised body.
Pins the fresh-session contract two ways: `db_session` is CLOSED before
the drain runs (SQLAlchemy silently reopens a connection on reuse, so
this alone can't force a raise — it's included anyway per spec, and
still proves the drain doesn't NEED the request session kept open); the
real teeth is `get_x_engine` wrapped to capture the actual session
XEngine is constructed with and asserting it is NOT `db_session` — that
catches a "captured self.session instead of opening a fresh one"
regression regardless of `.close()`'s (non-)effect.
"""
# A non-empty brand_voice skips XEngine's one-time nudge notification —
# this test's own commit would otherwise durably flip that GLOBAL
# "already nudged" system_settings flag for the rest of the suite.
await get_company_goals_service(db_session).upsert(
{"brand_voice": "Confident, concise, no fluff."}
)
monkeypatch.setattr(
x_engine_module, "_chat", AsyncMock(return_value="Revised body.")
)
task = await _seed_draft(db_session, source=X_POST_SOURCE, body="Original body")
task_id = _id(task)
factory, engine = await _redraft_engine_factory(_test_database_url)
monkeypatch.setattr("roboco.db.base.get_session_factory", lambda: factory)
captured_sessions: list[AsyncSession] = []
real_get_x_engine = x_engine_module.get_x_engine
def _capturing_get_x_engine(
session: AsyncSession, client: XClient | None = None
) -> x_engine_module.XEngine:
captured_sessions.append(session)
return real_get_x_engine(session, client=client)
monkeypatch.setattr(x_engine_module, "get_x_engine", _capturing_get_x_engine)
with _redraft_lock_free():
try:
with _lock_free():
await _svc(db_session).reject(task_id, "Needs a concrete detail")
await db_session.commit()
await db_session.close()
await _await_drain(db_session)
finally:
await engine.dispose()
assert len(captured_sessions) == 1
assert captured_sessions[0] is not db_session
open_posts = await _svc(db_session).list_open_posts()
redrafts = [t for t in open_posts if t.id != task_id]
assert len(redrafts) == 1
redraft = redrafts[0]
assert redraft.source == X_POST_SOURCE
assert redraft.status == TS.PENDING
assert redraft.confirmed_by_human is False
assert markers.get_x_draft_body(redraft) == "Revised body."
await _delete_tasks(db_session, task_id, UUID(str(redraft.id)))
@pytest.mark.asyncio
async def test_reject_redraft_local_model_failure_originates_nothing(
db_session: AsyncSession,
monkeypatch: pytest.MonkeyPatch,
_test_database_url: str,
) -> None:
"""A local-model failure at redraft time never ships a degraded copy —
the reject stays a plain cancel with no fresh draft."""
monkeypatch.setattr(
x_engine_module, "_chat", AsyncMock(side_effect=RuntimeError("ollama down"))
)
task = await _seed_draft(db_session, source=X_POST_SOURCE)
task_id = _id(task)
factory, engine = await _redraft_engine_factory(_test_database_url)
monkeypatch.setattr("roboco.db.base.get_session_factory", lambda: factory)
with _redraft_lock_free():
try:
with _lock_free():
updated = await _svc(db_session).reject(task_id, "Needs work")
assert updated is not None
assert updated.status == TS.CANCELLED
await db_session.commit()
await _await_drain(db_session)
finally:
await engine.dispose()
open_posts = await _svc(db_session).list_open_posts()
assert open_posts == []
await _delete_tasks(db_session, task_id)
@pytest.mark.asyncio
async def test_reject_redraft_respects_open_post_cap(
db_session: AsyncSession,
monkeypatch: pytest.MonkeyPatch,
_test_database_url: str,
) -> None:
"""The shared open-post cap blocks the redraft exactly like it blocks any
other origination — an unrelated open draft filling the cap means the
rejected item gets no revision this cycle."""
monkeypatch.setattr(cfg, "x_max_open_posts", 1)
monkeypatch.setattr(
x_engine_module, "_chat", AsyncMock(return_value="Revised body.")
)
task = await _seed_draft(db_session, source=X_POST_SOURCE)
task_id = _id(task)
filler = await _seed_draft(db_session, source=X_REPLY_SOURCE, body="Filler reply")
factory, engine = await _redraft_engine_factory(_test_database_url)
monkeypatch.setattr("roboco.db.base.get_session_factory", lambda: factory)
with _redraft_lock_free():
try:
with _lock_free():
await _svc(db_session).reject(task_id, "Needs a concrete detail")
await db_session.commit()
await _await_drain(db_session)
finally:
await engine.dispose()
open_posts = await _svc(db_session).list_open_posts()
ids = {t.id for t in open_posts}
assert ids == {filler.id} # no redraft — the cap was already at 1
await _delete_tasks(db_session, task_id, UUID(str(filler.id)))
@pytest.mark.asyncio
async def test_reject_survives_deferred_session_factory_failure(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A redraft failure at drain time (here: the fresh-session open itself
blowing up) must never break the reject — it already committed CANCELLED
before this best-effort seam ever runs."""
task = await _seed_draft(db_session, source=X_POST_SOURCE)
task_id = _id(task)
def _boom() -> async_sessionmaker[AsyncSession]:
raise RuntimeError("db down")
monkeypatch.setattr("roboco.db.base.get_session_factory", _boom)
with _lock_free():
updated = await _svc(db_session).reject(task_id, "Needs work")
assert updated is not None
assert updated.status == TS.CANCELLED
await db_session.commit()
await _await_drain(db_session) # must not raise
await db_session.refresh(updated)
assert updated.status == TS.CANCELLED
assert markers.get_x_reject_reason(updated) == "Needs work"
await _delete_tasks(db_session, task_id)
@pytest.mark.asyncio
async def test_reject_replayed_on_already_cancelled_is_noop(
db_session: AsyncSession,
) -> None:
"""A second reject() on an already-CANCELLED task (a stale/replayed
request — e.g. a double-tapped Telegram button) is idempotent: it
returns the task UNCHANGED, never re-flushes the reason, and schedules
NO second redraft — mirroring approve()'s already_rejected
short-circuit. Pre-fix this re-flushed CANCELLED and scheduled another
redraft on every replay."""
task = await _seed_draft(db_session, source=X_POST_SOURCE)
task_id = _id(task)
with _lock_free():
first = await _svc(db_session).reject(task_id, "Needs work")
assert first is not None
assert first.status == TS.CANCELLED
# The first reject scheduled its own redraft — clear the pending queue
# so the assertion below is unambiguous about what the SECOND call does.
db_session.info.pop("_roboco_pending_bus_publishes", None)
with _lock_free():
second = await _svc(db_session).reject(task_id, "A completely different reason")
assert second is not None
assert second.status == TS.CANCELLED
# Unchanged: the replay's reason must NOT overwrite the original.
assert markers.get_x_reject_reason(second) == "Needs work"
assert not db_session.info.get("_roboco_pending_bus_publishes")