Files
roboco/tests/unit/services/test_tiktok_client.py
T

410 lines
15 KiB
Python
Raw Normal View History

"""LiveTikTokPoster coverage: the inbox-upload sequence (init -> chunked PUT
-> status poll), the asymmetric final chunk via `plan_chunk_ranges`, and the
OAuth2 refresh-token rotation + persistence path.
Uses the real `db_session` fixture (mirrors test_video_post_service.py) since
the refresh path genuinely persists to the `tiktok_credentials` row — a mock
session can't stand in for that.
"""
from __future__ import annotations
import json
from itertools import pairwise
from typing import TYPE_CHECKING
import httpx
import pytest
from roboco.services.tiktok_client import (
LiveTikTokPoster,
build_tiktok_poster,
plan_chunk_ranges,
)
from roboco.services.tiktok_credentials import (
TikTokCredentialsData,
get_tiktok_credentials_service,
)
from roboco.services.video_post_service import NullTikTokPoster
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
if TYPE_CHECKING:
from pathlib import Path
TWO = 2
FOUR = 4
_CREDS = TikTokCredentialsData(
client_key="ck-test",
client_secret="cs-test",
access_token="at-test",
refresh_token="rt-test",
)
# ---- plan_chunk_ranges (pure) ---------------------------------------------- #
def test_plan_chunk_ranges_single_chunk_when_within_final_ceiling() -> None:
assert plan_chunk_ranges(50, chunk_size=64, max_final_chunk=128) == [(0, 50)]
def test_plan_chunk_ranges_folds_the_remainder_into_a_larger_final_chunk() -> None:
"""Interior chunks stay a fixed size; the final one absorbs the
remainder — larger than the interior size, the documented asymmetry."""
ranges = plan_chunk_ranges(10, chunk_size=4, max_final_chunk=8)
assert ranges == [(0, 4), (4, 10)]
final_start, final_end = ranges[-1]
assert (final_end - final_start) > FOUR # bigger than the interior chunk size
def test_plan_chunk_ranges_multiple_interior_chunks() -> None:
total = 300
ranges = plan_chunk_ranges(total, chunk_size=64, max_final_chunk=128)
assert ranges == [(0, 64), (64, 128), (128, 192), (192, 300)]
assert sum(end - start for start, end in ranges) == total
def test_plan_chunk_ranges_is_contiguous_with_no_gaps_or_overlaps() -> None:
total = 1000
ranges = plan_chunk_ranges(total, chunk_size=97, max_final_chunk=150)
assert ranges[0][0] == 0
assert ranges[-1][1] == total
for (_, prev_end), (next_start, _) in pairwise(ranges):
assert prev_end == next_start
def test_plan_chunk_ranges_empty_file() -> None:
assert plan_chunk_ranges(0, chunk_size=64, max_final_chunk=128) == [(0, 0)]
# ---- build_tiktok_poster branching ----------------------------------------- #
@pytest.mark.asyncio
async def test_build_without_creds_returns_null(db_session: AsyncSession) -> None:
poster = build_tiktok_poster(None, session=db_session, timeout=5.0)
assert isinstance(poster, NullTikTokPoster)
assert poster.configured is False
@pytest.mark.asyncio
async def test_build_with_creds_returns_live(db_session: AsyncSession) -> None:
poster = build_tiktok_poster(_CREDS, session=db_session, timeout=5.0)
assert isinstance(poster, LiveTikTokPoster)
assert poster.configured is True
@pytest.mark.asyncio
async def test_null_poster_upload_is_a_noop() -> None:
result = await NullTikTokPoster().upload_to_inbox(
mp4_path="/tmp/x.mp4", caption="hi"
)
assert result.uploaded is False
assert result.publish_id is None
def _write_clip(tmp_path: Path, data: bytes) -> str:
mp4 = tmp_path / "clip.mp4"
mp4.write_bytes(data)
return str(mp4)
async def _no_sleep(_seconds: float) -> None:
return None
# ---- upload_to_inbox: full sequence + asymmetric chunk over the wire ------- #
@pytest.mark.asyncio
async def test_upload_to_inbox_full_sequence_with_asymmetric_last_chunk(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, db_session: AsyncSession
) -> None:
monkeypatch.setattr("roboco.services.tiktok_client._CHUNK_SIZE_BYTES", 4)
monkeypatch.setattr("roboco.services.tiktok_client._MAX_FINAL_CHUNK_BYTES", 8)
monkeypatch.setattr("roboco.services.tiktok_client.asyncio.sleep", _no_sleep)
put_ranges: list[str] = []
status_calls = {"count": 0}
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if request.method == "POST" and path.endswith("/inbox/video/init/"):
assert request.headers["Authorization"] == "Bearer at-test"
body = json.loads(request.content)["source_info"]
assert body == {
"source": "FILE_UPLOAD",
"video_size": 10,
"chunk_size": 4,
"total_chunk_count": TWO,
}
return httpx.Response(
200,
json={
"data": {
"publish_id": "pub1",
"upload_url": "https://upload.tiktokapis.com/put/pub1",
}
},
)
if request.method == "PUT":
put_ranges.append(request.headers["Content-Range"])
assert request.headers["Content-Type"] == "video/mp4"
return httpx.Response(200)
if request.method == "POST" and path.endswith("/status/fetch/"):
status_calls["count"] += 1
state = (
"PROCESSING_UPLOAD"
if status_calls["count"] == 1
else "SEND_TO_USER_INBOX"
)
return httpx.Response(200, json={"data": {"status": state}})
raise AssertionError(f"unexpected request {request.method} {path}")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
poster = LiveTikTokPoster(
_CREDS, session=db_session, timeout=5.0, client=http_client
)
result = await poster.upload_to_inbox(
mp4_path=_write_clip(tmp_path, b"0123456789"), caption="ignored by inbox mode"
)
await http_client.aclose()
assert result.uploaded is True
assert result.publish_id == "pub1"
# Interior chunk is 4 bytes; the final chunk absorbs the remainder (6
# bytes) rather than being sent as its own small trailing chunk.
assert put_ranges == ["bytes 0-3/10", "bytes 4-9/10"]
assert status_calls["count"] == TWO # PROCESSING_UPLOAD once, then terminal
@pytest.mark.asyncio
async def test_upload_publish_failed_status_is_graceful(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, db_session: AsyncSession
) -> None:
monkeypatch.setattr("roboco.services.tiktok_client.asyncio.sleep", _no_sleep)
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if path.endswith("/inbox/video/init/"):
return httpx.Response(
200, json={"data": {"publish_id": "pub1", "upload_url": "https://u/x"}}
)
if request.method == "PUT":
return httpx.Response(200)
if path.endswith("/status/fetch/"):
return httpx.Response(200, json={"data": {"status": "FAILED"}})
raise AssertionError(f"unexpected request {path}")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
poster = LiveTikTokPoster(
_CREDS, session=db_session, timeout=5.0, client=http_client
)
result = await poster.upload_to_inbox(
mp4_path=_write_clip(tmp_path, b"short"), caption="x"
)
await http_client.aclose()
assert result.uploaded is False
assert result.publish_id is None
assert "publish failed" in result.detail
# ---- OAuth2 refresh: rotate + persist -------------------------------------- #
@pytest.mark.asyncio
async def test_upload_refreshes_and_persists_rotated_token_on_401(
tmp_path: Path, _test_database_url: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A 401 on the Bearer-authed init call triggers one refresh + retry; the
rotated access/refresh token pair must land in the DB row — not just be
held in the poster's in-memory creds."""
engine = create_async_engine(_test_database_url, future=True)
factory = async_sessionmaker(
bind=engine, class_=AsyncSession, expire_on_commit=False
)
monkeypatch.setattr("roboco.db.base.get_session_factory", lambda: factory)
async with factory() as setup:
await get_tiktok_credentials_service(setup).set_credentials(
client_key=_CREDS.client_key,
client_secret=_CREDS.client_secret,
access_token=_CREDS.access_token,
refresh_token=_CREDS.refresh_token,
)
await setup.commit()
init_auth_headers: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if path.endswith("/oauth/token/"):
form = dict(httpx.QueryParams(request.content.decode()))
assert form["grant_type"] == "refresh_token"
assert form["refresh_token"] == "rt-test"
return httpx.Response(
200, json={"access_token": "at-rotated", "refresh_token": "rt-rotated"}
)
if path.endswith("/inbox/video/init/"):
init_auth_headers.append(request.headers["Authorization"])
if request.headers["Authorization"] == "Bearer at-test":
return httpx.Response(401, json={"error": "access_token_invalid"})
return httpx.Response(
200, json={"data": {"publish_id": "pub2", "upload_url": "https://u/x"}}
)
if request.method == "PUT":
return httpx.Response(200)
if path.endswith("/status/fetch/"):
return httpx.Response(200, json={"data": {"status": "SEND_TO_USER_INBOX"}})
raise AssertionError(f"unexpected request {path}")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
async with factory() as caller:
poster = LiveTikTokPoster(
_CREDS, session=caller, timeout=5.0, client=http_client
)
result = await poster.upload_to_inbox(
mp4_path=_write_clip(tmp_path, b"short"), caption="x"
)
await http_client.aclose()
assert result.uploaded is True
assert init_auth_headers == ["Bearer at-test", "Bearer at-rotated"]
async with factory() as check:
stored = await get_tiktok_credentials_service(check).get_decrypted()
assert stored is not None
assert stored.access_token == "at-rotated"
assert stored.refresh_token == "rt-rotated"
assert stored.client_key == _CREDS.client_key # untouched by the refresh
await engine.dispose()
@pytest.mark.asyncio
async def test_refresh_falls_back_to_current_refresh_token_when_response_omits_it(
tmp_path: Path, _test_database_url: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""TikTok doesn't always rotate the refresh_token — when the grant
response omits it, the previous one must be kept, not nulled out."""
engine = create_async_engine(_test_database_url, future=True)
factory = async_sessionmaker(
bind=engine, class_=AsyncSession, expire_on_commit=False
)
monkeypatch.setattr("roboco.db.base.get_session_factory", lambda: factory)
async with factory() as setup:
await get_tiktok_credentials_service(setup).set_credentials(
client_key=_CREDS.client_key,
client_secret=_CREDS.client_secret,
access_token=_CREDS.access_token,
refresh_token=_CREDS.refresh_token,
)
await setup.commit()
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if path.endswith("/oauth/token/"):
return httpx.Response(200, json={"access_token": "at-rotated"})
if path.endswith("/inbox/video/init/"):
if request.headers["Authorization"] == "Bearer at-test":
return httpx.Response(401, json={"error": "access_token_invalid"})
return httpx.Response(
200, json={"data": {"publish_id": "pub3", "upload_url": "https://u/x"}}
)
if request.method == "PUT":
return httpx.Response(200)
if path.endswith("/status/fetch/"):
return httpx.Response(200, json={"data": {"status": "SEND_TO_USER_INBOX"}})
raise AssertionError(f"unexpected request {path}")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
async with factory() as caller:
poster = LiveTikTokPoster(
_CREDS, session=caller, timeout=5.0, client=http_client
)
result = await poster.upload_to_inbox(
mp4_path=_write_clip(tmp_path, b"x"), caption="x"
)
await http_client.aclose()
assert result.uploaded is True
async with factory() as check:
stored = await get_tiktok_credentials_service(check).get_decrypted()
assert stored is not None
assert stored.access_token == "at-rotated"
assert stored.refresh_token == _CREDS.refresh_token # unchanged, fallback
await engine.dispose()
@pytest.mark.asyncio
async def test_refresh_commits_rotated_tokens_independently_of_caller_session(
_test_database_url: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A lock-loss rollback of the caller's session must not discard the
rotated tokens. TikTok already invalidated the old refresh_token, so a
rollback that drops the rotation is a permanent credential lockout —
`_refresh` must commit in an independent session.
"""
engine = create_async_engine(_test_database_url, future=True)
factory = async_sessionmaker(
bind=engine, class_=AsyncSession, expire_on_commit=False
)
monkeypatch.setattr("roboco.db.base.get_session_factory", lambda: factory)
# Seed the singleton row with refresh_token="OLD" and commit so the fresh
# session opened inside _refresh can see it.
async with factory() as setup:
await get_tiktok_credentials_service(setup).set_credentials(
client_key="ck",
client_secret="cs",
access_token="at-old",
refresh_token="OLD",
)
await setup.commit()
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path.endswith("/oauth/token/")
form = dict(httpx.QueryParams(request.content.decode()))
assert form["grant_type"] == "refresh_token"
assert form["refresh_token"] == "OLD"
return httpx.Response(
200, json={"access_token": "at-new", "refresh_token": "NEW"}
)
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
# Caller session — separate from the fresh session _refresh commits in.
async with factory() as caller:
poster = LiveTikTokPoster(
TikTokCredentialsData(
client_key="ck",
client_secret="cs",
access_token="at-old",
refresh_token="OLD",
),
session=caller,
timeout=5.0,
client=http_client,
)
await poster._refresh(http_client)
# Simulate the lock-loss rollback in _approve_locked.
await caller.rollback()
await http_client.aclose()
# The persisted row must hold NEW, not OLD — the rollback must not have
# discarded the rotation.
async with factory() as check:
stored = await get_tiktok_credentials_service(check).get_decrypted()
assert stored is not None
assert stored.access_token == "at-new"
assert stored.refresh_token == "NEW"
await engine.dispose()