Skip to content

Commit 1e51889

Browse files
committed
Report TikTok publish failures honestly; disclose creator-settings gap
Codex found two real issues in this review pass: 1. publish_tiktok() always returned "status": "uploaded" regardless of what the post-upload status check actually said. Confirmed against TikTok's real, documented status values (PUBLISH_COMPLETE, FAILED with a fail_reason field, and PROCESSING_* -- not guessed): a terminal FAILED status now raises, with TikTok's own fail_reason in the message, instead of being silently reported as a success. Anything short of PUBLISH_COMPLETE is now honestly reported as "processing," pointing at --check-status, rather than "published." This matters more than usual here given the documented account-reset risk from blind retries -- an agent or user trusting a false "uploaded" status is exactly the kind of mistake that guardrail exists to prevent. 2. This publisher never checks a creator's own account restrictions (e.g. comments globally disabled) against the flags it sends before publishing. Confirmed via TikTok's public docs that creator_info exposes this, but fixing it properly needs a live account to confirm what the init endpoint actually does when a flag doesn't match -- reject outright, silently ignore, or something else. Left as an explicit, documented gap (module docstring + publish-tiktok skill) rather than guessed, to work through together during real testing rather than fixed blind now. Added 4 tests exercising the full real-publish path end to end (token load/refresh, init, upload, status fetch, all mocked) covering FAILED, still-processing, and PUBLISH_COMPLETE outcomes. No live TikTok API calls, no account access, nothing tested against a real account -- same as every other fix in this PR. 147 tests pass (was 143), compileall clean, git diff --check clean.
1 parent cae2ac1 commit 1e51889

4 files changed

Lines changed: 143 additions & 10 deletions

File tree

