|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import logging |
| 4 | +from typing import cast |
| 5 | +from unittest import mock |
| 6 | + |
| 7 | +import pytest |
| 8 | +from scrapy import Request, Spider |
| 9 | + |
| 10 | +from apify import Request as ApifyRequest |
| 11 | +from apify.scrapy.scheduler import ApifyScheduler |
| 12 | +from apify.storages import RequestQueue |
| 13 | + |
| 14 | + |
| 15 | +class DummySpider(Spider): |
| 16 | + name = 'dummy_spider' |
| 17 | + |
| 18 | + |
| 19 | +@pytest.fixture |
| 20 | +def spider() -> DummySpider: |
| 21 | + """Fixture to create a "dummy" Scrapy spider.""" |
| 22 | + return DummySpider() |
| 23 | + |
| 24 | + |
| 25 | +@pytest.fixture |
| 26 | +def scheduler(monkeypatch: pytest.MonkeyPatch, spider: DummySpider) -> ApifyScheduler: |
| 27 | + """Create a scheduler with its reactor check and async thread stubbed out. |
| 28 | +
|
| 29 | + The request queue is a plain mock that satisfies the `isinstance` checks; the `run_coro` results |
| 30 | + are set per test via the mocked async thread. |
| 31 | + """ |
| 32 | + monkeypatch.setattr('apify.scrapy.scheduler.is_asyncio_reactor_installed', lambda: True) |
| 33 | + monkeypatch.setattr('apify.scrapy.scheduler.AsyncThread', mock.MagicMock()) |
| 34 | + |
| 35 | + scheduler = ApifyScheduler() |
| 36 | + scheduler.spider = spider |
| 37 | + |
| 38 | + rq = mock.MagicMock() |
| 39 | + rq.__class__ = RequestQueue |
| 40 | + scheduler._rq = rq |
| 41 | + |
| 42 | + return scheduler |
| 43 | + |
| 44 | + |
| 45 | +def test_next_request_skips_request_that_fails_to_convert( |
| 46 | + scheduler: ApifyScheduler, |
| 47 | + caplog: pytest.LogCaptureFixture, |
| 48 | +) -> None: |
| 49 | + rq = cast('mock.MagicMock', scheduler._rq) |
| 50 | + async_thread = cast('mock.MagicMock', scheduler._async_thread) |
| 51 | + |
| 52 | + # A queue entry whose encoded Scrapy request is malformed; `to_scrapy_request` raises on it. |
| 53 | + malformed_request = ApifyRequest( |
| 54 | + url='https://example.com', |
| 55 | + method='GET', |
| 56 | + unique_key='https://example.com', |
| 57 | + user_data={'scrapy_request': 'this is not a correctly encoded Scrapy request'}, |
| 58 | + ) |
| 59 | + |
| 60 | + # `run_coro` is called for `fetch_next_request`, then for `mark_request_as_handled`. |
| 61 | + async_thread.run_coro.side_effect = [malformed_request, None] |
| 62 | + |
| 63 | + with caplog.at_level(logging.ERROR, logger='apify.scrapy.scheduler'): |
| 64 | + result = scheduler.next_request() |
| 65 | + |
| 66 | + # The malformed request is skipped instead of crashing the whole run. |
| 67 | + assert result is None |
| 68 | + assert 'skipping it' in caplog.text |
| 69 | + |
| 70 | + # It was still marked as handled before the failed conversion, so it is not retried forever. |
| 71 | + rq.mark_request_as_handled.assert_called_once_with(malformed_request) |
| 72 | + |
| 73 | + |
| 74 | +def test_next_request_returns_converted_request(scheduler: ApifyScheduler) -> None: |
| 75 | + rq = cast('mock.MagicMock', scheduler._rq) |
| 76 | + async_thread = cast('mock.MagicMock', scheduler._async_thread) |
| 77 | + |
| 78 | + apify_request = ApifyRequest( |
| 79 | + url='https://example.com', |
| 80 | + method='GET', |
| 81 | + unique_key='https://example.com', |
| 82 | + user_data={}, |
| 83 | + ) |
| 84 | + async_thread.run_coro.side_effect = [apify_request, None] |
| 85 | + |
| 86 | + result = scheduler.next_request() |
| 87 | + |
| 88 | + assert isinstance(result, Request) |
| 89 | + assert result.url == apify_request.url |
| 90 | + rq.mark_request_as_handled.assert_called_once_with(apify_request) |
0 commit comments