Skip to content

Commit 45a00c2

Browse files
committed
test: Add more tests
1 parent 50a588e commit 45a00c2

14 files changed

Lines changed: 1724 additions & 51 deletions

tests/integration/test_actor.py

Lines changed: 258 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,32 @@
22

33
from __future__ import annotations
44

5-
from datetime import UTC, datetime
5+
from collections.abc import AsyncIterator, Iterator
6+
from datetime import UTC, datetime, timedelta
67
from typing import TYPE_CHECKING
78

89
from ._utils import get_random_resource_name, maybe_await
9-
from apify_client._models import Actor, Build, ListOfActors, Run
10+
from apify_client._models import (
11+
Actor,
12+
ActorChargeEvent,
13+
ActorShort,
14+
Build,
15+
ListOfActors,
16+
ListOfWebhooks,
17+
PayPerEventActorPricingInfo,
18+
PricePerDatasetItemActorPricingInfo,
19+
Run,
20+
)
1021
from apify_client._resource_clients import BuildClient, BuildClientAsync
1122

1223
if TYPE_CHECKING:
1324
from apify_client import ApifyClient, ApifyClientAsync
1425

26+
# Actor carrying pricing-info entries for every non-trivial variant: FLAT_PRICE_PER_MONTH,
27+
# both flat and tiered PRICE_PER_DATASET_ITEM, and tiered PAY_PER_EVENT with
28+
# `isPrimaryEvent` / `isOneTimeEvent` fields.
29+
ALL_PRICING_VARIANTS_ACTOR = 'apify/facebook-pages-scraper'
30+
1531

