MIL-003: scraping Hacker News #37

Merged
Tirsvad merged 9 commits from mil-003-hacker-news into main 2026-10-28 07:24:45 +01:00
5 changed files with 198 additions and 24 deletions
Showing only changes of commit e52d19a563 - Show all commits
+41 -24
View File
@@ -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
+59
View File
@@ -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>") == ""
+59
View File
@@ -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")
+15
View File
@@ -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
+24
View File
@@ -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