From fc5f35db264d3c7945848d669e7fe117bc260f0d Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Fri, 21 Aug 2026 13:34:49 +0200 Subject: [PATCH 1/3] test: cover PPE charging on the Scrapy integration end-to-end --- tests/e2e/test_scrapy/test_ppe_spider.py | 52 ++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tests/e2e/test_scrapy/test_ppe_spider.py diff --git a/tests/e2e/test_scrapy/test_ppe_spider.py b/tests/e2e/test_scrapy/test_ppe_spider.py new file mode 100644 index 000000000..c21218fc1 --- /dev/null +++ b/tests/e2e/test_scrapy/test_ppe_spider.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import TYPE_CHECKING + +from .conftest import get_scrapy_source_files + +if TYPE_CHECKING: + from ..conftest import MakeActorFunction, RunActorFunction + + +async def test_ppe_spider_charges_pushed_items_within_budget( + make_actor: MakeActorFunction, + run_actor: RunActorFunction, +) -> None: + """A pay-per-event Scrapy Actor charges the synthetic dataset-item event and stops pushing at the budget cap.""" + actor = await make_actor( + label='scrapy-ppe', + source_files=get_scrapy_source_files('spider_basic.py', 'BasicSpider'), + additional_requirements=['scrapy>=2.14.0'], + ) + + await actor.update( + pricing_infos=[ + { + 'pricingModel': 'PAY_PER_EVENT', + 'apifyMarginPercentage': 0.0, + 'createdAt': '2024-01-01T00:00:00.000Z', + 'startedAt': '2024-01-01T00:00:00.000Z', + 'pricingPerEvent': { + 'actorChargeEvents': { + 'apify-default-dataset-item': { + 'eventTitle': 'Default dataset item', + 'eventPriceUsd': 0.05, + 'eventDescription': 'One item written to the default dataset', + }, + }, + }, + }, + ] + ) + + # The spider scrapes three products. At $0.05 an item, a $0.125 budget covers only two of them, so the + # third push has to be dropped and never charged. The budget deliberately does not divide evenly into the + # item price - hitting the cap exactly would let the platform auto-abort the run and race the clean exit. + run = await run_actor(actor, max_total_charge_usd=Decimal('0.125')) + + assert run.status == 'SUCCEEDED' + assert run.charged_event_counts == {'apify-default-dataset-item': 2} + + items = await actor.last_run().dataset().list_items() + assert items.count == 2 From 1d5792379e071298a8dad3d9764435c06d134a62 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Fri, 21 Aug 2026 14:09:58 +0200 Subject: [PATCH 2/3] test: poll for synthetic PPE charge propagation in the Scrapy e2e test --- tests/e2e/test_scrapy/test_ppe_spider.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/e2e/test_scrapy/test_ppe_spider.py b/tests/e2e/test_scrapy/test_ppe_spider.py index c21218fc1..73713d387 100644 --- a/tests/e2e/test_scrapy/test_ppe_spider.py +++ b/tests/e2e/test_scrapy/test_ppe_spider.py @@ -3,15 +3,19 @@ from decimal import Decimal from typing import TYPE_CHECKING +from ..._utils import poll_until_condition from .conftest import get_scrapy_source_files if TYPE_CHECKING: + from apify_client import ApifyClientAsync + from ..conftest import MakeActorFunction, RunActorFunction async def test_ppe_spider_charges_pushed_items_within_budget( make_actor: MakeActorFunction, run_actor: RunActorFunction, + apify_client_async: ApifyClientAsync, ) -> None: """A pay-per-event Scrapy Actor charges the synthetic dataset-item event and stops pushing at the budget cap.""" actor = await make_actor( @@ -46,7 +50,20 @@ async def test_ppe_spider_charges_pushed_items_within_budget( run = await run_actor(actor, max_total_charge_usd=Decimal('0.125')) assert run.status == 'SUCCEEDED' - assert run.charged_event_counts == {'apify-default-dataset-item': 2} + + expected_charges = {'apify-default-dataset-item': 2} + + # The SDK only tracks the synthetic event internally - the platform derives the charges from dataset writes + # asynchronously, so they can lag behind the run reaching a terminal status. Refetch until they propagate. + charged_run = await poll_until_condition( + apify_client_async.run(run.id).get, + lambda refetched: refetched is not None and refetched.charged_event_counts == expected_charges, + timeout=120, + poll_interval=1, + ) + + assert charged_run is not None + assert charged_run.charged_event_counts == expected_charges items = await actor.last_run().dataset().list_items() assert items.count == 2 From f6a679446b1351c733e86dbfc8e0fa76501735b8 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Fri, 21 Aug 2026 14:10:12 +0200 Subject: [PATCH 3/3] test: assert the dataset-wide item total in the Scrapy PPE e2e test --- tests/e2e/test_scrapy/test_ppe_spider.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/e2e/test_scrapy/test_ppe_spider.py b/tests/e2e/test_scrapy/test_ppe_spider.py index 73713d387..ecd9c5848 100644 --- a/tests/e2e/test_scrapy/test_ppe_spider.py +++ b/tests/e2e/test_scrapy/test_ppe_spider.py @@ -65,5 +65,7 @@ async def test_ppe_spider_charges_pushed_items_within_budget( assert charged_run is not None assert charged_run.charged_event_counts == expected_charges + # `total` counts the whole dataset, unlike `count`, which only covers the items returned in this page and + # can still lag right after a run finishes. items = await actor.last_run().dataset().list_items() - assert items.count == 2 + assert items.total == 2