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:
Renzo F
2026-07-17 07:12:11 +02:00
committed by GitHub
co-authored by Renn F
parent a12fefcba9
commit 4c8a9fc008
2 changed files with 118 additions and 7 deletions
+77 -2
View File
@@ -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,