Skip to content

Commit 7e5b768

Browse files
vdusekclaude
andcommitted
test: add unit tests for coverage gaps in actor, storage clients, and utilities
Add 43 new unit tests targeting uncovered branches and code paths: - 14 tests for _actor.py (webhooks, timeouts, push_data, event listeners, abort, remaining_time) - 16 tests for storage clients (API client creation validation, KVS operations, dataset operations) - 5 tests for _utils.py (IPython detection, enum extraction) - 3 tests for _crypto.py (invalid key types, non-dict input) - 3 tests for _proxy_configuration.py (unparseable URL, min_length, apify domain warning) - 2 tests for log.py (DEBUG/INFO level configuration) Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 2b54d26 commit 7e5b768

File tree

8 files changed

+531
-0
lines changed

8 files changed

+531
-0
lines changed

tests/unit/actor/test_actor_helpers.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
from __future__ import annotations
22

3+
import warnings
4+
from datetime import timedelta
35
from typing import TYPE_CHECKING
46

57
import pytest
68

79
from apify_client import ApifyClientAsync
810
from apify_shared.consts import ApifyEnvVars, WebhookEventType
11+
from crawlee.events._types import Event
912

1013
from apify import Actor, Webhook
1114
from apify._actor import _ActorType
@@ -174,3 +177,179 @@ async def test_set_terminal_status_message_locally(caplog: pytest.LogCaptureFixt
174177
assert len(matching_records) == 1
175178
assert matching_records[0].levelname == 'INFO'
176179
assert '[Terminal status message]: test-terminal-message' in matching_records[0].message
180+
181+
182+
async def test_push_data_with_empty_data() -> None:
183+
"""Test that push_data returns None when data is empty."""
184+
async with Actor:
185+
result = await Actor.push_data([])
186+
assert result is None
187+
188+
result = await Actor.push_data({})
189+
assert result is None
190+
191+
192+
async def test_off_removes_event_listener() -> None:
193+
"""Test that Actor.off() removes an event listener."""
194+
called = False
195+
196+
async def listener(_data: object) -> None:
197+
nonlocal called
198+
called = True
199+
200+
async with Actor:
201+
Actor.on(Event.PERSIST_STATE, listener)
202+
Actor.off(Event.PERSIST_STATE, listener)
203+
204+
205+
async def test_start_actor_with_webhooks(
206+
apify_client_async_patcher: ApifyClientAsyncPatcher, fake_actor_run: dict
207+
) -> None:
208+
"""Test that start() correctly serializes webhooks."""
209+
apify_client_async_patcher.patch('actor', 'start', return_value=fake_actor_run)
210+
211+
async with Actor:
212+
await Actor.start(
213+
'some-actor-id',
214+
webhooks=[Webhook(event_types=[WebhookEventType.ACTOR_RUN_SUCCEEDED], request_url='https://example.com')],
215+
)
216+
217+
assert len(apify_client_async_patcher.calls['actor']['start']) == 1
218+
219+
220+
async def test_start_actor_with_timedelta_timeout(
221+
apify_client_async_patcher: ApifyClientAsyncPatcher, fake_actor_run: dict
222+
) -> None:
223+
"""Test that start() accepts a timedelta timeout."""
224+
apify_client_async_patcher.patch('actor', 'start', return_value=fake_actor_run)
225+
226+
async with Actor:
227+
await Actor.start('some-actor-id', timeout=timedelta(seconds=120))
228+
229+
assert len(apify_client_async_patcher.calls['actor']['start']) == 1
230+
231+
232+
async def test_start_actor_with_invalid_timeout(
233+
apify_client_async_patcher: ApifyClientAsyncPatcher, fake_actor_run: dict
234+
) -> None:
235+
"""Test that start() raises ValueError for invalid timeout."""
236+
apify_client_async_patcher.patch('actor', 'start', return_value=fake_actor_run)
237+
238+
async with Actor:
239+
with pytest.raises(ValueError, match='Invalid timeout'):
240+
await Actor.start('some-actor-id', timeout='invalid') # type: ignore[arg-type]
241+
242+
243+
async def test_call_actor_with_webhooks(
244+
apify_client_async_patcher: ApifyClientAsyncPatcher, fake_actor_run: dict
245+
) -> None:
246+
"""Test that call() correctly serializes webhooks."""
247+
apify_client_async_patcher.patch('actor', 'call', return_value=fake_actor_run)
248+
249+
async with Actor:
250+
await Actor.call(
251+
'some-actor-id',
252+
webhooks=[Webhook(event_types=[WebhookEventType.ACTOR_RUN_SUCCEEDED], request_url='https://example.com')],
253+
)
254+
255+
assert len(apify_client_async_patcher.calls['actor']['call']) == 1
256+
257+
258+
async def test_call_actor_with_timedelta_timeout(
259+
apify_client_async_patcher: ApifyClientAsyncPatcher, fake_actor_run: dict
260+
) -> None:
261+
"""Test that call() accepts a timedelta timeout."""
262+
apify_client_async_patcher.patch('actor', 'call', return_value=fake_actor_run)
263+
264+
async with Actor:
265+
await Actor.call('some-actor-id', timeout=timedelta(seconds=120))
266+
267+
assert len(apify_client_async_patcher.calls['actor']['call']) == 1
268+
269+
270+
async def test_call_actor_with_remaining_time_deprecation(
271+
apify_client_async_patcher: ApifyClientAsyncPatcher, fake_actor_run: dict
272+
) -> None:
273+
"""Test that call() with RemainingTime emits deprecation warning."""
274+
apify_client_async_patcher.patch('actor', 'call', return_value=fake_actor_run)
275+
276+
async with Actor:
277+
with warnings.catch_warnings(record=True) as w:
278+
warnings.simplefilter('always')
279+
await Actor.call('some-actor-id', timeout='RemainingTime')
280+
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
281+
assert len(deprecation_warnings) == 1
282+
assert 'RemainingTime' in str(deprecation_warnings[0].message)
283+
284+
285+
async def test_call_actor_with_invalid_timeout(
286+
apify_client_async_patcher: ApifyClientAsyncPatcher, fake_actor_run: dict
287+
) -> None:
288+
"""Test that call() raises ValueError for invalid timeout."""
289+
apify_client_async_patcher.patch('actor', 'call', return_value=fake_actor_run)
290+
291+
async with Actor:
292+
with pytest.raises(ValueError, match='Invalid timeout'):
293+
await Actor.call('some-actor-id', timeout='invalid') # type: ignore[arg-type]
294+
295+
296+
async def test_call_task_with_webhooks(
297+
apify_client_async_patcher: ApifyClientAsyncPatcher, fake_actor_run: dict
298+
) -> None:
299+
"""Test that call_task() correctly serializes webhooks."""
300+
apify_client_async_patcher.patch('task', 'call', return_value=fake_actor_run)
301+
302+
async with Actor:
303+
await Actor.call_task(
304+
'some-task-id',
305+
webhooks=[Webhook(event_types=[WebhookEventType.ACTOR_RUN_SUCCEEDED], request_url='https://example.com')],
306+
)
307+
308+
assert len(apify_client_async_patcher.calls['task']['call']) == 1
309+
310+
311+
async def test_call_task_with_timedelta_timeout(
312+
apify_client_async_patcher: ApifyClientAsyncPatcher, fake_actor_run: dict
313+
) -> None:
314+
"""Test that call_task() accepts a timedelta timeout."""
315+
apify_client_async_patcher.patch('task', 'call', return_value=fake_actor_run)
316+
317+
async with Actor:
318+
await Actor.call_task('some-task-id', timeout=timedelta(seconds=120))
319+
320+
assert len(apify_client_async_patcher.calls['task']['call']) == 1
321+
322+
323+
async def test_call_task_with_invalid_timeout(
324+
apify_client_async_patcher: ApifyClientAsyncPatcher, fake_actor_run: dict
325+
) -> None:
326+
"""Test that call_task() raises ValueError for invalid timeout."""
327+
apify_client_async_patcher.patch('task', 'call', return_value=fake_actor_run)
328+
329+
async with Actor:
330+
with pytest.raises(ValueError, match='Invalid timeout'):
331+
await Actor.call_task('some-task-id', timeout='invalid') # type: ignore[arg-type]
332+
333+
334+
async def test_abort_with_status_message(
335+
apify_client_async_patcher: ApifyClientAsyncPatcher, fake_actor_run: dict
336+
) -> None:
337+
"""Test that abort() updates status message before aborting."""
338+
apify_client_async_patcher.patch('run', 'update', return_value=fake_actor_run)
339+
apify_client_async_patcher.patch('run', 'abort', return_value=fake_actor_run)
340+
341+
async with Actor:
342+
await Actor.abort('run-id', status_message='Aborting due to error')
343+
344+
assert len(apify_client_async_patcher.calls['run']['update']) == 1
345+
assert len(apify_client_async_patcher.calls['run']['abort']) == 1
346+
347+
348+
async def test_get_remaining_time_warns_when_not_at_home(caplog: pytest.LogCaptureFixture) -> None:
349+
"""Test that _get_remaining_time logs warning when not at home."""
350+
caplog.set_level('WARNING')
351+
async with Actor:
352+
# Actor is not at home, so _get_remaining_time should return None and log warning
353+
result = Actor._get_remaining_time()
354+
assert result is None
355+
assert any('inherit' in msg or 'RemainingTime' in msg for msg in caplog.messages)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
from apify._configuration import Configuration
6+
from apify.storage_clients._apify._api_client_creation import _create_api_client, create_storage_api_client
7+
8+
9+
def test_create_api_client_without_token() -> None:
10+
"""Test that _create_api_client raises ValueError when no token is set."""
11+
config = Configuration(token=None)
12+
with pytest.raises(ValueError, match='requires a valid token'):
13+
_create_api_client(config)
14+
15+
16+
def test_create_api_client_without_api_url() -> None:
17+
"""Test that _create_api_client raises ValueError when API URL is empty."""
18+
config = Configuration(token='test-token')
19+
# Force the api_base_url to be empty
20+
object.__setattr__(config, 'api_base_url', '')
21+
with pytest.raises(ValueError, match='requires a valid API URL'):
22+
_create_api_client(config)
23+
24+
25+
def test_create_api_client_without_public_api_url() -> None:
26+
"""Test that _create_api_client raises ValueError when public API URL is empty."""
27+
config = Configuration(token='test-token')
28+
object.__setattr__(config, 'api_public_base_url', '')
29+
with pytest.raises(ValueError, match='requires a valid API public base URL'):
30+
_create_api_client(config)
31+
32+
33+
async def test_create_storage_multiple_identifiers() -> None:
34+
"""Test that create_storage_api_client raises ValueError for multiple identifiers."""
35+
config = Configuration(token='test-token')
36+
with pytest.raises(ValueError, match='Only one of'):
37+
await create_storage_api_client(
38+
storage_type='Dataset',
39+
configuration=config,
40+
id='some-id',
41+
name='some-name',
42+
)
43+
44+
45+
async def test_create_storage_unknown_type() -> None:
46+
"""Test that create_storage_api_client raises ValueError for unknown storage type."""
47+
config = Configuration(token='test-token')
48+
with pytest.raises(ValueError, match='Unknown storage type'):
49+
await create_storage_api_client( # ty: ignore[no-matching-overload]
50+
storage_type='UnknownType',
51+
configuration=config,
52+
)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from unittest.mock import AsyncMock
5+
6+
import pytest
7+
8+
from apify.storage_clients._apify._dataset_client import ApifyDatasetClient
9+
10+
11+
def _make_dataset_client() -> ApifyDatasetClient:
12+
"""Create an ApifyDatasetClient with a mocked API client."""
13+
api_client = AsyncMock()
14+
return ApifyDatasetClient(api_client=api_client, api_public_base_url='', lock=asyncio.Lock())
15+
16+
17+
async def test_purge_raises_not_implemented() -> None:
18+
"""Test that purge() raises NotImplementedError."""
19+
client = _make_dataset_client()
20+
with pytest.raises(NotImplementedError, match='Purging datasets is not supported'):
21+
await client.purge()
22+
23+
24+
async def test_drop_calls_api_delete() -> None:
25+
"""Test that drop() delegates to the API client."""
26+
client = _make_dataset_client()
27+
await client.drop()
28+
client._api_client.delete.assert_awaited_once() # ty: ignore[possibly-missing-attribute]
29+
30+
31+
async def test_deprecated_api_public_base_url() -> None:
32+
"""Test that passing api_public_base_url triggers deprecation warning."""
33+
api_client = AsyncMock()
34+
with pytest.warns(DeprecationWarning, match='api_public_base_url argument is deprecated'):
35+
ApifyDatasetClient(
36+
api_client=api_client,
37+
api_public_base_url='https://api.apify.com',
38+
lock=asyncio.Lock(),
39+
)

0 commit comments

Comments
 (0)