feat(tiktok): tiktok.scrape capability + billing wire-up

This commit is contained in:
CREDO23 2026-07-08 18:21:44 +02:00
parent 9dd39faa50
commit 2943d8b23c
14 changed files with 317 additions and 0 deletions

View file

@ -0,0 +1,79 @@
"""``tiktok.scrape`` executor: verb input → actor input mapping → typed items.
Boundary mocked: the proprietary scraper (injected fake). NOT mocked: the verb's
own payloadTikTokScrapeInput mapping and the dictTikTokVideoItem wrapping.
"""
from __future__ import annotations
import pytest
from app.capabilities.tiktok.scrape.executor import build_scrape_executor
from app.capabilities.tiktok.scrape.schemas import ScrapeInput, ScrapeOutput
from app.exceptions import ForbiddenError
from app.proprietary.platforms.tiktok import TikTokAccessBlockedError, TikTokScrapeInput
pytestmark = pytest.mark.unit
class _FakeScraper:
"""Records the actor input + limit it was called with; returns canned items."""
def __init__(self, items: list[dict]):
self._items = items
self.calls: list[tuple[TikTokScrapeInput, int | None]] = []
async def __call__(
self, actor_input: TikTokScrapeInput, *, limit: int | None = None
) -> list[dict]:
self.calls.append((actor_input, limit))
return self._items
async def test_maps_urls_to_start_urls_and_wraps_items():
scraper = _FakeScraper([{"id": "123", "text": "hello"}])
execute = build_scrape_executor(scrape_fn=scraper)
out = await execute(ScrapeInput(urls=["https://www.tiktok.com/@nasa/video/123"]))
assert isinstance(out, ScrapeOutput)
assert len(out.items) == 1
assert out.items[0].id == "123"
(actor_input, _limit) = scraper.calls[0]
assert [u.url for u in actor_input.startUrls] == [
"https://www.tiktok.com/@nasa/video/123"
]
async def test_forwards_typed_sources_and_limit():
scraper = _FakeScraper([])
execute = build_scrape_executor(scrape_fn=scraper)
await execute(
ScrapeInput(
profiles=["nasa"],
hashtags=["food"],
search_queries=["cats"],
results_per_page=7,
max_items=25,
)
)
(actor_input, limit) = scraper.calls[0]
assert actor_input.profiles == ["nasa"]
assert actor_input.hashtags == ["food"]
assert actor_input.searchQueries == ["cats"]
assert actor_input.resultsPerPage == 7
# The outer collection limit is the caller's total-item cap.
assert limit == 25
async def test_access_blocked_maps_to_forbidden():
async def _blocked(actor_input: TikTokScrapeInput, *, limit: int | None = None):
raise TikTokAccessBlockedError("all IPs refused")
execute = build_scrape_executor(scrape_fn=_blocked)
with pytest.raises(ForbiddenError):
await execute(ScrapeInput(hashtags=["food"]))

View file

@ -0,0 +1,45 @@
"""``tiktok.scrape`` input guards: a source is required and the batch is bounded."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.capabilities.tiktok.scrape.schemas import (
MAX_TIKTOK_ITEMS,
MAX_TIKTOK_SOURCES,
ScrapeInput,
)
pytestmark = pytest.mark.unit
def test_rejects_input_with_no_source():
with pytest.raises(ValidationError):
ScrapeInput()
def test_accepts_urls_only():
payload = ScrapeInput(urls=["https://www.tiktok.com/@nasa"])
assert payload.profiles == []
def test_accepts_hashtags_only():
payload = ScrapeInput(hashtags=["food"])
assert payload.hashtags == ["food"]
def test_defaults_and_bounds():
payload = ScrapeInput(hashtags=["food"])
assert payload.max_items == 10
assert payload.results_per_page == 10
with pytest.raises(ValidationError):
ScrapeInput(hashtags=["food"], max_items=0)
with pytest.raises(ValidationError):
ScrapeInput(hashtags=["food"], max_items=MAX_TIKTOK_ITEMS + 1)
def test_rejects_more_sources_than_the_cap():
too_many = [f"tag{i}" for i in range(MAX_TIKTOK_SOURCES + 1)]
with pytest.raises(ValidationError):
ScrapeInput(hashtags=too_many)

View file

@ -0,0 +1,23 @@
"""The tiktok namespace registers its verb as one Capability the doors/agent read."""
from __future__ import annotations
import pytest
from app.capabilities import (
tiktok, # noqa: F401 — importing the namespace registers its verbs
)
from app.capabilities.core import BillingUnit
from app.capabilities.core.store import get_capability
from app.capabilities.tiktok.scrape.schemas import ScrapeInput, ScrapeOutput
pytestmark = pytest.mark.unit
def test_tiktok_scrape_is_registered_and_billed_per_video():
cap = get_capability("tiktok.scrape")
assert cap.name == "tiktok.scrape"
assert cap.input_schema is ScrapeInput
assert cap.output_schema is ScrapeOutput
assert cap.billing_unit is BillingUnit.TIKTOK_VIDEO