.claude/skills/publish-tiktok/SKILL.md

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,13 @@ assume the post is stuck private forever.
4949
`auth.publish_safety.should_publish`, as every other publisher here).
5050
- `--visibility` defaults to `private`. Only mention `--visibility public` once the user
5151
understands it is still forced private pre-audit -- see above.
52-
- `--title` is capped at 2200 UTF-16 code units by TikTok's API -- not independently verified
53-
against a live account by this tool; treat as a soft limit to check for until confirmed.
52+
- `--title` is capped at 2,200 UTF-16 code units (TikTok's documented limit) and this tool now
53+
actually enforces it before publishing -- an over-length title is rejected up front, not only
54+
by the real API after the video has already uploaded.
55+
- **This tool does not check a creator's own account restrictions before publishing.** A creator
56+
with e.g. comments disabled globally may reject the default `disable_comment=False` at the API
57+
level -- see the module docstring's "Known, disclosed gap" section. Confirm this together on a
58+
live account before assuming every combination of flags will be accepted.
5459
- **Never make multiple real (`--confirm-publish`) attempts back-to-back, even to debug the same
5560
failure.** Confirmed live 2026-08-18: a burst of real publish attempts against a brand-new
5661
unaudited developer app was followed by the connected TikTok account being reset entirely (new
@@ -67,10 +72,17 @@ assume the post is stuck private forever.
6772
- **Post "succeeds" (a `publish_id` comes back) but is invisible to anyone but the account
6873
owner**: expected pre-audit behavior, not a bug -- see the unaudited-app section above. Confirm
6974
with the user whether they want the manual per-post public-visibility fix.
70-
- **Upload fails partway through a chunk**: the chunk-size default (`DEFAULT_CHUNK_SIZE` in
71-
`auth/publish_tiktok.py`) is a reasonable starting point, not independently confirmed against
72-
TikTok's current minimum/maximum -- if this is the failure, that constant is the first thing to
73-
adjust, informed by whatever error TikTok's API actually returns.
75+
- **Upload fails partway through a chunk**: `MIN_CHUNK_SIZE`/`MAX_CHUNK_SIZE` in
76+
`auth/publish_tiktok.py` (5 MiB / 64 MiB) are confirmed against TikTok's real Media Transfer
77+
Guide, not guessed -- but chunked upload of a video over 64 MiB has not itself been exercised
78+
live yet (only the single-chunk, under-64-MiB path has). Treat the first large-video upload as
79+
still-unverified for that specific path.
80+
- **A publish "succeeds" (a `publish_id` comes back) but the video never appears**: the result's
81+
own `"status"` field distinguishes this now -- `"published"` means TikTok's status check
82+
reported `PUBLISH_COMPLETE`, `"processing"` means it hadn't finished yet as of that check (use
83+
`--check-status` to follow up), and a terminal `FAILED` status raises an error with TikTok's own
84+
`fail_reason` instead of silently returning either of those. Don't trust a bare `publish_id`
85+
alone as proof of success.
7486
- **Token expired and refresh fails**: `profiles/tiktok/client_secret.json` must still exist (not
7587
just `token.json`) for a refresh to succeed -- see `onboard-tiktok`'s known failures.
7688
- **The connected TikTok account gets reset/wiped after several real publish attempts in a short

CHANGELOG.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ that will firm up once it leaves beta. Dates are when a release was tagged, not
3535
safe-by-default test suite; added a dedicated TikTok OAuth check group to `doctor.py`
3636
(`python doctor.py --tiktok`). Still safe by default either way -- `--confirm-publish` is
3737
required for a real publish on both, exactly as for every other platform.
38-
- **Six further issues found by code review before any live retest, none of them guessed:**
38+
- **Seven further issues found by code review before any live retest, none of them guessed:**
3939
(1) `auth/publish_facebook.py` never forced its browser context to English like every other
4040
publisher does -- fixed with the same `FORCE_ENGLISH_LOCALE` constant. (2) The documented
4141
`--client-secrets path/to/tiktok_client.json` setup form silently broke every token refresh
@@ -58,7 +58,17 @@ that will firm up once it leaves beta. Dates are when a release was tagged, not
5858
code units rather than Python character count. (6) `auth/publish_facebook.py`'s
5959
timeline-verification check searched the whole page's text, which could false-positive on the
6060
profile bio, nav text, or an older post -- scoped to `[role="article"]` post containers
61-
instead.
61+
instead. (7) `publish_tiktok()` always returned `"status": "uploaded"` regardless of what the
62+
post-upload status check actually said -- a terminal `FAILED` response (TikTok's own
63+
documented status, with a `fail_reason` field) would have been reported as if it succeeded
64+
unless the caller manually inspected the nested `status_response`. Now raises on a genuine
65+
`FAILED` status, and honestly reports `"processing"` rather than `"published"` for anything
66+
short of TikTok's own `PUBLISH_COMPLETE`. A related, disclosed-but-unfixed gap: this module
67+
still doesn't check a creator's own account restrictions (e.g. comments disabled globally)
68+
against the flags it sends before publishing -- confirmed against TikTok's public docs that
69+
the `creator_info/query` endpoint exposes this, but fixing it properly needs a live account to
70+
confirm what the init endpoint actually does when a flag doesn't match, so it's left as an
71+
explicit, documented gap rather than guessed.
6272
- **YouTube's upload category is no longer hardcoded.** `auth/publish_youtube.py` gained
6373
`--category-id` (default unchanged: `22`, People & Blogs) -- closes a previously-documented
6474
defect in the upload path.

auth/publish_tiktok.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,21 @@
3939
second chunking bug (total_chunk_count computed via ceil division with an undersized trailing
4040
chunk, instead of TikTok's documented floor-division-plus-oversized-final-chunk rule) was found
4141
and fixed by code review before ever reaching a live test. See _compute_chunking's docstring.
42+
43+
## Known, disclosed gap: creator-level restrictions aren't checked before publishing
44+
45+
A creator's own account settings can restrict what a post is allowed to request -- for example,
46+
an account with comments disabled globally may reject `disable_comment=False` (this module's
47+
default) at the API level, per TikTok's `creator_info/query` endpoint (the same one `doctor.py`'s
48+
TikTok check already queries, for a different purpose: confirming the token authenticates against
49+
a real account). This module does NOT currently query that endpoint before constructing
50+
`post_info` and validate/constrain `privacy_level`/`disable_duet`/`disable_stitch`/
51+
`disable_comment` against it -- so an otherwise-valid publish attempt could be rejected by an
52+
init-time API error for a creator with non-default restrictions. Fixing this properly needs a
53+
live account to confirm what TikTok's init endpoint actually does when a flag doesn't match a
54+
creator's restrictions (reject outright, silently ignore, or something else) -- deliberately not
55+
guessed here. Treat this as a real gap to work through together, live, before broader use -- not
56+
a hypothetical.
4257
"""
4358

4459
from __future__ import annotations
@@ -409,16 +424,46 @@ def publish_tiktok(
409424

410425
status_response = _api_post_json(STATUS_FETCH_URL, access_token, {"publish_id": publish_id})
411426

427+
# Codex-reported regression, caught before any live test: this used to always return
428+
# "status": "uploaded" regardless of what status_response actually said, even a terminal
429+
# FAILED -- reporting a rejected post as if it succeeded unless the caller happened to
430+
# manually inspect the nested status_response. TikTok's documented post-status values
431+
# (https://developers.tiktok.com/doc/content-posting-api-reference-get-video-status):
432+
# PUBLISH_COMPLETE (done), FAILED (terminal, with a fail_reason field), and several
433+
# PROCESSING_* values for "still working on it" -- see check_publish_status above for
434+
# re-checking one of those later. A FAILED status must raise, not silently succeed; anything
435+
# short of PUBLISH_COMPLETE is honestly reported as still processing, not "uploaded" outright.
436+
status_error = status_response.get("error", {})
437+
if status_error.get("code") not in (None, "ok"):
438+
raise RuntimeError(
439+
f"TikTok status check failed for publish_id={publish_id}: {status_error}. "
440+
"Do not retry blind -- use --check-status to re-check later."
441+
)
442+
443+
tiktok_status = status_response.get("data", {}).get("status")
444+
if tiktok_status == "FAILED":
445+
fail_reason = status_response.get("data", {}).get("fail_reason", "unknown")
446+
raise RuntimeError(
447+
f"TikTok rejected this post after upload (fail_reason={fail_reason}, "
448+
f"publish_id={publish_id}). Do not retry blind -- see the troubleshoot-publishing "
449+
"skill before attempting another real publish."
450+
)
451+
412452
return {
413453
"dry_run": False,
414454
"platform": "tiktok",
415-
"status": "uploaded",
455+
"status": "published" if tiktok_status == "PUBLISH_COMPLETE" else "processing",
416456
"visibility": visibility,
417457
"privacy_level": privacy_level,
418458
"publish_id": publish_id,
419459
"title": title,
420460
"status_response": status_response,
421-
"note": UNAUDITED_APP_NOTICE,
461+
"note": UNAUDITED_APP_NOTICE + (
462+
"" if tiktok_status == "PUBLISH_COMPLETE" else
463+
f" Status is still '{tiktok_status}' as of this check, not yet confirmed complete -- "
464+
f"use `python -m auth.publish_tiktok --check-status {publish_id}` to re-check later "
465+
"rather than assuming success or retrying."
466+
),
422467
}
423468

424469

tests/test_publish_tiktok.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,72 @@ def fake_urlopen(req, timeout=30):
261261
assert "Settings and privacy" in message
262262

263263

264+
def _mock_full_publish_path(monkeypatch, tmp_path, status_data, status_error=None):
265+
import auth.publish_tiktok as tiktok_module
266+
"""Mock every step of a real publish attempt (load/refresh token, init, upload, status
267+
fetch) so publish_tiktok() can be exercised end to end with confirm_publish=True, entirely
268+
offline -- no network, no credentials, no TikTok account involved."""
269+
video = tmp_path / "clip.mp4"
270+
video.write_bytes(b"not a real video, just needs to exist")
271+
272+
monkeypatch.setattr("auth.publish_tiktok._load_token", lambda: {"access_token": "tok"})
273+
monkeypatch.setattr("auth.publish_tiktok._refresh_token_if_needed", lambda t: t)
274+
monkeypatch.setattr("auth.publish_tiktok._upload_video_chunks", lambda *a, **k: None)
275+
276+
init_response = {
277+
"error": {"code": "ok"},
278+
"data": {"publish_id": "pub123", "upload_url": "https://upload.example/"},
279+
}
280+
status_response = {"error": status_error or {"code": "ok"}, "data": status_data}
281+
282+
def fake_api_post_json(url, access_token, body):
283+
if url == tiktok_module.VIDEO_INIT_URL:
284+
return init_response
285+
assert url == tiktok_module.STATUS_FETCH_URL
286+
return status_response
287+
288+
monkeypatch.setattr("auth.publish_tiktok._api_post_json", fake_api_post_json)
289+
return video
290+
291+
292+
def test_real_publish_raises_on_terminal_failed_status(monkeypatch, tmp_path):
293+
"""Regression test for a Codex-reported bug found before any live test: publish_tiktok()
294+
used to always return "status": "uploaded" regardless of what the post-upload status check
295+
actually said -- reporting a rejected post as if it succeeded unless the caller happened to
296+
manually inspect the nested status_response. A terminal FAILED status (TikTok's own
297+
documented value, with a fail_reason field) must raise, not silently succeed."""
298+
video = _mock_full_publish_path(
299+
monkeypatch, tmp_path, status_data={"status": "FAILED", "fail_reason": "video_format_check_failed"}
300+
)
301+
with pytest.raises(RuntimeError, match="video_format_check_failed"):
302+
publish_tiktok(str(video), title="t", confirm_publish=True)
303+
304+
305+
def test_real_publish_reports_still_processing_honestly(monkeypatch, tmp_path):
306+
"""A status short of PUBLISH_COMPLETE (e.g. still PROCESSING_UPLOAD moments after the upload
307+
finished) must not be reported as unconditionally "uploaded"/done -- the caller needs to know
308+
to check again later via --check-status, not assume success."""
309+
video = _mock_full_publish_path(monkeypatch, tmp_path, status_data={"status": "PROCESSING_UPLOAD"})
310+
result = publish_tiktok(str(video), title="t", confirm_publish=True)
311+
assert result["status"] == "processing"
312+
assert "PROCESSING_UPLOAD" in result["note"]
313+
assert "--check-status" in result["note"]
314+
315+
316+
def test_real_publish_reports_success_on_publish_complete(monkeypatch, tmp_path):
317+
video = _mock_full_publish_path(monkeypatch, tmp_path, status_data={"status": "PUBLISH_COMPLETE"})
318+
result = publish_tiktok(str(video), title="t", confirm_publish=True)
319+
assert result["status"] == "published"
320+
321+
322+
def test_real_publish_raises_on_status_check_api_error(monkeypatch, tmp_path):
323+
video = _mock_full_publish_path(
324+
monkeypatch, tmp_path, status_data={}, status_error={"code": "internal_error", "message": "..."}
325+
)
326+
with pytest.raises(RuntimeError, match="status check failed"):
327+
publish_tiktok(str(video), title="t", confirm_publish=True)
328+
329+
264330
def test_check_publish_status_wires_token_and_publish_id_through(monkeypatch):
265331
"""Regression test for a real publish_id that came back 'PROCESSING_UPLOAD' right after
266332
upload (a transient state) and never actually appeared in the TikTok app -- the original code

0 commit comments

Comments
 (0)