MIL-003: scraping Hacker News #37
+41
-24
@@ -1,24 +1,41 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from scrape_movies.presentation import cli, soup_steps
|
from scrape_movies.presentation import cli, hacker_news_steps, soup_steps
|
||||||
|
|
||||||
|
|
||||||
def test_main_when_soup_step_is_given_prints_output(
|
def test_main_when_soup_step_is_given_prints_output(
|
||||||
capsys: pytest.CaptureFixture[str],
|
capsys: pytest.CaptureFixture[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
for step in soup_steps.STEPS:
|
for step in soup_steps.STEPS:
|
||||||
assert cli.main(["soup", step]) == 0
|
assert cli.main(["soup", step]) == 0
|
||||||
assert capsys.readouterr().out.strip(), step
|
assert capsys.readouterr().out.strip(), step
|
||||||
|
|
||||||
|
|
||||||
def test_main_when_no_command_prints_help(
|
def test_main_when_no_command_prints_help(
|
||||||
capsys: pytest.CaptureFixture[str],
|
capsys: pytest.CaptureFixture[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
assert cli.main([]) == 0
|
assert cli.main([]) == 0
|
||||||
assert "usage:" in capsys.readouterr().out
|
assert "usage:" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
def test_render_step_when_title_returns_page_title() -> None:
|
def test_render_step_when_title_returns_page_title() -> None:
|
||||||
assert soup_steps.render_step("title") == (
|
assert soup_steps.render_step("title") == (
|
||||||
"soup.title.string -> Angela's Personal Site"
|
"soup.title.string -> Angela's Personal Site"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_when_hacker_news_top_from_fixture_prints_top_story(
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
assert cli.main(["hacker-news", "top", "--source", "fixture"]) == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Mozilla lays off 250 employees" in out
|
||||||
|
assert "1312 points" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_when_every_hacker_news_step_runs_from_fixture_prints_output(
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
for step in hacker_news_steps.STEPS:
|
||||||
|
assert cli.main(["hacker-news", step, "--source", "fixture"]) == 0
|
||||||
|
assert capsys.readouterr().out.strip(), step
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from scrape_movies.constants import HACKER_NEWS_FIXTURE_PATH
|
||||||
|
from scrape_movies.domain.story import Story
|
||||||
|
from scrape_movies.infrastructure import hacker_news
|
||||||
|
from scrape_movies.infrastructure.soup_basics import load_html
|
||||||
|
|
||||||
|
PAGE = """
|
||||||
|
<table><tr class="athing" id="1"><td><span class="titleline">
|
||||||
|
<a href="https://example.com/one">One</a></span></td></tr>
|
||||||
|
<tr><td><span class="score">12 points</span></td></tr>
|
||||||
|
<tr class="athing" id="2"><td><span class="titleline">
|
||||||
|
<a href="https://example.com/job">A job ad</a></span></td></tr>
|
||||||
|
<tr><td>no score here</td></tr></table>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture() -> str:
|
||||||
|
return load_html(HACKER_NEWS_FIXTURE_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_stories_when_page_has_score_reads_integer() -> None:
|
||||||
|
assert hacker_news.parse_stories(PAGE)[0] == Story(
|
||||||
|
"One", "https://example.com/one", 12
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_stories_when_story_has_no_score_uses_zero() -> None:
|
||||||
|
assert hacker_news.parse_stories(PAGE)[1].upvotes == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_stories_when_page_has_no_stories_returns_empty() -> None:
|
||||||
|
assert hacker_news.parse_stories("<p>nothing</p>") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_stories_when_fixture_returns_thirty_stories() -> None:
|
||||||
|
assert len(hacker_news.parse_stories(_fixture())) == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_titles_when_fixture_starts_with_first_headline() -> None:
|
||||||
|
assert hacker_news.get_titles(_fixture())[0] == "Joan Feynman 1927-2020"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_links_when_fixture_returns_one_address_per_story() -> None:
|
||||||
|
links = hacker_news.get_links(_fixture())
|
||||||
|
assert len(links) == 30
|
||||||
|
assert links[0].startswith("https://www.aps.org/")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_upvotes_when_fixture_returns_integers() -> None:
|
||||||
|
upvotes = hacker_news.get_upvotes(_fixture())
|
||||||
|
assert upvotes[:2] == [38, 205]
|
||||||
|
assert all(isinstance(points, int) for points in upvotes)
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_story_markup_when_page_has_story_shows_title_row() -> None:
|
||||||
|
assert 'class="titleline"' in hacker_news.first_story_markup(PAGE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_story_markup_when_page_has_no_story_returns_empty() -> None:
|
||||||
|
assert hacker_news.first_story_markup("<p>x</p>") == ""
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from scrape_movies.constants import REQUEST_TIMEOUT_SECONDS, USER_AGENT
|
||||||
|
from scrape_movies.domain.errors import FetchError
|
||||||
|
from scrape_movies.infrastructure import http_client
|
||||||
|
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
def __init__(self, text: str, error: Exception | None = None) -> None:
|
||||||
|
self.text = text
|
||||||
|
self._error = error
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
if self._error is not None:
|
||||||
|
raise self._error
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_html_when_request_succeeds_sends_user_agent_and_timeout(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def fake_get(url: str, **kwargs: Any) -> _Response:
|
||||||
|
calls.append({"url": url, **kwargs})
|
||||||
|
return _Response("<html></html>")
|
||||||
|
|
||||||
|
monkeypatch.setattr(requests, "get", fake_get)
|
||||||
|
assert http_client.fetch_html("http://example.test") == "<html></html>"
|
||||||
|
assert calls == [
|
||||||
|
{
|
||||||
|
"url": "http://example.test",
|
||||||
|
"headers": {"User-Agent": USER_AGENT},
|
||||||
|
"timeout": REQUEST_TIMEOUT_SECONDS,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_html_when_server_errors_raises_fetch_error(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
error = requests.HTTPError("500")
|
||||||
|
monkeypatch.setattr(requests, "get", lambda *a, **k: _Response("", error))
|
||||||
|
with pytest.raises(FetchError) as caught:
|
||||||
|
http_client.fetch_html("http://example.test")
|
||||||
|
assert caught.value.__cause__ is error
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_html_when_connection_fails_raises_fetch_error(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
def fail(*args: Any, **kwargs: Any) -> _Response:
|
||||||
|
raise requests.ConnectionError("down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(requests, "get", fail)
|
||||||
|
with pytest.raises(FetchError):
|
||||||
|
http_client.fetch_html("http://example.test")
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from scrape_movies.domain.story import Story, top_story
|
||||||
|
|
||||||
|
|
||||||
|
def test_top_story_when_scores_differ_returns_highest() -> None:
|
||||||
|
stories = [Story("a", "/a", 5), Story("b", "/b", 50), Story("c", "/c", 7)]
|
||||||
|
assert top_story(stories) == Story("b", "/b", 50)
|
||||||
|
|
||||||
|
|
||||||
|
def test_top_story_when_scores_tie_returns_first() -> None:
|
||||||
|
stories = [Story("a", "/a", 9), Story("b", "/b", 9)]
|
||||||
|
assert top_story(stories) == Story("a", "/a", 9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_top_story_when_no_stories_returns_none() -> None:
|
||||||
|
assert top_story([]) is None
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from scrape_movies.application import use_cases
|
||||||
|
from scrape_movies.domain.story import Story
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch(url: str) -> str:
|
||||||
|
return f"html of {url}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(html: str) -> list[Story]:
|
||||||
|
return [Story(html, "/x", 3), Story("best", "/y", 99)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_stories_when_called_passes_page_through_parser() -> None:
|
||||||
|
stories = use_cases.list_stories(_fetch, _parse, "http://x")
|
||||||
|
assert stories[0].title == "html of http://x"
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_top_story_when_stories_exist_returns_highest() -> None:
|
||||||
|
top = use_cases.find_top_story(_fetch, _parse, "http://x")
|
||||||
|
assert top == Story("best", "/y", 99)
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_top_story_when_page_is_empty_returns_none() -> None:
|
||||||
|
assert use_cases.find_top_story(_fetch, lambda html: [], "http://x") is None
|
||||||
Reference in New Issue
Block a user