1632
async def test_get_public_actor(client: ApifyClient | ApifyClientAsync) -> None:
1733
"""Test getting a public Actor by ID."""
@@ -172,3 +188,243 @@ async def test_actor_validate_input(client: ApifyClient | ApifyClientAsync) -> N
172188
# Valid input (hello-world accepts empty input or simple input)
173189
is_valid = await maybe_await(actor_client.validate_input({}))
174190
assert is_valid is True
191+
192+
193+
async def test_get_nonexistent_actor_returns_none(client: ApifyClient | ApifyClientAsync) -> None:
194+
"""Test that getting a non-existent Actor returns None."""
195+
actor = await maybe_await(client.actor('this-actor/does-not-exist-anywhere').get())
196+
assert actor is None
197+
198+
199+
async def test_list_actors_desc_ascending(client: ApifyClient | ApifyClientAsync) -> None:
200+
"""Test listing Actors sorted ascending (desc=False)."""
201+
actors_page = await maybe_await(client.actors().list(limit=10, desc=False, sort_by='stats.lastRunStartedAt'))
202+
assert isinstance(actors_page, ListOfActors)
203+
assert actors_page.items is not None
204+
205+
# The API and Python may break ties on identical timestamps differently, so just verify the
206+
# sort key is monotonically non-decreasing rather than comparing to a locally re-sorted list.
207+
min_dt = datetime.min.replace(tzinfo=UTC)
208+
keys = [(a.stats.last_run_started_at if a.stats else None) or min_dt for a in actors_page.items]
209+
assert keys == sorted(keys)
210+
211+
212+
async def test_actors_iterate(client: ApifyClient | ApifyClientAsync, *, is_async: bool) -> None:
213+
"""Test paginated iteration over user's Actors."""
214+
iterator = client.actors().iterate(my=True, limit=10)
215+
collected: list[ActorShort] = []
216+
if is_async:
217+
assert isinstance(iterator, AsyncIterator)
218+
async for a in iterator:
219+
assert isinstance(a, ActorShort)
220+
collected.append(a)
221+
else:
222+
assert isinstance(iterator, Iterator)
223+
for a in iterator:
224+
assert isinstance(a, ActorShort)
225+
collected.append(a)
226+
227+
assert len(collected) <= 10
228+
for actor in collected:
229+
assert actor.id is not None
230+
231+
232+
async def test_actor_start_with_options(client: ApifyClient | ApifyClientAsync) -> None:
233+
"""Test starting an Actor with explicit run options."""
234+
actor_client = client.actor('apify/hello-world')
235+
236+
# Start the run with explicit build tag, memory, and timeout overrides
237+
run = await maybe_await(
238+
actor_client.start(
239+
build='latest',
240+
memory_mbytes=256,
241+
run_timeout=timedelta(seconds=120),
242+
wait_for_finish=60,
243+
)
244+
)
245+
assert isinstance(run, Run)
246+
assert run.id is not None
247+
assert run.options is not None
248+
assert run.options.memory_mbytes == 256
249+
assert run.options.timeout_secs == 120
250+
251+
try:
252+
# Any terminal-or-in-progress status returned by the platform is acceptable here —
253+
# under load the run can briefly land in `TIMING-OUT`, `FAILED`, `ABORTING`, or `ABORTED`
254+
# without indicating a client-side bug.
255+
assert run.status in (
256+
'READY',
257+
'RUNNING',
258+
'SUCCEEDED',
259+
'TIMED-OUT',
260+
'TIMING-OUT',
261+
'FAILED',
262+
'ABORTING',
263+
'ABORTED',
264+
)
265+
finally:
266+
# Wait for run to finish before cleanup
267+
await maybe_await(client.run(run.id).wait_for_finish())
268+
await maybe_await(client.run(run.id).delete())
269+
270+
271+
async def test_actor_start_with_run_input(client: ApifyClient | ApifyClientAsync) -> None:
272+
"""Test starting an Actor with a JSON run input."""
273+
actor_client = client.actor('apify/hello-world')
274+
275+
# Pass a custom input - hello-world accepts arbitrary input and echoes it in logs
276+
run = await maybe_await(actor_client.start(run_input={'message': 'integration-test-input'}))
277+
assert isinstance(run, Run)
278+
assert run.id is not None
279+
280+
run_client = client.run(run.id)
281+
try:
282+
finished_run = await maybe_await(run_client.wait_for_finish())
283+
assert isinstance(finished_run, Run)
284+
assert finished_run.status == 'SUCCEEDED'
285+
finally:
286+
await maybe_await(run_client.delete())
287+
288+
289+
async def test_actor_call_with_input_and_build(client: ApifyClient | ApifyClientAsync) -> None:
290+
"""Test calling an Actor with input and a specific build tag."""
291+
actor_client = client.actor('apify/hello-world')
292+
293+
run = await maybe_await(
294+
actor_client.call(
295+
run_input={'message': 'integration-test'},
296+
build='latest',
297+
memory_mbytes=256,
298+
)
299+
)
300+
assert isinstance(run, Run)
301+
302+
try:
303+
assert run.status == 'SUCCEEDED'
304+
finally:
305+
await maybe_await(client.run(run.id).delete())
306+
307+
308+
async def test_actor_update_categories(client: ApifyClient | ApifyClientAsync) -> None:
309+
"""Test updating an Actor's categories and seo_title fields."""
310+
actor_name = get_random_resource_name('actor')
311+
312+
created_actor = await maybe_await(
313+
client.actors().create(
314+
name=actor_name,
315+
title='Test Actor for Categories',
316+
)
317+
)
318+
assert isinstance(created_actor, Actor)
319+
actor_client = client.actor(created_actor.id)
320+
321+
try:
322+
updated = await maybe_await(
323+
actor_client.update(
324+
categories=['MARKETING'],
325+
seo_title='SEO Test Title',
326+
seo_description='SEO Test Description',
327+
)
328+
)
329+
assert isinstance(updated, Actor)
330+
# `categories` and `seo_title` are not declared fields on the Actor model but are returned via
331+
# `extra='allow'` so we read them from the dumped representation.
332+
dumped = updated.model_dump(by_alias=True)
333+
assert dumped.get('categories') == ['MARKETING']
334+
assert dumped.get('seoTitle') == 'SEO Test Title'
335+
finally:
336+
await maybe_await(actor_client.delete())
337+
338+
339+
async def test_actor_webhooks(client: ApifyClient | ApifyClientAsync) -> None:
340+
"""Test listing webhooks attached to an Actor."""
341+
actor_name = get_random_resource_name('actor')
342+
343+
created_actor = await maybe_await(
344+
client.actors().create(
345+
name=actor_name,
346+
title='Test Actor for Webhooks',
347+
)
348+
)
349+
assert isinstance(created_actor, Actor)
350+
actor_client = client.actor(created_actor.id)
351+
352+
try:
353+
webhooks_page = await maybe_await(actor_client.webhooks().list())
354+
assert isinstance(webhooks_page, ListOfWebhooks)
355+
# A fresh Actor has no webhooks attached.
356+
assert webhooks_page.items == []
357+
finally:
358+
await maybe_await(actor_client.delete())
359+
360+
361+
async def test_actor_default_build_wait_for_finish(client: ApifyClient | ApifyClientAsync) -> None:
362+
"""Test default_build with explicit wait_for_finish parameter."""
363+
actor_client = client.actor('apify/hello-world')
364+
365+
build_client = await maybe_await(actor_client.default_build(wait_for_finish=1))
366+
assert isinstance(build_client, BuildClient | BuildClientAsync)
367+
build = await maybe_await(build_client.get())
368+
assert isinstance(build, Build)
369+
370+
371+
async def test_actor_get_parses_tiered_price_per_dataset_item(client: ApifyClient | ApifyClientAsync) -> None:
372+
"""Test that actor.get() parses PRICE_PER_DATASET_ITEM entries with tieredPricing."""
373+
actor = await maybe_await(client.actor(ALL_PRICING_VARIANTS_ACTOR).get())
374+
assert isinstance(actor, Actor)
375+
assert actor.pricing_infos
376+
377+
tiered_ppd_entries = [
378+
info
379+
for info in actor.pricing_infos
380+
if isinstance(info, PricePerDatasetItemActorPricingInfo) and info.tiered_pricing is not None
381+
]
382+
assert tiered_ppd_entries, (
383+
f'{ALL_PRICING_VARIANTS_ACTOR} should have at least one tiered PRICE_PER_DATASET_ITEM entry — '
384+
'pick a different actor if pricing changed.'
385+
)
386+
387+
# Fixture-drift guard: tiered pricing is only meaningful when it has more than one tier
388+
# and the tiers actually differ in price. A degenerate single-tier or all-zero payload
389+
# would silently look like flat pricing.
390+
for entry in tiered_ppd_entries:
391+
assert entry.tiered_pricing is not None
392+
assert len(entry.tiered_pricing) >= 2, (
393+
f'{ALL_PRICING_VARIANTS_ACTOR} tiered PPD entry has only {len(entry.tiered_pricing)} tier(s); '
394+
'expected multiple tiers (e.g. FREE/BRONZE/SILVER/GOLD/PLATINUM/DIAMOND).'
395+
)
396+
distinct_prices = {t.tiered_price_per_unit_usd for t in entry.tiered_pricing.values()}
397+
assert len(distinct_prices) >= 2, (
398+
f'{ALL_PRICING_VARIANTS_ACTOR} tiered PPD entry has all-identical prices ({distinct_prices}); '
399+
'tiers should differ.'
400+
)
401+
402+
403+
async def test_actor_get_parses_tiered_pay_per_event(client: ApifyClient | ApifyClientAsync) -> None:
404+
"""Test that actor.get() parses tiered PAY_PER_EVENT events with isPrimaryEvent and isOneTimeEvent flags."""
405+
actor = await maybe_await(client.actor(ALL_PRICING_VARIANTS_ACTOR).get())
406+
assert isinstance(actor, Actor)
407+
assert actor.pricing_infos
408+
409+
tiered_ppe_events: list[ActorChargeEvent] = []
410+
for info in actor.pricing_infos:
411+
if not isinstance(info, PayPerEventActorPricingInfo):
412+
continue
413+
events = info.pricing_per_event.actor_charge_events or {}
414+
tiered_ppe_events.extend(event for event in events.values() if event.event_tiered_pricing_usd is not None)
415+
416+
assert tiered_ppe_events, (
417+
f'{ALL_PRICING_VARIANTS_ACTOR} should have at least one tiered PAY_PER_EVENT event — '
418+
'pick a different actor if pricing changed.'
419+
)
420+
# Because every model uses `extra='allow'`, a regenerator that drops either alias would
421+
# silently absorb the JSON key into `model_extra`. Asserting the typed attribute is
422+
# populated catches that drift.
423+
assert any(event.is_primary_event is True for event in tiered_ppe_events), (
424+
f'{ALL_PRICING_VARIANTS_ACTOR}: no tiered PPE event has is_primary_event == True. '
425+
'The isPrimaryEvent alias may have been dropped from the model.'
426+
)
427+
assert any(event.is_one_time_event is not None for event in tiered_ppe_events), (
428+
f'{ALL_PRICING_VARIANTS_ACTOR}: no tiered PPE event has is_one_time_event populated. '
429+
'The isOneTimeEvent alias may have been dropped from the model.'
430+
)

0 commit comments

Comments
 (0)