Skip to content

Commit 1b22587

Browse files
committed
moving
1 parent 0e36577 commit 1b22587

5 files changed

Lines changed: 202 additions & 336 deletions

File tree

tests/integration/test_actor.py

Lines changed: 94 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
from ._utils import get_random_resource_name, maybe_await
1010
from apify_client._models import (
1111
Actor,
12+
ActorChargeEvent,
1213
ActorShort,
1314
Build,
15+
FlatPricePerMonthActorPricingInfo,
1416
ListOfActors,
1517
ListOfWebhooks,
1618
PayPerEventActorPricingInfo,
@@ -22,6 +24,11 @@
2224
if TYPE_CHECKING:
2325
from apify_client import ApifyClient, ApifyClientAsync
2426

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

2633
async def test_get_public_actor(client: ApifyClient | ApifyClientAsync) -> None:
2734
"""Test getting a public Actor by ID."""
@@ -368,40 +375,95 @@ async def test_actor_default_build_wait_for_finish(client: ApifyClient | ApifyCl
368375
assert isinstance(build, Build)
369376

370377

371-
async def test_get_actor_with_tiered_pricing(client: ApifyClient | ApifyClientAsync) -> None:
372-
"""Regression test for apify/apify-client-python#811.
378+
async def test_actor_get_parses_tiered_price_per_dataset_item(client: ApifyClient | ApifyClientAsync) -> None:
379+
"""Test that actor.get() parses PRICE_PER_DATASET_ITEM entries with tieredPricing.
373380
374-
`apify/facebook-pages-scraper` historically returns `pricingInfos` entries that use tiered
375-
pricing — both `PRICE_PER_DATASET_ITEM` with `tieredPricing` (instead of `pricePerUnitUsd`)
376-
and `PAY_PER_EVENT` with `eventTieredPricingUsd` (instead of `eventPriceUsd`). Earlier
377-
versions of the Pydantic models required the flat-price fields and rejected the response.
381+
Tiered-PPD actors return `tieredPricing: {FREE, BRONZE, SILVER, GOLD, PLATINUM, DIAMOND}`
382+
instead of a flat `pricePerUnitUsd`.
378383
"""
379-
actor = await maybe_await(client.actor('apify/facebook-pages-scraper').get())
384+
actor = await maybe_await(client.actor(ALL_PRICING_VARIANTS_ACTOR).get())
380385
assert isinstance(actor, Actor)
381-
assert actor.pricing_infos is not None
386+
assert actor.pricing_infos
382387

383-
tiered_ppr = [
384-
pi
385-
for pi in actor.pricing_infos
386-
if isinstance(pi, PricePerDatasetItemActorPricingInfo) and pi.tiered_pricing is not None
387-
]
388-
assert tiered_ppr, 'expected at least one PRICE_PER_DATASET_ITEM entry with tiered_pricing'
389-
tiered_pricing = tiered_ppr[0].tiered_pricing
390-
assert tiered_pricing is not None
391-
tiered_entry = next(iter(tiered_pricing.values()))
392-
assert tiered_entry.tiered_price_per_unit_usd >= 0
393-
394-
tiered_ppe_events = [
395-
event
396-
for pi in actor.pricing_infos
397-
if isinstance(pi, PayPerEventActorPricingInfo)
398-
and pi.pricing_per_event is not None
399-
and pi.pricing_per_event.actor_charge_events is not None
400-
for event in pi.pricing_per_event.actor_charge_events.values()
401-
if event.event_tiered_pricing_usd is not None
388+
tiered_ppd_entries = [
389+
info
390+
for info in actor.pricing_infos
391+
if isinstance(info, PricePerDatasetItemActorPricingInfo) and info.tiered_pricing is not None
402392
]
403-
assert tiered_ppe_events, 'expected at least one PAY_PER_EVENT charge event with event_tiered_pricing_usd'
404-
event_tiered_pricing = tiered_ppe_events[0].event_tiered_pricing_usd
405-
assert event_tiered_pricing is not None
406-
tiered_event_entry = next(iter(event_tiered_pricing.values()))
407-
assert tiered_event_entry.tiered_event_price_usd >= 0
393+
assert tiered_ppd_entries, (
394+
f'{ALL_PRICING_VARIANTS_ACTOR} should have at least one tiered PRICE_PER_DATASET_ITEM entry — '
395+
'pick a different actor if pricing changed.'
396+
)
397+
398+
# Fixture-drift guard: tiered pricing is only meaningful when it has more than one tier
399+
# and the tiers actually differ in price. A degenerate single-tier or all-zero payload
400+
# would silently look like flat pricing.
401+
for entry in tiered_ppd_entries:
402+
assert entry.tiered_pricing is not None
403+
assert len(entry.tiered_pricing) >= 2, (
404+
f'{ALL_PRICING_VARIANTS_ACTOR} tiered PPD entry has only {len(entry.tiered_pricing)} tier(s); '
405+
'expected multiple tiers (e.g. FREE/BRONZE/SILVER/GOLD/PLATINUM/DIAMOND).'
406+
)
407+
distinct_prices = {t.tiered_price_per_unit_usd for t in entry.tiered_pricing.values()}
408+
assert len(distinct_prices) >= 2, (
409+
f'{ALL_PRICING_VARIANTS_ACTOR} tiered PPD entry has all-identical prices ({distinct_prices}); '
410+
'tiers should differ.'
411+
)
412+
413+
414+
async def test_actor_get_parses_tiered_pay_per_event(client: ApifyClient | ApifyClientAsync) -> None:
415+
"""Test that actor.get() parses PAY_PER_EVENT events with eventTieredPricingUsd,
416+
isPrimaryEvent and isOneTimeEvent.
417+
418+
Tiered PPE events use `eventTieredPricingUsd` instead of `eventPriceUsd`, plus the
419+
`isPrimaryEvent` / `isOneTimeEvent` flags.
420+
"""
421+
actor = await maybe_await(client.actor(ALL_PRICING_VARIANTS_ACTOR).get())
422+
assert isinstance(actor, Actor)
423+
assert actor.pricing_infos
424+
425+
tiered_ppe_events: list[ActorChargeEvent] = []
426+
for info in actor.pricing_infos:
427+
if not isinstance(info, PayPerEventActorPricingInfo):
428+
continue
429+
events = info.pricing_per_event.actor_charge_events or {}
430+
tiered_ppe_events.extend(event for event in events.values() if event.event_tiered_pricing_usd is not None)
431+
432+
assert tiered_ppe_events, (
433+
f'{ALL_PRICING_VARIANTS_ACTOR} should have at least one tiered PAY_PER_EVENT event — '
434+
'pick a different actor if pricing changed.'
435+
)
436+
# Because every model uses `extra='allow'`, a regenerator that drops either alias would
437+
# silently absorb the JSON key into `model_extra`. Asserting the typed attribute is
438+
# populated catches that drift.
439+
assert any(event.is_primary_event is True for event in tiered_ppe_events), (
440+
f'{ALL_PRICING_VARIANTS_ACTOR}: no tiered PPE event has is_primary_event == True. '
441+
'The isPrimaryEvent alias may have been dropped from the model.'
442+
)
443+
assert any(event.is_one_time_event is not None for event in tiered_ppe_events), (
444+
f'{ALL_PRICING_VARIANTS_ACTOR}: no tiered PPE event has is_one_time_event populated. '
445+
'The isOneTimeEvent alias may have been dropped from the model.'
446+
)
447+
448+
449+
async def test_actor_pricing_infos_includes_expected_variants(client: ApifyClient | ApifyClientAsync) -> None:
450+
"""Test that actor.get() parses pricing_infos for PPE, PPD and FLAT_PRICE_PER_MONTH variants.
451+
452+
Fixture sanity check. If the actor stops publishing one of these variants, the sibling
453+
tiered_* tests no longer exercise their respective code paths and this test fails loudly —
454+
pointing maintainers to pick a different fixture.
455+
"""
456+
actor = await maybe_await(client.actor(ALL_PRICING_VARIANTS_ACTOR).get())
457+
assert isinstance(actor, Actor)
458+
assert actor.pricing_infos
459+
460+
seen = {type(entry) for entry in actor.pricing_infos}
461+
expected = {
462+
PayPerEventActorPricingInfo,
463+
PricePerDatasetItemActorPricingInfo,
464+
FlatPricePerMonthActorPricingInfo,
465+
}
466+
missing = expected - seen
467+
assert not missing, (
468+
f'{ALL_PRICING_VARIANTS_ACTOR} no longer carries pricing_infos for {missing}; pick a different fixture.'
469+
)

tests/integration/test_build.py

Lines changed: 89 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,41 @@
88

99
from ._utils import get_random_resource_name, maybe_await
1010
from apify_client._models import Actor, Build, BuildShort, ListOfBuilds
11-
from apify_client._resource_clients import BuildClient, BuildClientAsync
1211

1312
if TYPE_CHECKING:
1413
from apify_client import ApifyClient, ApifyClientAsync
1514

1615
# Use a public actor that has builds available
1716
HELLO_WORLD_ACTOR = 'apify/hello-world'
1817

18+
# Apify-owned actor whose `latest` build sets `minMemoryMbytes: 128` (well below the spec's
19+
# previously-required minimum of 256). Also publishes `actorDefinition.version: "0.0.1"`,
20+
# exercising the semver-triplet version pattern.
21+
SMALL_MIN_MEMORY_ACTOR = 'apify/instagram-profile-scraper'
22+
23+
# Apify-owned actor whose builds list includes entries with `meta.origin: "CI"`
24+
# from the internal CI pipeline. A deep `desc=True` pagination is needed because
25+
# CI builds are infrequent and rotate out of the most-recent window.
26+
CI_ORIGIN_ACTOR = 'apify/cheerio-scraper'
27+
28+
29+
def _pick_build_id(actor: Actor) -> str:
30+
"""Return a stable `build_id` from `actor.tagged_builds`, preferring the `latest` tag.
31+
32+
Avoids relying on API-side dict ordering (`next(iter(...))` would otherwise pick
33+
whichever tag the API decides to serialize first).
34+
"""
35+
assert actor.tagged_builds, f'{actor.username}/{actor.name} has no tagged builds'
36+
latest = actor.tagged_builds.get('latest')
37+
if latest is not None and latest.build_id is not None:
38+
return latest.build_id
39+
fallback = next(
40+
(info.build_id for info in actor.tagged_builds.values() if info and info.build_id),
41+
None,
42+
)
43+
assert fallback is not None, f'{actor.username}/{actor.name} has no tagged build with a build_id'
44+
return fallback
45+
1946

2047
async def test_build_list_for_actor(client: ApifyClient | ApifyClientAsync) -> None:
2148
"""Test listing builds for a public Actor."""
@@ -190,25 +217,71 @@ async def test_build_delete_and_abort(client: ApifyClient | ApifyClientAsync) ->
190217
await maybe_await(actor_client.delete())
191218

192219

193-
async def test_default_build_with_semver_version(client: ApifyClient | ApifyClientAsync) -> None:
194-
"""Regression test for apify/apify-client-python#811.
220+
async def test_build_get_accepts_small_min_memory_mbytes(client: ApifyClient | ApifyClientAsync) -> None:
221+
"""Test that build.get() parses actorDefinition.minMemoryMbytes values below 256 MB.
195222
196-
`apify/facebook-pages-scraper` declares its actor version as `0.0.1`, a three-segment
197-
SemVer string. The earlier OpenAPI pattern `^[0-9]+\\.[0-9]+$` rejected anything beyond
198-
two segments, so simply fetching the default build raised a Pydantic validation error.
223+
The platform's real minimum is 128 MB and many community actors use exactly that.
224+
The symmetric `maxMemoryMbytes < 256` case has no stable fixture, so the `max` side
225+
is implicitly trusted to share fate with `min`.
199226
"""
200-
actor = client.actor('apify/facebook-pages-scraper')
201-
build_client = await maybe_await(actor.default_build())
202-
assert isinstance(build_client, BuildClient | BuildClientAsync)
203-
build = await maybe_await(build_client.get())
227+
actor = await maybe_await(client.actor(SMALL_MIN_MEMORY_ACTOR).get())
228+
assert isinstance(actor, Actor)
229+
build_id = _pick_build_id(actor)
230+
231+
build = await maybe_await(client.build(build_id).get())
232+
assert isinstance(build, Build)
233+
assert build.actor_definition is not None, 'expected actorDefinition on a SUCCEEDED build'
234+
235+
# Fixture-drift guard: only meaningful if the chosen build actually carries a value
236+
# below the old 256 threshold.
237+
actual_min = build.actor_definition.min_memory_mbytes
238+
assert actual_min is not None
239+
assert actual_min < 256, (
240+
f'{SMALL_MIN_MEMORY_ACTOR} latest build has min_memory_mbytes={actual_min!r} '
241+
'(expected <256). Pick a different fixture to keep this test meaningful.'
242+
)
243+
244+
245+
async def test_actor_builds_list_accepts_ci_origin(client: ApifyClient | ApifyClientAsync) -> None:
246+
"""Test that actor.builds().list() parses builds with meta.origin == 'CI'.
247+
248+
Apify-owned actors are routinely rebuilt by the internal CI pipeline. A deep `desc=True`
249+
page (limit=100) is required because CI builds are rare relative to WEB/CLI builds.
250+
"""
251+
builds = await maybe_await(client.actor(CI_ORIGIN_ACTOR).builds().list(limit=100, desc=True))
252+
assert isinstance(builds, ListOfBuilds)
253+
assert builds.items, f'{CI_ORIGIN_ACTOR} should have builds'
254+
255+
# Fixture-drift guard: only meaningful if the page actually contains a CI-origin build.
256+
# Pydantic already validated every `meta.origin` against `RunOrigin` at deserialization,
257+
# so the check is exercised iff at least one such entry exists.
258+
ci_origin_builds = [b for b in builds.items if b.meta is not None and b.meta.origin == 'CI']
259+
assert ci_origin_builds, (
260+
f'{CI_ORIGIN_ACTOR}: no builds with meta.origin == "CI" in the most-recent 100. '
261+
'CI builds may have rotated out of the window — pick a different actor or paginate deeper.'
262+
)
263+
264+
265+
async def test_actor_definition_version_accepts_semver_triplet(client: ApifyClient | ApifyClientAsync) -> None:
266+
"""Test that ActorDefinition.version accepts semver-triplet strings like '0.0.1'.
267+
268+
`apify/instagram-profile-scraper` publishes its actor.json with `version: "0.0.1"`.
269+
"""
270+
actor = await maybe_await(client.actor(SMALL_MIN_MEMORY_ACTOR).get())
271+
assert isinstance(actor, Actor)
272+
build_id = _pick_build_id(actor)
273+
274+
build = await maybe_await(client.build(build_id).get())
204275
assert isinstance(build, Build)
205276
assert build.actor_definition is not None
206-
assert build.actor_definition.version is not None
207-
# The fix must accept any multi-segment dot-separated numeric version
208-
# (e.g. "0.1", "1.0", "0.0.1").
209-
segments = build.actor_definition.version.split('.')
210-
assert len(segments) >= 2
211-
assert all(seg.isdigit() for seg in segments)
277+
# Fixture-drift guard: only meaningful if the chosen build's version actually carries
278+
# more than one dot.
279+
version = build.actor_definition.version
280+
assert version is not None
281+
assert version.count('.') >= 2, (
282+
f'{SMALL_MIN_MEMORY_ACTOR} no longer publishes a multi-dot version (got {version!r}) — '
283+
'pick a different fixture to keep this test meaningful.'
284+
)
212285

213286

214287
async def test_build_get_open_api_definition(client: ApifyClient | ApifyClientAsync) -> None:

0 commit comments

Comments
 (0)