mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(video): unconfigured platforms are skipped, not pending-forever failures (#545)
A draft targeting a platform with no credentials could never complete: the X leg posted and committed its id, the TikTok leg failed as 'transient', and the card sat pending in the CEO queue with retry semantics that structurally cannot succeed. Unconfigured platforms are now an explicit skip: the draft COMPLETES when every configured platform has posted (detail names what was skipped), an approve whose targets are all unconfigured refuses loudly instead of silently completing, and genuine post failures keep their partial/retry semantics. Re-approving a parked card clears it without re-posting (the already-posted guard is covered by a dedicated test). Verified: 32/32 test_video_post_service (3 new: skip-and-complete, all-unconfigured refusal, re-approve recovery), ruff/mypy/xenon clean. Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -67,15 +67,17 @@ class _StubXPoster(XVideoPoster):
|
||||
posted: bool = True,
|
||||
video_id: str = "x-vid-1",
|
||||
raises: bool = False,
|
||||
configured: bool = True,
|
||||
) -> None:
|
||||
self._posted = posted
|
||||
self._video_id = video_id
|
||||
self._raises = raises
|
||||
self._configured = configured
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return True
|
||||
return self._configured
|
||||
|
||||
async def post_video(self, *, mp4_path: str, caption: str) -> XVideoPostResult:
|
||||
self.calls.append((mp4_path, caption))
|
||||
@@ -93,15 +95,17 @@ class _StubTikTokPoster(TikTokPoster):
|
||||
uploaded: bool = True,
|
||||
publish_id: str = "tt-pub-1",
|
||||
raises: bool = False,
|
||||
configured: bool = True,
|
||||
) -> None:
|
||||
self._uploaded = uploaded
|
||||
self._publish_id = publish_id
|
||||
self._raises = raises
|
||||
self._configured = configured
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return True
|
||||
return self._configured
|
||||
|
||||
async def upload_to_inbox(
|
||||
self, *, mp4_path: str, caption: str
|
||||
@@ -253,6 +257,77 @@ async def test_approve_single_platform_only_calls_that_poster(
|
||||
assert tiktok_poster.calls == [] # never invoked — not in this draft's platforms
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_completes_when_unconfigured_platform_is_skipped(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""The live lingering-card defect: X posts, TikTok has no credentials —
|
||||
the draft must COMPLETE (skipped, not pending-forever)."""
|
||||
task = await _seed_video_post(db_session)
|
||||
x_poster = _StubXPoster()
|
||||
tiktok_poster = _StubTikTokPoster(configured=False)
|
||||
with _LOCKED[0], _LOCKED[1]:
|
||||
result = await _svc(
|
||||
db_session, x_poster=x_poster, tiktok_poster=tiktok_poster
|
||||
).approve(_id(task))
|
||||
assert result is not None
|
||||
assert result.status == "posted"
|
||||
assert result.posted == {"x": "x-vid-1"}
|
||||
assert "skipped (unconfigured): tiktok" in result.detail
|
||||
assert tiktok_poster.calls == [] # never attempted without credentials
|
||||
await db_session.refresh(task)
|
||||
assert task.status == TS.COMPLETED
|
||||
draft = markers.get_video_draft(task)
|
||||
assert draft is not None
|
||||
assert draft["x_posted_id"] == "x-vid-1"
|
||||
assert "tiktok_posted_id" not in draft
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_refuses_when_no_platform_is_configured(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""All targets unconfigured: never silently complete a draft that
|
||||
reached no audience."""
|
||||
task = await _seed_video_post(db_session)
|
||||
with _LOCKED[0], _LOCKED[1]:
|
||||
result = await _svc(
|
||||
db_session,
|
||||
x_poster=_StubXPoster(configured=False),
|
||||
tiktok_poster=_StubTikTokPoster(configured=False),
|
||||
).approve(_id(task))
|
||||
assert result is not None
|
||||
assert result.status == "post_failed"
|
||||
assert "no target platform has credentials configured" in result.detail
|
||||
await db_session.refresh(task)
|
||||
assert task.status != TS.COMPLETED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reapprove_after_partial_post_skips_x_and_completes(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""The exact recovery path for a card parked by an unconfigured
|
||||
platform: X already posted on a prior approve, TikTok unconfigured —
|
||||
re-approve must not re-post X and must clear the card."""
|
||||
task = await _seed_video_post(db_session)
|
||||
x_poster = _StubXPoster()
|
||||
tiktok_poster = _StubTikTokPoster(configured=False)
|
||||
svc = _svc(db_session, x_poster=x_poster, tiktok_poster=tiktok_poster)
|
||||
draft = markers.get_video_draft(task)
|
||||
assert draft is not None
|
||||
markers.set_video_draft(task, {**draft, "x_posted_id": "x-vid-prior"})
|
||||
await db_session.flush()
|
||||
with _LOCKED[0], _LOCKED[1]:
|
||||
result = await svc.approve(_id(task))
|
||||
assert result is not None
|
||||
assert result.status == "posted"
|
||||
assert result.posted == {"x": "x-vid-prior"}
|
||||
assert x_poster.calls == [] # already-posted guard held — no double post
|
||||
await db_session.refresh(task)
|
||||
assert task.status == TS.COMPLETED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_is_idempotent_second_call_is_noop(
|
||||
db_session: AsyncSession,
|
||||
|
||||
Reference in New Issue
Block a user